Skip to content

10 - Tests

What this session is

About an hour. You'll learn how to write tests for your Python code with pytest - the standard test framework. By the end you can verify your own code works, watch it fail when you break it, and read the tests in any Python OSS project to understand what the code is supposed to do.

Why tests

When you change code, you might break something that used to work. The change you made looks fine. The thing that broke is in a file you haven't opened in three weeks. Without tests, you find out when a user does.

A test is a small program that calls your code with known inputs and checks the outputs match expectations. You run them after every change. If they pass, you keep going. If one fails, you know what broke.

This sounds obvious. Beginner programmers skip it for years because it feels like extra work. It isn't. It's the work that prevents three hours of debugging next week.

Install pytest

Python ships with a built-in unittest framework, but the wider community uses pytest - friendlier syntax, better error messages, more flexibility. Install in your active venv:

pip install pytest

Verify:

pytest --version

Your first test

Create a folder:

mkdir -p ~/code/python-learning/mathutils && cd ~/code/python-learning/mathutils

Create a mathutils.py:

def add(a, b):
    return a + b

def is_even(n):
    return n % 2 == 0

Create test_mathutils.py (note the test_ prefix - pytest auto-discovers these):

from mathutils import add, is_even

def test_add():
    assert add(2, 3) == 5

def test_is_even():
    assert is_even(4)
    assert not is_even(7)

Run:

pytest

You should see:

============================ test session starts ============================
collected 2 items

test_mathutils.py ..                                              [100%]

============================= 2 passed in 0.01s =============================

Each . is a passing test. [100%] means all of them passed.

The mechanics

  • File naming: files must start with test_ (or end with _test). pytest finds them automatically.
  • Function naming: test functions must start with test_.
  • Assertions: plain Python assert. No special API. pytest rewrites failed assert statements to give you rich error messages.

That's it. No setUp, no test classes, no special inheritance. The simplest possible thing that works.

Watching a test fail (do this)

Open mathutils.py. Change add to return a - b. Save. Run pytest.

You should see:

============================== FAILURES ==============================
______________________________ test_add ______________________________

    def test_add():
>       assert add(2, 3) == 5
E       assert -1 == 5
E        +  where -1 = add(2, 3)

test_mathutils.py:4: AssertionError
=================== short test summary info ==========================
FAILED test_mathutils.py::test_add - assert -1 == 5

Notice how informative: it shows the line that failed, the actual value (-1), the expected value (5), and which call produced what. Pytest's assert rewriting is what gives you this.

Change add back. Re-run. Green again.

Parametrize: many cases, one function

When you have many cases for the same function, don't write test_x_1, test_x_2. Parametrize:

import pytest
from mathutils import is_even

@pytest.mark.parametrize("n, expected", [
    (0, True),
    (1, False),
    (2, True),
    (-4, True),
    (-7, False),
    (1000, True),
])
def test_is_even(n, expected):
    assert is_even(n) == expected

What's happening:

  • @pytest.mark.parametrize runs the same test multiple times with different inputs.
  • First argument: a string naming the parameters.
  • Second argument: a list of tuples - one tuple per case.
  • pytest generates one test per case, each with a distinct name like test_is_even[2-True].

This is the idiomatic Python testing shape. You'll see it in 80% of test files.

Run with pytest -v to see each case named individually.

Fixtures: shared setup

Many tests need the same setup - a temporary file, a fresh database connection, a particular object. Pytest has fixtures for this:

import pytest

@pytest.fixture
def sample_data():
    return {"name": "Alice", "age": 30}

def test_name(sample_data):
    assert sample_data["name"] == "Alice"

def test_age(sample_data):
    assert sample_data["age"] == 30

Fixtures are functions decorated with @pytest.fixture. Tests "request" them by listing them as parameters. Pytest runs the fixture, passes the return value to the test.

Built-in fixtures you'll meet often: - tmp_path - a unique temp directory (pathlib.Path). Cleaned up after the test. - monkeypatch - modify env vars, attributes, dict items; auto-undone after the test. - capsys - capture print output for assertion.

Example: testing a function that reads a file.

def test_read_file(tmp_path):
    p = tmp_path / "data.txt"
    p.write_text("hello")
    assert read_file(p) == "hello"

tmp_path gives you a fresh, isolated directory; you write a file there, run your code against it, the directory disappears after. No cleanup boilerplate, no global state.

Testing exceptions

Use pytest.raises:

import pytest
from mathutils import divide

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

The test passes if the call inside the with block raises ZeroDivisionError. It fails if no exception is raised, or a different type is raised.

You can also assert on the message:

def test_divide_by_zero_message():
    with pytest.raises(ZeroDivisionError, match="division by zero"):
        divide(10, 0)

match is a regex applied to the error message.

Useful pytest commands

Command What it does
pytest Run all tests in the current directory and subdirectories.
pytest -v Verbose - show each test by name.
pytest -x Stop at the first failure.
pytest -k pattern Run only tests whose name matches the pattern.
pytest path/to/test_file.py Run one file.
pytest test_x.py::test_func Run one function.
pytest --tb=short Compact tracebacks.
pytest --pdb Drop into the Python debugger on failure.
pytest -q Quiet - minimal output.
pytest --collect-only List what would run without running it.

pytest -v is the most useful during development.

Running tests as you change code

Install pytest-watch and let it re-run tests every time you save a file:

pip install pytest-watch
ptw                # in the project root

Or simpler: run pytest -v after every save. The instant feedback loop is the productive way to work.

A note on coverage

pytest-cov shows what percentage of your code your tests touch:

pip install pytest-cov
pytest --cov=mathutils

100% coverage is a misleading goal - you can hit it with tests that don't actually catch bugs. A better target: "every code path has at least one test, and every bug fix gets a regression test." Coverage gives you a floor, not a ceiling.

Exercise

Set up and test a small package.

  1. Make a folder ~/code/python-learning/wordtools and cd in.

  2. Create wordtools.py:

    def word_count(s: str) -> int:
        return len(s.split())
    
    def is_palindrome(s: str) -> bool:
        s = s.lower()
        return s == s[::-1]
    

  3. Create test_wordtools.py. Write parameterized tests for both:

  4. word_count: "" → 0, "hello" → 1, "hello world" → 2, " many spaces here " → 3.
  5. is_palindrome: "" → True, "a" → True, "racecar" → True, "hello" → False, "Racecar" → True (lowercase first).

  6. Run pytest -v. All tests should pass.

  7. Break each function on purpose, watch the relevant test fail, fix it, watch it pass.

  8. Stretch: add a function most_common_word(s: str) -> str (returns the word appearing most). Use collections.Counter. Write a parametrized test for it, including a tie-breaking case.

What you might wonder

"Where do tests live in real projects?" Three common layouts: - Next to the code (mathutils.py, test_mathutils.py in the same folder). Common for small projects. - In a tests/ directory at the top level, mirroring the source layout. Common for medium-to-large projects. - In src/<package>/ + tests/ (the "src layout"). The modern best-practice. Avoids a class of import bugs.

All three are valid. The README or pytest configuration tells you which a project uses.

"What about unittest, the stdlib framework?" Older. More boilerplate (test classes, self.assertEqual). Some projects (especially within Python itself) use it. Recognize it; prefer pytest for new code.

"Should I write the test first or the code first?" Either works. Any tests are infinitely better than no tests. Start by writing the code, then writing a test. After a few months, try writing the test first sometimes; see which feels better.

"How much testing is enough?" A useful heuristic: every bug you fix gets a test that would have caught it. Every important code path has at least one test. Don't chase 100% coverage; chase confidence.

"What about mocking?" Mocking means replacing real dependencies (databases, APIs) with fake ones during a test. The stdlib unittest.mock is the standard tool; pytest has pytest-mock as a nicer wrapper. Use sparingly - overuse leads to tests that pass on broken code.

Done

You can now: - Install pytest and write tests in test_*.py files. - Use assert with rich pytest error messages. - Parametrize tests with @pytest.mark.parametrize. - Share setup via fixtures (including tmp_path, monkeypatch, capsys). - Test that exceptions are raised with pytest.raises. - Drive pytest from the command line for fast iteration.

You can now verify your own code. More importantly, you can read the test files in any real Python project and understand what they're checking - that's most of what makes a real codebase legible.

Next page: how Python projects are organized into modules and packages, and how to use code other people wrote.

Next: Modules, packages, pip, venv →

Comments