A service with five collaborators needs five doubles in every test that constructs it — and most tests care about one. The result, repeated across a suite, is dozens of lines per test configuring a repository, a clock, a notifier, a payment gateway and a feature-flag client, with the one line that matters buried in the middle. A factory function inverts that: it builds the service with sensible fakes for everything, and each test passes only the collaborator it is actually about.
The pattern needs one change in production code — the service must receive its collaborators rather than constructing them — and one small function in the test support package. Everything else follows, including a large reduction in how much each test has to know about the parts of the system it is not testing. That reduction is the real payoff: a test that states only the collaborator it is about is a test whose intent is obvious from its first line, and one that no unrelated change to the service's wiring can break.
Prerequisites
- A service that takes its dependencies through its constructor; see dependency injection for testability.
- Fakes for the main collaborators, as in writing an in-memory fake repository.
pytest >= 8.0.
Solution
# tests/support/billing.py
from dataclasses import dataclass
from myapp.billing import BillingService
from tests.support.fakes import (FakeClock, FakeGateway, FakeNotifier,
FakeOrderRepository, StaticFlags)
@dataclass
class Wired:
service: BillingService
repo: FakeOrderRepository
clock: FakeClock
gateway: FakeGateway
notifier: FakeNotifier
def make_billing(**overrides) -> Wired:
"""A BillingService with a fake for every collaborator unless overridden."""
parts = dict(
repo=FakeOrderRepository(),
clock=FakeClock(),
gateway=FakeGateway(),
notifier=FakeNotifier(),
flags=StaticFlags(),
)
parts.update(overrides) # the test's choices win
service = BillingService(**parts)
return Wired(service=service, **{k: v for k, v in parts.items() if k != "flags"})
def test_declined_payment_notifies_the_customer():
# Only the gateway matters here; everything else is a sensible default.
wired = make_billing(gateway=FakeGateway(decline_all=True))
wired.repo.add(an_order(id="ord_1", customer_id="cus_1"))
wired.service.charge("ord_1")
assert wired.notifier.sent == [("cus_1", "payment_declined")]
Why this works
The factory encodes the assembly of the service once, with a fake for every dependency. parts.update(overrides) lets a test replace any subset by keyword, and anything it does not mention falls back to the default. Because the defaults are fakes with real behaviour rather than bare mocks, the service works end to end in every test without configuration: orders can be saved and loaded, notifications recorded, time advanced.
Returning the collaborators alongside the service is what makes assertions natural. The test did not create the notifier, but it can still inspect wired.notifier.sent, because the factory hands back every part it used. Without that, the test would have to create every collaborator it might want to assert on, which reintroduces the boilerplate the factory exists to remove.
Edge cases and failure modes
- Bare
Mock()defaults. A mock default returns mock objects, so an untouched collaborator produces confusing downstream failures. Use fakes, or at leastcreate_autospec(..., spec_set=True)with realistic return values. - Shared default instances. Defaults created at module level are shared between tests, so state leaks. Construct defaults inside the factory, fresh per call.
- A factory per test file. Several slightly different factories drift apart. Keep one per service, in the test-support package.
- Overrides with typos.
make_billing(gatewy=…)would silently pass an unknown keyword to the constructor and fail with a confusing error. Checkoverrides.keys()against the known parts and raise with a clear message. - Growing the factory with scenario flags.
make_billing(declining=True, expired_card=True)turns the factory into a scenario engine. Keep it about wiring; express scenarios through the fakes' own constructors.
What the factory does to a test suite over time
The immediate effect is shorter tests. The longer-term effect is more interesting: the factory becomes the single place where the service's dependency graph is expressed for tests, and that changes how the suite responds to change.
When the service gains a new collaborator — an audit logger, say — without a factory, every test that constructs the service must be edited to pass one, and a large suite turns a one-line production change into a two-hundred-file diff. With a factory, the new collaborator gets a fake default in one place, every existing test keeps passing unchanged, and only the tests that are actually about auditing mention it. The cost of adding a dependency drops from proportional-to-the-suite to constant.
The same holds in reverse. Removing a collaborator, renaming a constructor parameter, or splitting one dependency into two all become edits to the factory and to the handful of tests that override that specific part. Tests that do not care about a collaborator are, by construction, insulated from changes to it — which is exactly the property a test suite should have, and the one hand-assembled services destroy.
A useful measure of whether the factory is doing its job is how many tests in the suite construct the service directly rather than through it. That number should be close to zero, and the exceptions should be the tests of the factory's defaults themselves. When direct construction creeps back in — usually because someone needed an unusual combination and found it quicker to write it out — it is worth adding the combination as an override instead, before the pattern of bypassing the factory spreads.
Providing the factory through a fixture
Some defaults need setup the factory cannot do on its own — a temporary directory for a file-backed store, a database session for the integration variant. The clean arrangement is a fixture that returns the factory itself, closed over whatever setup it needs, so tests still call it with their overrides.
import pytest
@pytest.fixture
def make_billing_with_db(db_session):
def factory(**overrides):
overrides.setdefault("repo", SqlOrderRepository(db_session))
return make_billing(**overrides)
return factory
@pytest.mark.integration
def test_charge_persists_the_payment(make_billing_with_db):
wired = make_billing_with_db()
wired.repo.add(an_order(id="ord_1"))
wired.service.charge("ord_1")
assert wired.repo.get("ord_1").status == "paid"
The unit tests keep calling make_billing with in-memory fakes; the integration tests call the fixture-provided variant, which swaps in the real repository and nothing else. Both share one assembly path, so a change to how the service is wired shows up in both at once rather than drifting between a unit-test factory and a hand-built integration setup.
Validating overrides
One small addition makes the factory noticeably friendlier: rejecting overrides it does not recognise. Because the overrides are forwarded to the service's constructor, a misspelt keyword produces a TypeError from deep inside BillingService.__init__, naming an unexpected argument without saying where it came from. Checking the keys up front turns that into a precise message at the call site.
KNOWN = {"repo", "clock", "gateway", "notifier", "flags"}
def make_billing(**overrides) -> Wired:
unknown = set(overrides) - KNOWN
if unknown:
raise TypeError(f"make_billing got unknown overrides {sorted(unknown)}; "
f"expected some of {sorted(KNOWN)}")
...
The known set doubles as documentation of what the service depends on, and keeping it next to the defaults means adding a collaborator is one edit to one place. It is the same principle as spec_set on a mock: fail at the moment a name is wrong, with the wrong name in the message, rather than somewhere downstream where the mistake is no longer visible.
The last habit worth adopting is to give the factory's defaults the most boring behaviour available. A gateway that approves every charge, a notifier that records and succeeds, a clock fixed at a known instant, flags all at their production defaults. Boring defaults mean a test that does not mention a collaborator gets the happy path from it, and every test that needs something unusual says so explicitly in its override — which is exactly where a reader will look for it.
Frequently Asked Questions
How is a factory function different from a pytest fixture? A fixture is built before the test runs and cannot take per-test arguments without indirection. A factory function is called by the test with the overrides it needs, so each test decides which collaborator to replace. The factory can itself be provided by a fixture when it needs setup.
Should the factory's defaults be mocks or fakes?
Fakes wherever one exists. A fake with real behaviour makes the default service usable in any test without configuration; a default Mock returns Mock objects that often cause confusing failures in tests that never meant to involve that collaborator.
Does production code need to change for this? Only to accept its collaborators rather than constructing them. Once a service takes its dependencies in its constructor, the factory is purely test-support code that assembles it with fakes by default.
Related
- Dependency Injection for Testability — the constructor change this pattern depends on.
- Injecting Fakes vs Mocks in Constructors — choosing what the defaults should be.
- Injecting a Clock Instead of Patching datetime — the FakeClock used as a default here.
- Test Data Factories & Builders — the same defaults-plus-overrides idea for data.
← Back to Dependency Injection for Testability