Isolation & Contracts

Driving Mocks with side_effect Sequences

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

Solution

Python
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"
A side_effect sequence consumed call by call A list of three items: TimeoutError, TimeoutError and a success dictionary. The first call consumes the first item and raises it, the second call raises the second, and the third call returns the dictionary. A fourth call would find the list exhausted and raise StopIteration. Each call takes the next item off the script TimeoutError call 1 · raised TimeoutError call 2 · raised {"status": "ok"} call 3 · returned exhausted call 4 · StopIteration assert fetch.call_count == 3 catches a fourth call (over-retrying) and a second call only (giving up too early) — the two ways retry logic is usually wrong
The script defines what the collaborator does; the call-count assertion defines how many times the code under test was allowed to ask.

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 to RuntimeError: generator raised StopIteration, which looks nothing like a mock problem. Size scripts exactly and assert call_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_effect and return_value together. side_effect wins. 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 on call_count versus await_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.

Four recurring side_effect scripts Four named scripts. Transient failure then success tests that retries recover. Permanent failure tests that retries stop at the limit. Pagination ending in an empty page tests that the consumer stops at the end. Rate limiting with a retry-after hint tests that the requested delay is honoured, ideally with a fake clock. Name the scenario, not the list transient_failure(times=2, then=ok) [ConnectionError, ConnectionError, ok] retries recover permanent_failure(times=3) [ConnectionError] * 3 retries stop at the limit pages(p1, p2) [p1, p2, empty] the empty page is the point rate_limited(retry_after=2, then=ok) [RateLimited(2), ok] assert the delay, do not wait for it
Each helper encodes the one detail that is easy to get wrong in a hand-written list, and reads as a sentence at the call site.

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.

Choosing between a script, a function and a fake Three options by complexity. A short list suits fixed sequences of up to about five outcomes that do not depend on arguments. A function-valued side effect suits behaviour that depends on arguments. A fake class suits long or stateful behaviour, such as pagination with cursors, that many tests need. More dependence on inputs, more structure needed list script fixed sequence ≤ 5 outcomes retries, one miss then hit function result depends on args no persistent state lookups by id, conditional errors fake class stateful, long, reused many tests need it pagination, caches, stores
Moving right costs a little more code and buys correctness that no longer depends on the test author keeping a long list consistent by hand.

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.

← Back to Deep Dive into unittest.mock