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 |
|---|---|
|
|
|
|
|
|
|
|
|
the default; |
|
|
|
|
|
|
|
|
|
|
bare |
no translation by design: a mock requires a spec and fabricates nothing beyond it |
|
|
|
|
|
|
|
|
|
|
|
|
a |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
no equivalent by design; open |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.