Testing Python with pytest

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Python 3.13 · pytest 8.3

Tests are not about coverage percentages — they are what lets you change code without flinching. This article builds a runnable pytest suite from nothing.

Environment

python -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
python -m pip install pytest==8.3.4
pytest --version

Examples are verified on Python 3.13 with pytest 8.3.

The first test

Code under test, calc.py:

def add(a: int, b: int) -> int:
    return a + b

def divide(a: int, b: int) -> float:
    if b == 0:
        raise ValueError("divisor must not be 0")
    return a / b

Test, test_calc.py:

from calc import add, divide

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

Run it:

pytest
collected 1 item
test_calc.py .                    [100%]
1 passed

The conventions pytest relies on:

  • Test files: test_*.py or *_test.py.
  • Test functions: names starting with test_.
  • Assertions: bare assert, no self.assertEqual needed.

Assertions

def test_list():
    items = [1, 2, 3]
    assert len(items) == 3
    assert 2 in items
    assert items == [1, 2, 3]

When an assertion fails, pytest rewrites the expression and prints the actual values, which is why bare asserts are worth using:

E       assert 4 == 3
E        +  where 4 = len([1, 2, 3, 4])

Do not write assert x == True or assert x is True; write assert x.

Testing exceptions

import pytest

def test_divide_by_zero():
    with pytest.raises(ValueError, match="divisor must not be 0"):
        divide(1, 0)

match is a regex applied to the exception message. To inspect the exception itself:

with pytest.raises(ValueError) as exc:
    divide(1, 0)
assert "divisor" in str(exc.value)

Parametrizing

One test body, many inputs — no copy-paste:

@pytest.mark.parametrize(
    ("a", "b", "expected"),
    [
        (1, 2, 3),
        (0, 0, 0),
        (-1, 1, 0),
    ],
)
def test_add_cases(a, b, expected):
    assert add(a, b) == expected

Stacking two or more parametrize decorators produces their Cartesian product, which is a compact way to cover combinations.

Fixtures: setup and teardown

import pytest

@pytest.fixture
def sample_file(tmp_path):
    path = tmp_path / "input.csv"
    path.write_text("name,age\nAda,18\n", encoding="utf-8")
    return path

def test_read(sample_file):
    assert sample_file.exists()
  • A fixture is declared with @pytest.fixture; tests request it by parameter name.
  • tmp_path is a built-in fixture: a fresh temporary directory per test, cleaned up afterwards.
  • For expensive setup (a database connection, a model load) use scope="module" or scope="session".
  • A yield in a fixture runs teardown after the test: create the resource, yield it, then close it.

Organising tests

myapp/
├── myapp/
│   ├── __init__.py
│   └── calc.py
└── tests/
    ├── conftest.py        # shared fixtures
    ├── test_calc.py
    └── test_cli.py

Commands worth memorising:

pytest                     # everything
pytest tests/test_calc.py  # one file
pytest -k "add"            # select by name substring
pytest -v                  # show every test name
pytest -x                  # stop at the first failure
pytest --tb=short          # shorter tracebacks

Coverage (optional):

python -m pip install pytest-cov
pytest --cov=myapp --cov-report=term-missing

What to test and what to skip

Worth testing:

  • Business logic and its boundaries: empty input, zero, negatives, very long strings.
  • Places where a bug already happened once — a regression test.
  • Behavior of your public interface, not internal implementation details.

Not worth testing:

  • Third-party libraries themselves.
  • One-line getters and setters.
  • Code that depends on the network, the clock, or randomness — inject a fake or use a fixture instead of reaching out for real.

Common mistakes

1. File names that pytest does not collect

calc_test.py is collected; calc.py is not. Only test_*.py and *_test.py are collected by default.

2. Tests that depend on each other

pytest gives no ordering guarantee. Use pytest-randomly to shuffle runs and expose hidden coupling; each test must set up its own data.

3. Tests with real side effects

Writing files into the project directory, touching a real database, or sending real requests. Isolate with tmp_path, fixtures, and fakes.

4. Weak assertions

assert result      # passes for anything non-empty — proves nothing

Assert on specific values or specific attributes.

5. Treating tests as debug scripts

A test must run repeatedly with a stable result. Use pytest -s to see print output while debugging, but do not leave those prints committed.

6. Fixtures that mutate shared state at session scope

A broad scope plus mutation means one test can poison another. Keep mutable fixtures at the default function scope.

Summary

  • Files test_*.py, functions test_*, bare assert.
  • Exceptions with pytest.raises; multiple inputs with parametrize.
  • Fixtures handle setup and teardown; tmp_path handles temporary files.
  • Test behavior, not implementation; isolate external dependencies; keep runs repeatable.

Next: turn a script into an installable CLI with argparse and a src layout.