Testing Python with pytest
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
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_*.pyor*_test.py. - Test functions: names starting with
test_. - Assertions: bare
assert, noself.assertEqualneeded.
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_pathis 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"orscope="session". - A
yieldin a fixture runs teardown after the test: create the resource,yieldit, 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, functionstest_*, bareassert. - Exceptions with
pytest.raises; multiple inputs withparametrize. - Fixtures handle setup and teardown;
tmp_pathhandles 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.