Skip to content

unmock removes a spied or mocked shell function instead of restoring it #1401

Closed

Description

@Chemaclass

Problem

bashunit::unmock on a spied or mocked shell function removes the function instead of restoring it. The docs promise the opposite: "drops the function override, restoring the real command".

Reproduced on main 776997e9, macOS arm64, Bash 3.2.57 and Bash 5.3:

function test_unmock_restores_a_spied_function() {
  function greet() { echo real; }
  bashunit::spy greet
  greet
  bashunit::unmock greet
  assert_same "real" "$(greet 2>&1)"
}
function test_unmock_restores_a_mocked_function() {
  function wave() { echo real; }
  bashunit::mock wave echo mocked
  bashunit::unmock wave
  assert_same "real" "$(wave 2>&1)"
}

Both fail with greet: command not found / wave: command not found, and the test is also reported as an Error because of the shell diagnostic.

Where

src/doubles/spy.sh:224 and src/doubles/mock.sh:97-101 redefine the function with eval "function $command() { ... }" without saving the previous definition. src/doubles/mock.sh:18 (bashunit::unmock) runs unset -f "$command", which is right for an external command (the binary on PATH becomes visible again) and wrong for a function the test file or the code under test defined.

Why it matters

Spying on a function from the code under test, then releasing the spy to call the real implementation later in the same test, is the documented reason to call bashunit::unmock. Today that leaves the test with a missing function.

Suggested change

Before redefining, save the existing definition when declare -F "$command" succeeds (declare -f "$command" output, in a _BASHUNIT_DOUBLE_${variable}_ORIGINAL slot or a temp file). In unmock, eval the saved definition back when one exists, else unset -f. The same restore belongs in the per-test unwind only if it is cheap; the per-test subshell already discards redefinitions.

Acceptance criteria

  • After bashunit::unmock, a function that existed before bashunit::spy or bashunit::mock runs its original body.
  • A name that was an external command, or undefined, keeps today's behaviour: the function is removed.
  • The docs line about restoring stays true, and a test covers both the function and the command case and fails on the current code.

Proposed fix

Save the previous function body when a double replaces a shell function, and restore it in bashunit::unmock. Add one helper in src/doubles/mock.sh used by bashunit::spy (src/doubles/spy.sh:224), bashunit::mock (mock.sh:97-101) and bashunit::mock_sequence (mock.sh:177) just before their eval "function $command() ...":

# Keeps the definition a double is about to replace, so unmock can put it back.
function bashunit::doubles::save_original() {
  local command=$1 variable
  bashunit::helper::normalize_variable_name_to_slot "$command"
  variable=$_BASHUNIT_HELPER_VARNAME_OUT
  local saved_var="_BASHUNIT_DOUBLE_${variable}_ORIGINAL"
  [ -n "${!saved_var-}" ] && return 0
  if declare -F -- "$command" >/dev/null 2>&1; then
    export "$saved_var"="$(declare -f -- "$command")"
  fi
}

In bashunit::unmock (mock.sh:18), after unset -f "$command": if _BASHUNIT_DOUBLE_${variable}_ORIGINAL is set, eval "${!saved_var}" and unset "$saved_var". Public behaviour that must not change: unmocking an external command or an undefined name still just removes the function; the sandbox shim restore stays; a second spy or mock on the same name still replaces the first (the [ -n ] && return keeps the first original). Bash 3.0: declare -f name and ${!var-} exist on 3.0; no declare -n, no +=. Docs: docs/test-doubles.md under bashunit::unmock already promises the restore; add one sentence that this includes shell functions. CHANGELOG: one line under Unreleased, Fixed.

Agent brief

  • Branch: fix/1401-unmock-restores-function from fresh origin/main. Commit: fix(doubles): restore a spied or mocked shell function on unmock, body Closes #1401.
  • Files: src/doubles/mock.sh, src/doubles/spy.sh; tests in tests/functional/doubles_lifecycle_test.sh, next to the existing unmock tests, copying their define-double-unmock-assert shape.
  • RED first:
function test_unmock_restores_a_spied_shell_function() {
  function greet_1401() { echo real; }
  bashunit::spy greet_1401
  greet_1401
  bashunit::unmock greet_1401
  assert_same "real" "$(greet_1401 2>&1)"
}

function test_unmock_restores_a_mocked_shell_function() {
  function wave_1401() { echo real; }
  bashunit::mock wave_1401 echo mocked
  bashunit::unmock wave_1401
  assert_same "real" "$(wave_1401 2>&1)"
}

Run only that file: ./bashunit tests/functional/doubles_lifecycle_test.sh.

  • Non-goals: per-test cleanup in runner/hooks.sh (the test subshell already discards redefinitions); bashunit::spy still overrides the behaviour as documented; A spy records a multi-line argument as two calls #1399 and Call assertions pass against a recorded call that does not exist #1400 are separate.
  • Gate, once, when the change is complete: ./bashunit tests/, ./bashunit --parallel --simple --strict tests/, make sa, make lint. make lint is the formatting authority; never shfmt -w; 2-space indent.
  • Done when: every acceptance box is checked, the gate is green, CHANGELOG and docs are updated as listed above, and a PR is open with the pr skill.

Activity

  1. added
    p2Wrong behaviour users hit in normal use
    area:doublesCode under src or scripts for doubles
    agent-readyScoped, reproduced and testable; an agent can take it without more questions
    on Oct 28, 2026
  2. self-assigned this
    on Oct 29, 2026
  3. added a commit that references this issue on Oct 30, 2026
    1c9a6a0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

agent-readyScoped, reproduced and testable; an agent can take it without more questionsarea:doublesCode under src or scripts for doublesbugSomething isn't workingp2Wrong behaviour users hit in normal use

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions