return_value gives a mock one answer for every call. Real collaborators are rarely that consistent: a flaky service fails twice then succeeds, a paginated API returns three pages then an empty one, a cache misses on the first lookup and hits on the second. side_effect accepts an iterable, and each call consumes the next item — returning it, or raising it if it is an exception. That single feature turns a mock from a constant into a script, and it is the standard way to test retry loops, pagination, and state-dependent behaviour without building a fake.
The feature has one sharp edge worth knowing before relying on it: when the iterable runs out, the next call raises StopIteration, which in generator and coroutine contexts can turn into something much stranger than a test failure. Sizing the sequence deliberately, and asserting it was consumed exactly, keeps that edge from ever cutting. This guide covers both forms of side_effect — iterables for behaviour that depends on call order and functions for behaviour that depends on arguments — along with the recurring scripts worth naming, and the point at which a script should give way to a fake.
Prerequisites
- Python 3.8+;
unittest.mockin the standard library. pytest >= 8.0.- The basics of mock configuration from deep dive into unittest.mock.
Solution
from unittest.mock import Mock
import pytest
def test_retry_succeeds_on_the_third_attempt():
fetch = Mock(side_effect=[
TimeoutError("attempt 1"), # raised
TimeoutError("attempt 2"), # raised
{"status": "ok"}, # returned
])
result = fetch_with_retry(fetch, retries=3)
assert result == {"status": "ok"}
assert fetch.call_count == 3 # the whole script was used
def test_retry_gives_up_after_the_limit():
fetch = Mock(side_effect=[TimeoutError] * 3) # classes are raised too
with pytest.raises(TimeoutError):
fetch_with_retry(fetch, retries=3)
assert fetch.call_count == 3 # and not a fourth time
def test_lookup_depends_on_the_argument():
users = {"u1": {"name": "Ada"}, "u2": {"name": "Alan"}}
def lookup(user_id):
# A function: behaviour driven by arguments, not call order.
if user_id not in users:
raise KeyError(user_id)
return users[user_id]
repo = Mock(get=Mock(side_effect=lookup))
assert greet(repo, "u2") == "Hello, Alan"
Why this works
When side_effect is an iterable, Mock converts it to an iterator at assignment and calls next() on it for each invocation. If the item is an exception class or instance, the mock raises it; otherwise it returns it. The mock's return_value is ignored while side_effect is set, so the script fully determines behaviour.
Because the iterator is created once at assignment, the script is stateful across the whole test: calls made during setup consume items just as calls made during the action do. That is usually what is wanted, and occasionally the cause of a confusing failure when a fixture happens to call the mock once before the test body runs. Assigning side_effect inside the test, immediately before the action, avoids the surprise.
When side_effect is a callable, the mock calls it with the same arguments it received and returns the callable's result — unless the callable returns the special mock.DEFAULT sentinel, in which case the mock falls back to its return_value. That lets a function handle a few special cases and defer everything else to a configured default. It is a small feature that keeps function-valued side effects short: the function only has to describe the interesting inputs, and every ordinary call falls through to the same default the rest of the test already relies on.
Edge cases and failure modes
- Running out. A fourth call against a three-item script raises
StopIteration. In a generator, Python converts that toRuntimeError: generator raised StopIteration, which looks nothing like a mock problem. Size scripts exactly and assertcall_count. - Mutable items. The same dictionary returned from two positions is the same object. If the code mutates it, the second call returns the mutated version. Use separate literals.
- Exception instances reused. Raising the same exception instance twice accumulates a traceback. Use classes, or separate instances.
side_effectandreturn_valuetogether.side_effectwins. Setting both is usually a sign one of them is left over from an earlier version of the test.- Async code. For
AsyncMock, each item is the result of the await. Exceptions are raised on await, not on call, which matters when asserting oncall_countversusawait_count.
Common scripts worth recognising
A handful of scripts recur across codebases, and recognising them makes the corresponding tests quick to write and easy to read.
Transient failure, then success — [ConnectionError, ConnectionError, response] — is the retry test. Its partner, permanent failure — [ConnectionError] * 3 with a retry limit of three — proves the loop gives up. Together they bound the retry behaviour from both sides, and both assert on call_count so neither over- nor under-retrying slips through.
Pagination — [page_1, page_2, empty_page] — tests that the consumer follows the sequence until the empty page and stops. The empty page matters as much as the full ones: without it the test cannot tell whether the consumer stopped because it saw the end or because the script ran out.
Cache miss, then hit — a spy whose underlying fetch returns once — belongs with spies rather than scripts, since the point is to observe that the second call never reached the backing store.
Rate limiting — [RateLimited(retry_after=2), response] — tests that the code honours a retry-after hint, ideally combined with a fake clock so the test asserts the delay requested rather than actually waiting for it.
Naming these patterns in a shared test-support module — transient_failure(times=2, then=response), pages(*items) — turns a script from a bare list into a statement of the scenario. It also centralises the one detail that is easy to get wrong in each: the trailing empty page, the exact retry count, the retry-after value. A reader seeing transient_failure(times=2, then=ok) understands the test's premise immediately, which is the whole reason for scripting the mock rather than faking the service.
Scripts versus fakes
A side_effect script is excellent for a collaborator whose behaviour in the test is a short, fixed sequence: three attempts, two pages, one miss then one hit. It becomes a liability when the sequence gets long or when it has to agree with other parts of the test. A twelve-item script modelling a paginated API with a cursor, where the items must match the cursor values the code sends, is really a fake written as a list — hard to read, easy to get subtly inconsistent, and impossible to reuse.
The signal to switch is when the script starts needing to know about its inputs. A function-valued side_effect covers the middle ground — behaviour depending on arguments, without a class — and a small fake covers the rest. The paginated case, for example, becomes a function that slices a list of records by the cursor it receives, which is shorter than the script, correct for any page size, and trivially reused by every test that paginates.
The rule of thumb that works well in practice: if the script has more than about five items, or if any item's correctness depends on an argument the code passes, write a function or a fake instead. The script's virtue is that it makes the scenario readable at a glance, and that virtue disappears once reading it requires cross-referencing the calls. Moving to a function or a fake at that point is not a failure of the technique but the natural next step, and it is usually a shorter change than expected because the scenario was already well understood from writing the script.
Frequently Asked Questions
What happens when a side_effect iterable runs out?
The next call raises StopIteration. In synchronous code that surfaces as a confusing error from inside the mock; in a generator or coroutine it can be converted into a RuntimeError or silently end iteration. Size the sequence to the expected number of calls, and treat running out as a test failure.
Can a side_effect sequence mix values and exceptions?
Yes. Each item is either returned or, if it is an exception class or instance, raised. [TimeoutError, TimeoutError, {"ok": True}] raises twice and then returns the dictionary, which is the canonical way to test retry logic.
When should side_effect be a function rather than a list? When the result depends on the arguments rather than on the call count — returning different users for different ids, or raising only for a particular input. A function receives the same arguments as the mock and its return value becomes the mock's.
Related
- Deep Dive into unittest.mock — how mocks are configured and what they record.
- Resolving side_effect and return_value Conflicts — the precedence rules between the two.
- Testing Retry and Backoff Logic Without Waiting — scripts plus a fake clock.
- Writing an In-Memory Fake Repository — where to go when the script grows too long.
← Back to Deep Dive into unittest.mock