A good test suite is not the one with the most tests. It is the one that tells you quickly whether users can still do the things they pay you for. Test risky boundaries, keep most checks cheap, and refuse to preserve implementation trivia.
🎙️ Published & recorded: ·
Start where information changes shape or ownership: an HTTP request becomes a command, money becomes stored integer cents, your code calls a payment service, or a database transaction commits. Those seams contain more real bugs than a private helper that joins two strings. For each boundary, cover one normal case, one refusal, and one awkward edge.
# pytest: observable behavior at the HTTP boundary
def test_rejects_zero_quantity(client):
response = client.post("/orders", json={"sku": "A7", "quantity": 0})
assert response.status_code == 422
assert response.json()["error"] == "quantity must be at least 1"Do not begin by testing getters, framework wiring, or language behavior. If deleting a test would not make a plausible regression scarier, that test is probably inventory, not protection.
The useful idea behind the pyramid is economic: cheap deterministic checks should outnumber slow broad checks. It is not a quota. A parser library may deserve thousands of unit tests and almost no browser tests. A checkout flow deserves a handful of real browser paths because the browser, API, database, and payment handoff are the product.
# A practical portfolio, not a mandated ratio
unit many milliseconds pure rules and transformations
integration enough seconds database, queue, filesystem, adapters
end-to-end few minutes revenue and account-critical journeysDo not mock a system you need confidence in. If PostgreSQL syntax, transaction behavior, or constraints matter, run PostgreSQL in an integration test. Keep end-to-end tests few because failures are expensive to localize, not because a triangle on a conference slide said so.
Arrange creates the smallest meaningful world. Act performs one behavior. Assert checks the outcome that matters. The pattern is valuable because a reader can spot the stimulus and consequence in seconds. It is not a demand for comments or exactly one assertion. Several assertions about one returned invoice are still one reason to fail.
def test_applies_member_discount():
# Arrange
cart = Cart(items=[Item(price_cents=2500)], member=True)
# Act
total = cart.total()
# Assert
assert total.subtotal_cents == 2500
assert total.discount_cents == 250
assert total.due_cents == 2250AssertionError: assert 2249 == 2250
+ where 2249 = Total(...).due_centsOne: rerun this single test to prove it is stable. Two: inspect the first differing value, here two thousand two hundred forty-nine versus two thousand two hundred fifty. Three: trace where rounding occurs. Four: fix the production rounding rule or correct the expected business rule; never add one to the assertion just to turn the build green.
A test should survive a refactor that preserves behavior. Assert the receipt, stored row, emitted event, or returned error. Avoid asserting private method order and every internal call. For collaborators, a stub returns prepared data, a fake implements a small working version, and a spy records an interaction. Use a mock expectation only when the interaction itself is the contract, such as sending exactly one refund request.
class FakeGateway:
def __init__(self): self.charges = []
def charge(self, cents):
self.charges.append(cents)
return {"id": "ch_test_1", "status": "paid"}
def test_checkout_charges_the_final_total():
gateway = FakeGateway()
receipt = checkout(Cart.totaling(4200), gateway)
assert receipt.payment_id == "ch_test_1"
assert gateway.charges == [4200]A fake payment gateway is excellent for domain tests and insufficient for proving your real adapter sends the provider's required headers. Give the adapter a contract or sandbox integration test. A double buys speed by giving up realism; say clearly which realism you gave up.
Sleeping is not synchronization. A test that waits five hundred milliseconds hopes the worker finishes in time; under load, sometimes it will not. Await the task, poll a durable state with a deadline, or expose a completion signal. Inject a clock instead of freezing global time, then advance it explicitly.
// Jest: await the promise and control the clock
test("expires a reset token after 15 minutes", async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-07-25T10:00:00Z"));
const token = await issueResetToken("user_7");
jest.setSystemTime(new Date("2026-07-25T10:16:00Z"));
await expect(redeem(token)).rejects.toThrow("token expired");
jest.useRealTimers();
});Jest did not exit one second after the test run has completed.
This usually means that there are asynchronous operations that weren't stopped.One: rerun with --detectOpenHandles. Two: inspect the reported server, socket, timer, or database pool. Three: close that resource in afterEach or afterAll and await the close operation. Four: restore real timers. Do not use force exit; it hides leaked production resources.
Repository tests should run against the same database engine as production. SQLite is not a small PostgreSQL; constraints, JSON, locking, and transaction behavior differ. Start an isolated database, apply the real migrations, and reset state per test. At service boundaries, validate requests and responses against a shared schema so producer and consumer disagreements fail before deployment.
# Integration test: prove the database enforces the invariant
def test_email_is_unique(db):
db.users.insert(email="[email protected]")
with pytest.raises(UniqueViolation):
db.users.insert(email="[email protected]")
# Consumer contract: fields actually used by this client
{ "id": "usr_12", "plan": "pro", "active": true }fixture 'db' not found
available fixtures: capfd, caplog, capsys, monkeypatch, tmp_pathOne: run pytest --fixtures -q and confirm the fixture is absent. Two: make sure conftest.py sits in this test directory or a parent directory. Three: check that the plugin providing the fixture is installed and enabled. Four: fix the fixture name or scope. Do not add an empty db fixture; that only converts a setup error into a misleading test.
A red test can mean a production regression, a wrong expectation, broken setup, polluted state, or an unreliable test. First run only that test. Then read the first useful application frame and the complete expected-versus-actual diff. Reproduce it twice before touching code. If it passes alone, investigate order dependence and leaked state.
# Narrow first; add detail only when needed
pytest tests/orders/test_discount.py::test_member_discount -vv
pytest tests/orders/test_discount.py::test_member_discount -vv --showlocals
# Then prove or reject order dependence
pytest tests/orders/test_discount.py tests/users/test_profile.py -vvDo not update snapshots until you can explain every changed line. Do not weaken equality to “contains” because a failure is inconvenient. The failure message is evidence. Preserve it, shrink the reproduction, identify the changed assumption, and only then choose whether production code, test data, or the expectation is wrong.
A flaky test trains the team to ignore red builds. Quarantine can keep the main pipeline usable for a day, but it must create an owned repair ticket and remain visible. Common causes are shared state, real clocks, random data without a seed, network calls, unordered results, fixed ports, and sleeps.
# Expose intermittence and preserve the seed
pytest tests/test_worker.py -x --count=100
# Bad: result order belongs to the scheduler
assert [job.id for job in completed] == [1, 2, 3]
# Good, only if the product contract says order is irrelevant
assert {job.id for job in completed} == {1, 2, 3}One: record the failing seed, worker ID, timezone, and test order. Two: loop the smallest reproduction. Three: remove one uncontrolled input at a time. Four: prove the fix with repeated runs. Automatic retries may collect evidence, but a passing retry must still leave the build marked unstable.
Line coverage answers whether a line ran, not whether anyone checked the result. One test can execute every branch and assert nothing. Use coverage to discover untouched risk, especially error handling and authorization. Prefer branch coverage for decision-heavy code. A modest threshold that never decreases is useful; chasing one hundred percent usually buys brittle tests for trivial code.
def can_refund(order, actor):
if not actor.is_staff: # authorization branch
return False
if order.age_days > 30: # policy branch
return False
return order.status == "paid"
# Valuable cases: non-staff, too old, wrong status, allowed.
# Four lines of code can encode four different business risks.Review uncovered code by consequence. An uncovered log formatter is not equal to an uncovered permission denial. Mutation testing can reveal assertions that never notice changed behavior, but use it on core rules first; it is a diagnostic tool, not another vanity score.
Keep this decision path next to the test runner.
# choose the test
pure rule → unit test
SQL / transaction / constraint → real database integration test
third-party request shape → adapter contract test
critical user journey → a small end-to-end test
# write the test
Arrange the smallest world → Act once → Assert observable behavior
normal case + refusal + awkward boundary
# when it fails
run it alone → read full diff → reproduce twice → inspect first useful frame
passes alone? check shared state, order, clock, random seed, ports
# reject these shortcuts
sleep for synchronization · force-exit leaked handles · mock the database
blind snapshot updates · retries that hide flakes · 100% coverage worshipMy hard line: tests exist to make change safer, not to make a dashboard greener. Delete a brittle test that only repeats the implementation. Add a sharp test around the behavior that broke last Tuesday. Run real infrastructure where its behavior is part of your promise. When a failure cannot tell you what promise was broken, rewrite the test before adding another one.