skip to content

Why does Mock.call_args_list show a mutated list rather than the value at call time?

level: seniorimportance: nice to knowfreq 22%

answer

  1. The record does not copy anything
  2. Two names, one list object
  3. Every recorded call points at the same buffer
  4. Comparing an object against itself always passes
  5. Snapshot with copy.deepcopy at call time

basics

~20 s

A mock records a reference to each argument, never a copy. If the code mutates or reuses that object after the call, the recorded call reflects the object's current state, so assertions compare against whatever it holds now.

solid answer

~40 s

`call_args` and `call_args_list` store the argument objects themselves inside `call` tuples, so the record aliases live data. Code that fills one buffer, hands it to a collaborator and then clears it for the next batch leaves every recorded call pointing at the same emptied list — `call_args_list` prints `[call([]), call([])]`, and an assertion against the intended contents fails long after the call was correct. The mirror-image bug is worse: asserting against the *same* mutable object the code passed always passes, because both sides are one object. `unittest.mock.ANY` does not help, since it only widens the comparison. The fixes are to have the production code hand over an owned value, or to record with a double that snapshots the argument via `copy.deepcopy` at call time.

code

python · 11 lines
python
from unittest.mock import Mock

sink = Mock()
buf = []
for flight in ("KJ118", "KJ204", "KJ377"):
    buf.append(flight)
    sink.emit(buf)
    buf.clear()

print(sink.emit.call_count)        # 3
print(sink.emit.call_args_list)    # [call([]), call([]), call([])]

go deeper

for a junior

Remember that passing an object to a function passes a reference, not a copy, and that a mock's recorded arguments are those same objects. Mutating one afterwards changes what the test sees.

for a middle

Explain the mechanics: call_args_list holds call objects wrapping the original argument objects, so a reused buffer appears in its final state in every entry, and comparing an object with itself always succeeds.

for a senior

Diagnose it from the symptom — a call count that is right while every recorded argument looks wrong or suspiciously identical — and fix it at the source by handing collaborators an owned value rather than papering over it in the assertion.

for a principal

Own the ownership convention: which side owns a mutable payload at a module boundary, and how a false-passing assertion of this shape can keep a defect behind a green suite through a whole release cycle.

Mocks record arguments by reference. When a mock is called, `unittest.mock` builds a `call` object holding the positional tuple and keyword dict exactly as they arrived, and appends it to `call_args_list` (and to `mock_calls`, and sets `call_args`). Nothing is copied, and nothing is serialised. The record is therefore a set of live references to whatever objects the caller passed, and it stays live for the rest of the test. For immutable arguments — strings, numbers, tuples of those — this is invisible. For mutable ones it produces one of the more disorienting test failures in Python. ## The failing shape A flight-schedule differ walks a day's changes, accumulates each batch into one reusable list, hands it to a sink and clears the list for the next batch: ```python buf = [] for change in changes: buf.append(change) sink.emit(buf) buf.clear() ``` Every `emit` call receives the *same* list object. By the time the test inspects `sink.emit.call_args_list`, that object has been cleared, and every recorded call shows it in its final state: ``` [call([]), call([]), call([])] ``` The count and the ordering are right; the arguments are a lie about the past. `assert_called_with(["KJ118"])` fails with an error message showing an empty list, and the natural conclusion — "the code never passed the flight" — is wrong. The code passed it; the record simply does not remember what the object looked like at the time. ## The dangerous mirror image The same aliasing can make an assertion pass when it should fail. If the test builds the payload, hands it to the code under test, and then asserts `sink.emit.assert_called_once_with(payload)`, both sides of the `==` are one object. It compares equal to itself no matter what the code did to it in between — including if the code mutated it into something the sink should never have received. An assertion that cannot fail is worse than no assertion, because it looks like coverage. Mutation-in-flight also interacts badly with a swallowed exception: a handler that catches an error and resets the shared buffer on the way out leaves the record looking innocent, so a defect that silently drops a day's changes can sit behind a green suite for an entire release train before anyone notices the schedule feed is short. ## Why `ANY` is not the fix `unittest.mock.ANY` compares equal to anything, and it is the right tool for an argument you cannot predict — a generated identifier, a timestamp. It does nothing for aliasing: writing `call(ANY)` makes the assertion pass, but only by asserting nothing about the argument at all. Reaching for it here converts a confusing failure into a silent one. ## What actually fixes it **Change the production code to hand over an owned value.** `sink.emit(list(buf))` or building a fresh list per batch is usually the right call regardless of testing: passing a buffer you intend to keep mutating is a contract hazard for any collaborator, not just a mock. A real consumer that queues the batch for later has exactly the same bug, and the test is telling you about it. **Snapshot at call time when you cannot change the caller.** Replace the mock with a small recording double whose method stores `copy.deepcopy(arg)` — or, if you keep the mock, have it record through a callable that copies. Assert against the snapshots. Use `copy.deepcopy` rather than a shallow copy when the argument is nested, since a shallow copy still aliases the inner containers. **Assert immediately, before the mutation happens.** If the collaborator is called once and the mutation follows, checking `call_args` at that point sidesteps the problem entirely; this is often the smallest change. **Never assert against an object the code under test can reach.** Build the expected value independently — a fresh literal in the assertion — so the two sides of the comparison are genuinely separate objects. ## The general rule Test doubles remember *identity*, not *history*. Any recorded value that is mutable and still reachable by the code under test is a value the record cannot vouch for. The moment an assertion involves a list, dict, set, dataclass or buffer that outlives the call, ask who else holds a reference to it before you believe either the failure or the pass.

  • Why can asserting against the same object the code mutated make a test pass when it should fail?
    Because the mock stored a reference to that object, so the expected value and the recorded argument are the same object and `==` compares it with itself. Whatever the code did to it in between is invisible. Build the expected value independently — a fresh literal in the assertion — so the comparison has two distinct objects to compare.
  • Would using unittest.mock.ANY for the mutated argument solve this?
    No. `ANY` compares equal to anything, so the assertion passes without checking the argument at all — it turns a misleading failure into a silent one. `ANY` is for arguments you genuinely cannot predict, such as a generated identifier or a timestamp, and should be used for that single argument while the rest of the call stays pinned.
  • Beyond testing, why is passing a buffer you intend to clear a design problem?
    Because any collaborator that stores the argument rather than consuming it immediately sees it change underneath. A queue, a retry buffer, an async handoff or a background writer all break the same way the mock's record does. Handing over an owned value — a fresh list, a tuple, or a copy — makes the ownership explicit and removes a whole class of aliasing bugs.

saying these in an interview costs you the question

  • Believes a mock stores a copy or a snapshot of each argument
  • Concludes the code never passed the value because the record is empty
  • Reaches for mock.ANY to make the confusing assertion pass
  • Asserts against the same mutable object the code under test holds
  • Uses a shallow copy for a nested argument and still sees aliasing
  • Thinks reset_mock would restore the original argument values

context