01 · Test Architecture at Scale¶
Every technique in Levels 1–3 answers "how do I write this test?" This module answers a different question: once a project has thousands of tests across unit, API, UI, and performance layers, how do you organize them so the suite stays fast, reliable, and trustworthy instead of becoming a two-hour liability nobody wants to run?
1. The test pyramid, in actual code¶
# unit layer — pure logic, no I/O, milliseconds
def calc_total(items):
return sum(i["price"] * i["qty"] for i in items)
import pytest
@pytest.mark.unit
def test_calc_total():
assert calc_total([{"price": 10, "qty": 2}, {"price": 5, "qty": 1}]) == 25
# service/integration layer — components wired together, still no network
class OrderService:
def __init__(self):
self.orders = {}
def place_order(self, order_id, items):
total = calc_total(items)
self.orders[order_id] = total
return total
@pytest.mark.integration
def test_place_order():
svc = OrderService()
total = svc.place_order("o1", [{"price": 10, "qty": 3}])
assert svc.orders["o1"] == 30
assert total == 30
$ pytest test_pyramid_demo.py -v -m unit
test_pyramid_demo.py::test_calc_total PASSED
1 passed, 1 deselected
$ pytest test_pyramid_demo.py -v
test_pyramid_demo.py::test_calc_total PASSED
test_pyramid_demo.py::test_place_order PASSED
2 passed
Note the actual warning this run produced:
That's pytest telling you something real: a marker used without being
registered in pytest.ini/pyproject.toml is indistinguishable from a typo
at scale. Section 3 fixes this properly — it's exactly the kind of small gap
that turns into a silently-broken filter (-m intgration matching nothing,
with no error) once a suite has hundreds of contributors.
2. Why the pyramid shape, not a snapshot of what to test¶
The pyramid isn't about ratios for their own sake — it's about cost and feedback speed. A unit test runs in milliseconds and pinpoints the exact function that broke. A UI test (Level 3 Module 1) might take seconds, needs a browser, and a failure could mean the UI broke, the API broke, the network was slow, or the test itself is flaky (Level 3 Module 9) — much more to untangle. A healthy suite has far more of the cheap, precise layer and far fewer of the expensive, ambiguous one, because that's the only shape that lets you run "the suite" in minutes instead of hours.
3. Registering markers and separating suites by directory¶
# pytest.ini
[pytest]
markers =
unit: fast, no I/O
integration: multiple components, still in-process
e2e: full stack, real browser or real network
testpaths = tests
tests/
├── unit/
│ └── test_calc_total.py
├── integration/
│ └── test_order_service.py
└── e2e/
└── test_checkout_flow.py
Directory separation plus registered markers gives you two independent ways
to select a slice of the suite — pytest tests/unit for the fastest possible
signal, pytest -m e2e for a pre-release gate — and re-running
pytest -v after registering the marker produces zero warnings, confirming
the fix.
4. Shared fixtures without shared coupling¶
A large suite's biggest architectural risk is a conftest.py that every test
file depends on, making the whole suite fragile to one change. The fix is
scoping conftest.py files to match the directory structure:
tests/
├── conftest.py # truly universal fixtures only (e.g. a temp dir)
├── unit/
│ └── conftest.py # unit-only fixtures
├── integration/
│ └── conftest.py # a test database, an in-memory service registry
└── e2e/
└── conftest.py # a browser fixture (Level 3 Module 1), base_url
pytest resolves fixtures by walking up the directory tree, so an e2e/
test never even sees unit/conftest.py's fixtures, and a change scoped to
one layer's fixtures can't accidentally break another layer's tests.
5. Test doubles as an architectural boundary, not just a mocking trick¶
Level 2 Module 4 covered unittest.mock mechanically. At scale, the more
important decision is where the boundary between real and faked
dependencies sits, and keeping it consistent:
# A clear seam: OrderService depends on an abstract PaymentGateway,
# not directly on a specific SDK.
class PaymentGateway:
def charge(self, amount): raise NotImplementedError
class StripeGateway(PaymentGateway):
def charge(self, amount):
... # real network call
class FakePaymentGateway(PaymentGateway):
def __init__(self):
self.charges = []
def charge(self, amount):
self.charges.append(amount)
return {"status": "success"}
Unit and integration tests inject FakePaymentGateway; only a small,
dedicated set of contract/e2e tests (Level 4 Module 3 covers this in depth)
ever touches StripeGateway. This is the architectural payoff of dependency
injection: the test decides which implementation runs, and the production
code never has an if TESTING: branch anywhere in it.
6. Testing-specific traps¶
Trap 1 — an inverted pyramid. A suite with hundreds of slow E2E tests and almost no unit tests looks thorough but is expensive and slow to run, and a single failure is hard to localize. If your CI run takes 40 minutes and most of that is UI tests, that's an architecture problem, not a "buy faster CI runners" problem.
Trap 2 — global conftest.py bloat. Every fixture that "might be useful
elsewhere" ending up in the root conftest.py eventually makes every test
file implicitly coupled to every other layer's setup — a change to an e2e
fixture can break a unit test's collection phase entirely. Push fixtures down
to the narrowest conftest.py they're actually needed in.
Trap 3 — unregistered or inconsistent markers across teams. As seen in
section 1's actual warning, an unregistered marker silently fails to alert
you to typos. At scale, pytest --strict-markers turns that warning into a
hard collection error — worth enabling once markers are standardized, so a
new contributor's typo is caught immediately instead of silently excluding
tests from a CI filter.
Trap 4 — treating "more tests" as strictly better. Two tests that assert the same thing at different layers (a unit test and an E2E test both checking "total is calculated correctly") add maintenance cost without added confidence. Each layer should test what only that layer can catch: unit tests for logic, integration tests for component wiring, E2E tests for real user flows across the whole stack.
How It Actually Works¶
The testing pyramid (many unit, fewer integration, fewest end-to-end) isn't a style preference — it's dictated by the process/IO cost curve you've now seen the mechanics of across this whole path: a unit test's cost is a bare eval-loop call (Level 1), an integration test's cost adds real socket/database I/O (Level 2), and an end-to-end test's cost adds a full browser process plus WebDriver/CDP protocol overhead (Levels 1-3). Architecting a test suite around this shape means deliberately pushing as much verification as possible down to the cheapest layer that can actually catch the bug — a business-rule edge case belongs in a unit test not because "best practice says so" but because paying browser-launch cost to verify pure logic wastes CI minutes on overhead the fast layer already covers.
A well-architected suite also separates test doubles by boundary, mirroring exactly
where your production code crosses a real process/network boundary — unittest.mock
patches (Level 2) at each such boundary give you the pyramid's middle layers without
the pyramid's cost, because you keep the call graph's shape while removing the actual
I/O. The architectural discipline is knowing precisely which boundaries in your
dependency graph are safe to fake (external services, wall-clock time, randomness)
versus which ones a test should cross for real (your own database schema, your own
API contract) — faking your own contract just tests your mocks' assumptions about
themselves.
Cheat sheet¶
| Layer | Tests | Speed | Failure tells you |
|---|---|---|---|
| Unit | pure functions, no I/O | ms | exactly which function broke |
| Integration | components wired together, in-process | ~seconds | wiring/contract mismatch between components |
| E2E | full stack, real browser/network | seconds–minutes | something in the whole user flow broke (needs more digging) |
| Enforce the split | pytest.ini markers + testpaths |
— | -m unit / -m e2e selects a layer |
| Keep suites decoupled | directory-scoped conftest.py |
— | a layer's fixture change can't break another layer |
| Catch marker typos | --strict-markers |
— | unregistered markers become collection errors, not warnings |
Exercise¶
- Reorganize a small project's tests into
tests/unit,tests/integration,tests/e2edirectories, each with its ownconftest.py, and register all three markers inpytest.ini. - Run
pytest --strict-markerswith one deliberately misspelled marker and paste the exact collection error it produces (versus the warning shown in section 1 without--strict-markers). - Introduce a
PaymentGatewayabstract base and aFakePaymentGatewayas in section 5, and write one unit test and one integration test that both use the fake — confirm neither test makes a real network call. - Time your full suite with
pytest --durations=10and identify which layer dominates total runtime; propose (in a comment) one test you'd move down a layer to speed up the suite without losing coverage. - Write a short (150-word) architecture note explaining, for a team of five
new contributors, which
conftest.pya new fixture should go in and why — use the directory structure from section 4 as your reference.