Skip to content
← All Python articles

Python explainer

Write Tests That Catch Real Bugs

About 4 min to read. Allow extra time to work through examples.

A test is worth keeping when it fails on a bug someone could plausibly write. Check exact results, pick inputs at the edges, and read coverage as a list of untested lines.

This article uses Python. You can make it your practice mode to browse related content across the site.

Here is a function, and a test for it. The test passes:

def divide_budget(total: int, people: int) -> tuple[int, int]:
    '''Split total between people. Return each share and what is left over.'''
    if total < 0 or people <= 0:
        raise ValueError("total must be nonnegative and people must be positive")
    return divmod(total, people)


def test_divide_budget() -> None:
    divide_budget(10, 3)

The test also passes if someone breaks the function. Change the last line to return total // people, 0, and the leftover is always 0, but the test still passes. It fails only if divide_budget raises an exception, because it never looks at the result.

Check the result

A test is worth keeping when it fails on a bug someone could plausibly write. Compare the result with the exact answer you expect:

import pytest


def test_budget_keeps_the_remainder() -> None:
    assert divide_budget(10, 3) == (3, 1)


def test_budget_rejects_zero_people() -> None:
    with pytest.raises(ValueError, match="people must be positive"):
        divide_budget(10, 0)

The broken version returns (3, 0), so the first test fails, and pytest prints assert (3, 0) == (3, 1). pytest can show both values because it rewrites the assert statements in your test files as it imports them. The second test checks the error. pytest.raises fails the test if the block raises nothing, or raises a different exception.

Pick inputs where a broken version gives a different answer

divide_budget(9, 3) returns (3, 0), and so does the broken version. A test with those inputs passes for both. For each case, ask which mistake it would catch. The useful cases are usually at the edges:

  • an exact fit and a near miss, such as 9 and 10 items in groups of 3;
  • zero and one, such as an empty list or a single item;
  • the smallest and largest values the function accepts, and the first value it rejects.

The next exercise, Catch the Broken Versions, gives you three broken versions of a function. You pick the cases that catch all three.

Test what callers can see

Callers can see the return value, the exception, and any change to a file or a database. A correct rewrite keeps those the same, so tests that check them keep passing. A test that checks how the code works, such as which helper it calls, fails after a correct rewrite. You then have to update the test for no gain.

Check that the code made a call only when the call is the behavior. "Send the email exactly once" is a promise to the caller, so assert that the fake mailer got one message.

Read coverage as a list of untested lines

Coverage reports which lines ran during your tests. The first test above runs every line of divide_budget except the raise, and checks nothing. So coverage shows where you have not looked yet. It cannot tell you that a test checks anything. Read the lines it reports as missed, and write a test for the ones that matter.

A mutation-testing tool finds tests that run code without checking it. It makes small changes to your code, such as turning < into <=, and runs your tests against each changed version. If the tests still pass on a changed version, an assertion is missing. A later exercise in this track builds a small one.

Fix a flaky test at its cause

A flaky test passes on some runs and fails on others, with no change to the code. The usual causes:

  • One test changes shared state, such as a module-level dictionary, and a later test depends on it. Give each test fresh state with a fixture.
  • The test reads the real clock, or sleeps and assumes another thread has finished by then. Pass in a fake clock, or wait for an event.
  • The test compares an unordered result, such as a set turned into a list, with a fixed order. Compare sets, or sort both sides.

Rerunning a flaky test until it passes hides the bug. Find the cause.

Check your understanding A test asserts that divide_budget(9, 3) == (3, 0). Does it catch the version that returns (total // people, 0)? Show answer

No. 9 divides by 3 exactly, so the broken version also returns (3, 0), and the test passes for both. Use an input with a remainder, such as 10 and 3, where the correct and broken versions give different answers.

Going further

The pytest-cov plugin reports coverage with pytest --cov. Its branch mode, --cov-branch, also reports an if that only ever went one way.

Fakes make tests fast, but they check your code only against your idea of the real service. Keep a few slower tests that use the real database, or the service's test environment, and run them before each release.

step 1 of 19

Testing with pytest and Hypothesis

Up next Catch the Broken Versions