Skip to content

feat(doubles): mock sequences and an assert_have_never_been_called #1023

Closed

Description

@Chemaclass

Problem

bashunit::mock (src/doubles/mock.sh:45) replaces a command with a fixed body or a fixed exit code, and that is all it can do. Two everyday testing needs have no expression:

1. Different results per call. Retry logic, polling loops and backoff are exactly the code that most needs testing, and they all require "fail the first time, succeed the second":

# there is no way to write this today
bashunit::mock curl --returns-in-sequence 1 1 0

The workaround is a hand-rolled counter file in each test, which every user reinvents.

2. Asserting a command was never called. The spy side can assert "called N times", but expressing "this destructive command must never run" means writing assert_have_been_called_times 0 rm, which reads backwards and — before #895 — silently passed for a command that was never spied at all.

Proposal

bashunit::mock_sequence <cmd> <body-or-code> [<body-or-code>…]
  • Each call consumes the next entry; the last entry repeats once exhausted (document this — the alternative, failing after exhaustion, is also defensible; pick one and test it).
  • Entries follow the existing convention that an all-digits value is an exit code and anything else is a body (bashunit::doubles::is_exit_code, src/doubles/mock.sh:37), so the two doubles keep reading the same way.
  • Registered in the same registry the runner unwinds after each test, so no leakage between tests.
assert_have_never_been_called <cmd>

Acceptance criteria

  • bashunit::mock_sequence cmd 1 0 returns 1 on the first call and 0 on the second
  • Behaviour after the sequence is exhausted is documented and tested
  • Bodies and exit codes can be mixed in one sequence
  • The sequence state resets between tests (no leakage), including under --parallel
  • bashunit::unmock clears a sequenced mock completely
  • A spy over a sequenced mock still records every call
  • assert_have_never_been_called cmd passes when the command was spied and never called
  • It fails, printing the call log, when the command was called
  • It fails when cmd was never spied (fix(doubles): call assertions silently pass when the command was never spied #895 rule)
  • The generated mock body stays free of injectable shell syntax (the reason is_exit_code exists — see the comment at src/doubles/mock.sh:33)
  • Bash 3.0 compatible; no per-assertion fork regression
  • Documented in docs/test-doubles.md; tests in tests/unit/doubles/ and tests/functional/doubles_test.sh

Repo checklist (agent)

  • TDD: RED → GREEN → REFACTOR. Write the failing test first.
  • Bash 3.0+ only: no printf -v, no += append, no declare -A, no [[ ]], no ${var,,}, no &>>, no ${arr[-1]}. Expanding a possibly-empty array under set -u needs ${arr[@]+"${arr[@]}"}.
  • A new CLI flag must be wired in all of these or a parity test fails:
  • Gates: make sa, make lint, ./bashunit tests/, ./bashunit --parallel tests/. Never run shfmt -w.
  • Docs: update docs/command-line.md. Editing docs/assertions.md invalidates the bashunit doc acceptance snapshot — regenerate it.
  • CHANGELOG.md: add one line under ## Unreleased.
  • Fixtures under tests/acceptance/fixtures/ must not end in *test.sh.
  • One issue = one PR.

Activity

  1. self-assigned this
    on Aug 12, 2026
  2. Chemaclass commented on Aug 12, 2026

    @Chemaclass
    MemberAuthor

    Done in #1070 (merged).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions