Skip to content

Lesson 2 of 9 · 20m

Unit Tests That Don't Rot

Arrange-Act-Assert, test isolation, and the assertion patterns that survive refactors instead of breaking on them.

A unit test has one job: verify a piece of logic does what it says when everything else is held constant. Tests that rot are tests that fail when the code is right — usually because they assert on implementation details rather than behavior.

The shape that survives

def test_discount_applies_to_subtotal():
    # Arrange — the minimal fixture that makes the test readable
    cart = Cart(items=[Item(price=100), Item(price=50)])
    # Act — one operation under test
    cart.apply_discount(0.10)
    # Assert — the observable result, not the mechanism
    assert cart.total == 135

AAA isn't a style — it's a readability contract. If your Arrange section is 40 lines, the test is telling you the object is too coupled; if Assert checks internals (cart._discount_rate == 0.10), the test couples to implementation and will break on refactor.

What makes a unit test rot

  • Asserting on internals — mock.assert_called_with(db.execute, 'SELECT ...') breaks on a harmless refactor. Assert the outcome (result.total == 135), not the path.
  • Testing too much at once — one test that exercises five behaviors fails ambiguously; five focused tests point at the failure.
  • Depends on time, randomness, or order — inject the clock, seed the RNG, sort deterministically.

Factories beat fixtures for objects

Fixture files grow into rigid hell — a user with every field set when you only need email. A factory (UserFactory(email='x')) sets the minimum and fills the rest with defaults: readable, composable, and the test stays valid when the model gains a new required field.

The unit test isn't a function call

"Unit" is about what's under test — a function, a class, a module — not the test's shape. A unit test exercises the code path without touching the database, network, or filesystem; when it touches any of those, it's an integration test, and it should live with the integration suite.

Field notes

  • Test names should read like requirements: test_rejects_expired_token beats test_auth_3.
  • Parametrized tests (@pytest.mark.parametrize) compress N near-identical tests into a table — write them when the shape repeats.
  • A test that can't fail is worse than no test — assert something real, or delete the test.