Coming from unittest.mock

unittest.mock substitutes objects: a patched attribute becomes a Mock, and the test asserts against what the Mock recorded. wrapture defaults to the opposite strategy: wrap the real code and intervene in flight, with call signatures checked, real return values recorded, and order and nesting kept across everything observed. When a test does want a substitute, substitution is a deliberate, scoped opt-in rather than the silent baseline: stub() supplies one callable, mock(Spec) one collaborator, both strict and recorded. The one thing wrapture does not provide is fabrication without a spec; the end of this page says why.

The quick translation, expanded below:

unittest.mock

wrapture

@patch("pkg.mod.fn") as a decorator, mock injected

@bound("pkg.mod", "fn") as a decorator, binding injected by name

@patch.dict(os.environ, {"K": "v"}) as a decorator

@bound(os.environ, item="K").overrides("v")

patch.object(C, "m", return_value=v)

binding(C, "m").on_call.returns(v)

patch.object(C, "m", side_effect=exc)

binding(C, "m").on_call.raises(exc)

patch.object(C, "m", autospec=True, ...)

the default; strict=False to opt out

side_effect=fn (fabricate per call)

on_call.decorates(fn)

side_effect=[a, b, exc] (a sequence)

on_call.returns_from([a, b]), then then().raises(exc)

Mock(wraps=real)

on_call.transforms_args(fn) / transforms_result(fn) / decorates(fn)

patch.multiple(C, a=..., b=...)

bindings(a=wrapture.binding(C, "a"), b=wrapture.binding(C, "b")), per-member behaviour

Mock(spec=Connection) / create_autospec(Connection) as a collaborator

mock(Connection): the spec’d path is the only path

bare Mock() as a collaborator

no translation by design: a mock requires a spec and fabricates nothing beyond it

AsyncMock inside a spec’d collaborator

mock(Connection): each method’s kind comes from the spec

Mock() supplied as a callable (a hook, a receiver, a callback)

stub(); stub("label") to name its events

Mock(return_value=v) handed over as a callable

stub(returns=v); stub(raises=exc) for side_effect=exc

Mock(side_effect=fn) handed over as a callable

observed(fn): the function itself is the behaviour, its calls recorded

create_autospec(fn) supplied as a callable

stub(mimics=fn): signature-checked, arguments by name

AsyncMock() supplied as a coroutine callable

stub(kind="coroutine"), or stub(mimics=fn) with the kind inferred

a Mock faking an iterator, __next__ scripted with side_effect=[a, b, StopIteration()]

stub(kind="generator", returns=[a, b]): a real generator, exhaustion included

m.assert_called_once_with(a=1)

events.with_args(a=1).assert_once()

m.call_args_list

events (filterable) or tape.all

m.assert_not_called()

events.assert_never() or expect_never()

AsyncMock(return_value=v) / side_effect=exc

on_call.returns(v) / raises(exc) on the async def: delivered on await, as the real method would

m.assert_awaited_once(), m.await_count

events.finished().assert_once(), events.finished().count

m.assert_not_awaited()

events.finished().assert_never(); events.pending() is what was called and never awaited

m.reset_mock()

no equivalent by design; open timeline() around the step to assert on (tapes are not cleared)

m.assert_has_calls([call(a), call(b)])

tape.assert_order(m.events.with_args(...), m.events.with_args(...), consecutive=True)

m.mock_calls == [call(a), call(b)]

tape.assert_order(..., exact=True)

manager.attach_mock(...) then manager.mock_calls for cross-mock order

tape.assert_order(charge, record), any bindings, arguments optional

patch.dict(os.environ, {"K": "v"}), monkeypatch.setenv("K", "v")

binding(os.environ, item="K").overrides("v")

monkeypatch.delenv("K"), monkeypatch.delitem(d, "k")

binding(os.environ, item="K").hides(), binding(d, item="k").hides()

patch.dict(d, {"k": v}), monkeypatch.setitem(d, "k", v)

binding(d, item="k").overrides(v), or binding(d, mode="mapping").updates({"k": v}) for several at once

patch.dict(d, {...}, clear=True)

binding(d, mode="mapping").overrides({...}); overrides({}) for an empty d

monkeypatch.setattr(mod, "CONST", v), patch("mod.CONST", v)

binding("mod", attr="CONST").overrides(v); or binding("mod", "CONST").on_get.returns(v) to also see each read

patch.dict(registry, {"GET": fake}) for a callable

binding(registry, item="GET", mode="callable").on_call... (wrapped, recorded, removable)

Stubbing a return value

Both tools do this well, and for a pure stub there is little to choose between them:

# unittest.mock
from unittest.mock import patch

with patch.object(Gateway, "charge", return_value={"id": "stub"}):
    order_service.place("widget", 1)
# wrapture
import wrapture

with wrapture.binding(Gateway, "charge").on_call.returns({"id": "stub"}):
    order_service.place("widget", 1)

One of mock’s habits deserves a warning even here. A MagicMock fabricates attributes on demand, so the moment the substitution is any wider than one method (patching the class itself, or injecting Mock() as a collaborator), every method on the fabricated object succeeds with a fabricated result, and a misspelled method call passes silently unless spec checking was configured. wrapture has no spec-less fabrication anywhere: a binding names one real attribute and a misspelled name raises AttributeError at creation; a collaborator double is built with mock(Connection) from the named class and answers to exactly its surface; and everything a binding does not explicitly change stays the real code.

The same goes for the call itself. mock validates a stubbed call’s arguments against the real signature only if autospec=True (or create_autospec) was asked for; without it, charge(500, bogus=True) returns the stub happily and the drift shows up in production. A wrapture binding is strict by default: a call that returns(), raises() or decorates() would answer without reaching the real method is bound to the method’s signature first and raises TypeError as the real call would. binding(..., strict=False) turns that off for the rare patch that means to accept a different shape.

Injecting a failure

Again equivalent on the surface:

# unittest.mock
with patch.object(Gateway, "charge", side_effect=TimeoutError("down")):
    ...
# wrapture
with wrapture.binding(Gateway, "charge").on_call.raises(TimeoutError("down")):
    ...

In both versions the patched method itself never runs: the call raises instead, exactly as with mock’s side_effect. The difference is everything around the failure. In the mock version the rest of the pipeline is typically also mocked, so the test can only assert that the exception propagated. With wrapture only the one bound method is replaced and the rest of the pipeline stays real: the reservation is really taken, the ledger write really does or does not happen, and by recording on a timeline the test can assert on what the rest of the system did about the failure.

A sequence of outcomes

mock’s side_effect also takes a list, consumed one entry per call, with exceptions raised and anything else returned:

# unittest.mock
with patch.object(Gateway, "charge", side_effect=[{"id": "A"}, TimeoutError("down"), {"id": "B"}]):
    ...

wrapture keeps values and exceptions apart. returns_from() hands out successive values from an iterable, lazily, and then() adds a phase that takes over, here once the sequence is exhausted; each phase has the full behaviour vocabulary, so the third outcome is another phase in turn:

# wrapture
charge = wrapture.binding(Gateway, "charge")
charge.on_call.returns_from([{"id": "A"}])

down = charge.on_call.then()
down.raises(TimeoutError("down"))

back = down.then(after=1)
back.returns({"id": "B"})

More lines for the same three outcomes, but each phase says what it is, and the same shape covers what a list cannot: then(after=n) and then(until=fn) change behaviour on a count or on a condition seen in the calls, advance() moves on from the test, and binding.phase and in_phase(n) on the recording tell you which regime a call ran under. Phased behaviour has the details.

Patching several methods at once

Mock does this too: stack the context managers, or use patch.multiple(). Each patched attribute becomes its own Mock, configured separately:

# unittest.mock
with (
    patch.object(Gateway, "charge", return_value={"id": "stub"}),
    patch.object(Gateway, "refund", side_effect=TimeoutError("down")),
):
    ...

With wrapture, several bindings are a group: one object, one lifecycle, each member carrying its own behaviour, and the members need not even be on the same class:

# wrapture
group = wrapture.bindings(charge=wrapture.binding(Gateway, "charge"),
                          refund=wrapture.binding(Gateway, "refund"),
                          record=wrapture.binding(Ledger, "record"))
group.charge.on_call.returns({"id": "stub"})
group.refund.on_call.raises(TimeoutError("down"))

with group:
    ...

Two things here have no mock equivalent. The record member has no behaviour at all: it stays the real method and is there to be observed, so inside a timeline the group mixes stubbed, failing, and purely-watched methods in one declaration. And the group never half-applies: if any member fails to apply, the ones already applied are removed again, where a stack of patch.object managers that fails midway unwinds only through the ordinary context manager machinery. Because bindings wrap rather than replace, several bindings can also stack on the same method, composing with wrappers other parties installed.

Setting a value rather than replacing a call

Not every patch is a call. patch.dict(os.environ, {...}), monkeypatch.setenv, monkeypatch.setitem and monkeypatch.setattr(mod, "CONST", v) put a value somewhere for the duration of a test. wrapture spells all of these as a value binding: name the owner, name the slot with attr= or item=, and say what it holds:

# unittest.mock / pytest
with patch.dict(os.environ, {"API_KEY": "sk_test"}):
    ...
monkeypatch.setattr(config, "TIMEOUT", 0.5)
monkeypatch.delenv("DEBUG", raising=False)
# wrapture
with wrapture.binding(os.environ, item="API_KEY").overrides("sk_test"):
    ...
wrapture.binding("config", attr="TIMEOUT").overrides(0.5)
wrapture.binding(os.environ, item="DEBUG").hides()

The mechanics are the same as patch.dict: the owner is changed in place and restored on exit, so a settings dict imported elsewhere sees the change. What a value binding adds is the binding lifecycle: it is a context manager, it can be suspended and resumed, it goes in a bindings() group with everything else the test patches, and the pytest plugin’s leak sweep reports one left applied. It records nothing, and says so, since there is no call to observe. See Value bindings.

patch.dict(d, {...}) on a whole mapping, merging several entries or with clear=True replacing the content outright, is a mapping binding, mode="mapping" on the dict: updates({...}) merges, overrides({...}) replaces, and overrides({}) empties it. As with patch.dict the one dict is changed in place, so every holder of it sees the change, and the original entries come back on exit:

# unittest.mock
with patch.dict(config.SETTINGS, {"currency": "EUR"}, clear=True):
    ...
# wrapture
with wrapture.binding(config, "SETTINGS", mode="mapping").overrides({"currency": "EUR"}):
    ...

See Mapping bindings.

Running the real code while modifying the call

This is where substitution runs out of road. Mock(wraps=real) forwards calls but cannot modify the arguments the original receives, cannot post-process its result, and create_autospec(spec, wraps=real) accepts wraps and ignores it. The stdlib has no way to say “run the real method, but change one thing”.

With wrapture this is the ordinary case:

# run the real charge, but pin its result id for stable assertions
with wrapture.binding(Gateway, "charge").on_call.transforms_result(
    lambda r: {**r, "id": "ch_TEST"}
):
    ...

# run the real charge, but force the sandbox currency on the way in
with wrapture.binding(Gateway, "charge").on_call.transforms_args(
    lambda args, kwargs: (args, {**kwargs, "currency": "EUR"})
):
    ...

Seeing calls an object makes to itself

patch.object(OrderService, "_take_payment") replaces the method, so the real payment logic no longer runs. But a Mock injected as a collaborator cannot see self._take_payment() at all: the call never crosses the seam the double sits behind. wrapture wraps the method on the class, so an internal self-call passes through the wrapper like any other call, with the real code still running.

Asserting on what happened

unittest.mock records calls on the mock and asserts with assert_called_once_with() and friends: a flat call list, arguments by reference, no return values. wrapture records on a timeline, through the same bindings that intervene:

with wrapture.timeline(charge, record):
    place_order("widget")

    charge.events.with_args(amount=500).assert_once()
    record.events.raising(TimeoutError).assert_never()

Events carry signature-normalized arguments, real return values (which mock does not record), exceptions, nesting and ordering, and the same handle asserts on them. Two habits transfer directly, upgraded: argument matching is by parameter name against the normalized call, so with_args(amount=500) matches however the caller spelled it, and keywords the target collects in **kwargs match the same way, so a mock-style assert_called_with(..., priority=5) against a def dispatch(job, **options) target translates to with_args(priority=5) unchanged; and where mock’s misspelled assert_calld_once famously passed silently for years, a misspelled wrapture assertion is an AttributeError.

One thing here has no mock counterpart at all: a patched class method in mock is one Mock, so calls made on different instances merge into a single call list and the test cannot say which object was charged. A binding on the class records the bound instance per event, and with_instance(obj) filters to the calls made on exactly that object (by identity, so even two equal-but-distinct instances stay apart).

Order is the other habit. mock’s assert_has_calls([call(500), call(500)]) checks a contiguous run of calls on one mock, and mock_calls == [...] the exact list; across several mocks, ordering needs them attached to a parent mock whose mock_calls is then compared by hand. wrapture asserts order on the tape, across any bindings, with filtered logs saying which calls:

# unittest.mock
charge.assert_has_calls([call(500), call(500)])
assert manager.mock_calls == [call.charge(500), call.charge(500), call.record("failed")]
# wrapture
charge_500 = charge.events.with_args(amount=500)
tape.assert_order(charge_500, charge_500, consecutive=True)
tape.assert_order(charge_500, charge_500, record.events.with_args(status="failed"), exact=True)

Without a flag assert_order is a subsequence check, other events allowed between; consecutive=True is assert_has_calls, and exact=True is the mock_calls comparison, each concerned only with the bindings the steps name.

The unit testing page covers the workflow: filters, assertions, declared expectations, the call tree, and the pytest plugin.

Async code

AsyncMock exists for two reasons: a plain Mock in place of an async def returns a value that cannot be awaited, and the bug async tests most need to catch is a call that was never awaited, which assert_awaited_once() and assert_not_awaited() name separately from assert_called_once(). wrapture covers both without a separate kind of binding. A stub on an async def target follows the target’s convention, returns() and raises() arriving on await, and an event has a completion separate from its call, so events.finished() is the awaited subset and events.pending() what was created and forgotten:

# unittest.mock
with patch.object(Notifier, "send", new_callable=AsyncMock) as send:
    await service.notify("hello")
    send.assert_awaited_once_with("hello")
# wrapture
with wrapture.timeline(send):
    await service.notify("hello")
    send.events.with_args(message="hello").finished().assert_once()
    send.events.pending().assert_never()

And with wrapture the real send ran, or was stubbed in place with the rest of the code still real, per everything above.

Asserting on log messages

The comparison extends one step past unittest.mock, to pytest’s caplog fixture, because the shape is the same. caplog attaches a handler at the root logger and hands the test a flat list of records, so the strongest assertion it supports is “this message was logged at some point during the test”. wrapture’s capture_logs() records messages as events on the same tape as the calls, which keeps the flat assertion one line, and adds the one caplog cannot express, position:

logs = wrapture.capture_logs("myapp.orders")

with wrapture.timeline(charge, logs) as tape:
    place_order(declined_card)

    logs.events.at_level("WARNING").with_message("*declined*").assert_once()

    # The assertion caplog has no words for: the warning was logged
    # by this call, not merely somewhere during the test.
    warning = logs.events.at_level("WARNING").first
    assert tape.parent_of(warning) is charge.events.first

Capture also does not touch the handler configuration: it hears each record on the logger that emitted it, before propagation, so a library that sets propagate = False is captured the same as any other, with no fixture-side forcing. The unit testing page covers the full selection and filtering surface.

Where unittest.mock still fits

Everything above follows one rule with one opt-out. The rule: wrap the real code, strictly, and record what actually flowed. The opt-out: when the test itself must supply the thing being called, stub() supplies one callable and mock(Spec) one collaborator, still strict (signatures checked, surfaces fixed by the spec) and still recorded on the same tape as everything else.

What wrapture deliberately does not provide is the spec-less form: a bare Mock() or MagicMock whose attributes exist on first touch and whose call chains all answer. A fabricated object that answers everything verifies nothing; the misspelled method, the drifted signature and the unconfigured chain all pass silently, and that is a bug farm, not a feature gap. A test that cannot name the class it is substituting has a question to answer before it has a double to build.

That leaves unittest.mock two genuine holds. It is in the standard library: zero dependencies, universally understood, on every team’s common ground, and sometimes that alone decides. And it is the tool for fabrication without a spec, where a test wants an object invented as it is touched. The two libraries coexist happily in one suite; translating between them is what the table at the top is for.