Unit Test Automation¶
Automating unit tests is the base of the pyramid and the cheapest automation a team can own. This page is about the mechanics: how the tests are structured, run and kept fast.
graph LR
SAVE[Developer saves a file] --> WATCH[Watcher runs affected tests]
WATCH --> MS["Result in under a second"]
COMMIT[Commit] --> CI[Full unit suite in CI]
CI --> GATE[Gate the merge]
The two loops matter for different reasons. The inner loop changes how code gets written. The outer loop is what makes the guarantee collective.
Structure that survives¶
| Practice | Effect |
|---|---|
| Arrange, act, assert with visible separation | The scenario is readable at a glance |
| One behaviour per test | A failure names one thing |
| Behaviour-based names | rejects_expired_card, not test_validate_3 |
| No branching or loops in test bodies | A test that needs an if is two tests |
| Test data builders | Each test shows only what is relevant to it |
| Parameterised cases for tables of data | Boundaries and partitions without copy-paste |
@pytest.mark.parametrize("amount,expected", [
(49.99, 5), (50.00, 0), (50.01, 0), (0, 5),
])
def test_shipping_cost_at_threshold(amount, expected):
assert shipping_cost(amount) == expected
Parameterisation is the natural fit for boundary value analysis: the table of cases in the test design becomes the table in the code, with one failure per failing row.
Keeping the suite fast¶
Speed is a correctness property of a unit suite, because a slow suite stops being run.
- No I/O. No network, no disk, no real database, no sleeps. If a unit test needs to wait, the dependency should be injected and controlled.
- Controlled clock. Inject time. Every
sleep(2)in a suite is two seconds paid on every run forever. - Cheap fixtures. Shared expensive setup is a sign the test is not a unit test.
- Parallel by default, which is only possible if tests share no state. See test isolation.
- Budget it. A whole-project unit suite over a minute or two is already discouraging people from running it locally.
Doubles, kept in proportion¶
Use test doubles for the dependencies that are slow, non-deterministic, or have side effects, and use real objects everywhere else. Mocking every collaborator produces tests that assert on internal call structure and break on every refactor while proving nothing about behaviour.
graph LR
D[Dependency] --> Q{Slow, non-deterministic or side-effecting?}
Q -->|no| REAL[Use the real object]
Q -->|yes| DOUBLE[Substitute a stub, fake or mock]
In the pipeline¶
| Stage | What runs |
|---|---|
| Pre-commit hook | Linters and the fastest subset, optional |
| Pull request | Full unit suite, coverage of changed lines reported |
| Merge to main | Full unit suite, plus integration |
| Nightly | Everything, including mutation testing on critical modules |
Gate merges on the unit suite. It is fast enough that gating costs almost nothing, and it is the layer whose failures are cheapest to fix.
Check Your Understanding¶
Why is execution speed treated as a correctness property of a unit suite?
Which practice most improves the diagnostic value of a failing unit test?