Python Unit Testing with pytest
Learn pytest from scratch: writing assertions, using fixtures, parametrizing tests, and organizing a test suite with conftest.py.
pytest is Python's most popular testing framework. It lets you write small, readable test functions using plain assert statements — no boilerplate classes required — while still scaling to complex test suites with shared fixtures, parametrization, and plugins.
This chapter covers everything you need to test Python code with pytest: installation, writing your first test, assertions and expected exceptions, fixtures, parametrize, test organization with conftest.py, useful command-line options, and the most common gotchas.
Why pytest?
Python ships with the unittest module, so why use pytest instead?
| Feature | unittest | pytest |
|---|---|---|
| Test syntax | Class + method | Plain function |
| Assertions | self.assertEqual(a, b) | assert a == b |
| Fixtures | setUp / tearDown | @pytest.fixture (composable) |
| Parametrize | Manual loop | @pytest.mark.parametrize |
| Plugin ecosystem | Minimal | 1 000+ plugins (coverage, mock, etc.) |
pytest also runs unittest-style tests unchanged, so you can adopt it gradually.
Installation
pytest is not part of the standard library. Install it with pip inside a virtual environment:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install pytestVerify the installation:
pytest --version
# pytest 8.x.xSee Python pip if you need a refresher on package management.
Your First Test
pytest discovers test files automatically. By default it looks for:
- Files named
test_*.pyor*_test.py - Functions whose names start with
test_
Create math_utils.py with a simple function:
# math_utils.py
def add(a, b):
return a + bNow create test_math_utils.py in the same directory:
# test_math_utils.py
from math_utils import add
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, 1) == 0
def test_add_zeros():
assert add(0, 0) == 0Run the tests:
pytest test_math_utils.pyOutput:
collected 3 items
test_math_utils.py ... [100%]
3 passed in 0.01sEach dot represents one passing test. A failing test prints F and shows the full assertion diff.
Assertions
pytest rewrites plain assert statements at collection time so that failures show a detailed diff — no need for special assertion methods.
def test_assertion_diff():
result = [1, 2, 4]
expected = [1, 2, 3]
assert result == expected # pytest shows exactly where lists differA failure output looks like:
AssertionError: assert [1, 2, 4] == [1, 2, 3]
At index 2: 4 != 3Floating-Point Comparisons
Never compare floats with == — rounding errors make it unreliable. Use pytest.approx:
import pytest
import math
def circle_area(r):
return math.pi * r * r
def test_circle_area():
assert circle_area(5) == pytest.approx(78.53981633974483)pytest.approx accepts an optional abs or rel tolerance:
assert 0.1 + 0.2 == pytest.approx(0.3, abs=1e-9)Testing Expected Exceptions
Use pytest.raises as a context manager to assert that a specific exception is raised:
import pytest
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)The match argument is a regular expression checked against the exception message. If the exception is not raised, pytest fails the test — ensuring you catch regressions where error handling is accidentally removed.
See Python Try...Except for a deeper look at exception handling, and Raising Exceptions for how to raise them intentionally.
Parametrize: Running One Test with Many Inputs
@pytest.mark.parametrize lets you run the same test logic against multiple data sets without writing a loop:
import pytest
from math_utils import add
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(10, -5, 5),
])
def test_add(a, b, expected):
assert add(a, b) == expectedpytest generates a separate test case for each tuple and reports them individually:
test_math_utils.py::test_add[2-3-5] PASSED
test_math_utils.py::test_add[-1-1-0] PASSED
test_math_utils.py::test_add[0-0-0] PASSED
test_math_utils.py::test_add[10--5-5] PASSEDThis is far cleaner than a manual loop — individual failures are isolated and easy to identify.
Fixtures
A fixture is a function decorated with @pytest.fixture that provides shared setup (and optional teardown) for tests. Instead of repeating setup code in every test, you declare a fixture once and inject it by name as a test parameter.
Basic Fixture
import pytest
class UserStore:
def __init__(self):
self.users = []
def add_user(self, name):
self.users.append(name)
def count(self):
return len(self.users)
@pytest.fixture
def store():
return UserStore()
def test_empty_store(store):
assert store.count() == 0
def test_add_user(store):
store.add_user("Alice")
assert store.count() == 1pytest sees that test_add_user has a parameter called store, looks for a fixture with that name, calls it, and passes the result in. Each test gets a fresh fixture instance — changes in one test never bleed into another.
Fixtures with Teardown (yield)
Use yield inside a fixture to split it into setup (before yield) and teardown (after yield). This ensures cleanup always runs, even if the test fails:
import pytest
import tempfile
import os
@pytest.fixture
def temp_file():
fd, path = tempfile.mkstemp(suffix=".txt")
os.close(fd)
yield path # test receives the path here
if os.path.exists(path):
os.unlink(path) # always runs after the test
def test_write_to_temp_file(temp_file):
with open(temp_file, "w") as f:
f.write("hello")
with open(temp_file) as f:
assert f.read() == "hello"Fixture Scope
By default, fixtures are created and torn down once per test function. You can widen the scope to reduce expensive setup:
| Scope | Created once per |
|---|---|
"function" (default) | Each test function |
"class" | Each test class |
"module" | Each test file |
"session" | Entire test run |
@pytest.fixture(scope="session")
def database_connection():
conn = create_db_connection()
yield conn
conn.close()Use "session" scope for expensive resources like database connections or server processes. Use "function" scope (the default) for anything that mutates state.
Built-in Fixtures
pytest ships several built-in fixtures you can use without importing anything:
tmp_path— apathlib.Pathpointing to a temporary directory unique to the test.monkeypatch— replaces attributes, environment variables, or dictionary entries for the duration of a test, then reverts automatically.capsys— capturesstdout/stderroutput so you can assert on printed text.
def greet(name):
print(f"Hello, {name}!")
def test_greet_output(capsys):
greet("World")
captured = capsys.readouterr()
assert captured.out == "Hello, World!\n"Using monkeypatch
monkeypatch is the idiomatic way to replace external dependencies in tests without a third-party mock library:
import time
def get_timestamp():
return time.time()
def test_get_timestamp(monkeypatch):
monkeypatch.setattr(time, "time", lambda: 1_000_000.0)
assert get_timestamp() == 1_000_000.0After the test, time.time is restored to its original implementation. See Python Decorators if you want to understand how @pytest.fixture works under the hood.
Organizing Tests with conftest.py
When a fixture is needed by tests in multiple files, put it in conftest.py. pytest discovers conftest.py files automatically and makes their fixtures available to all tests in the same directory and below — no import required.
project/
├── conftest.py # shared fixtures live here
├── test_users.py
├── test_orders.py
└── utils/
├── conftest.py # fixtures scoped to this subdirectory
└── test_helpers.py# conftest.py
import pytest
@pytest.fixture
def admin_user():
return {"name": "Admin", "role": "admin", "active": True}# test_users.py — no import needed; pytest injects admin_user automatically
def test_admin_is_active(admin_user):
assert admin_user["active"] is TrueClass-Based Tests
You can group related tests into a class. Unlike unittest.TestCase, pytest classes do not require inheritance:
class TestCalculator:
def test_add(self):
assert 2 + 2 == 4
def test_multiply(self):
assert 3 * 4 == 12
def test_subtract(self):
assert 10 - 3 == 7Classes are useful for grouping tests that share a logical concern. Avoid classes when the grouping has no real benefit — flat functions are simpler.
Marks: Skipping and Custom Labels
pytest's mark system lets you annotate tests with metadata for selective execution.
Skip a Test
import pytest
import sys
@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
assert False
@pytest.mark.skipif(sys.platform == "win32", reason="Linux only")
def test_linux_feature():
assert TrueCustom Marks
Register custom marks in pytest.ini (or pyproject.toml) to tag tests by category:
# pytest.ini
[pytest]
markers =
slow: marks tests as slow (deselect with -m "not slow")
integration: marks integration tests@pytest.mark.slow
def test_large_dataset():
...Run only slow tests:
pytest -m slowRun everything except slow tests:
pytest -m "not slow"Useful Command-Line Options
pytest # run all discovered tests
pytest test_math_utils.py # run a specific file
pytest test_math_utils.py::test_add # run one test by name
pytest -v # verbose: show each test name
pytest -x # stop on first failure
pytest --tb=short # shorter traceback (default is long)
pytest -k "add" # run tests whose name contains "add"
pytest --lf # re-run only last-failing tests
pytest -q # quiet: minimal outputTest Coverage
Install the coverage plugin to measure which lines your tests exercise:
pip install pytest-cov
pytest --cov=math_utils --cov-report=term-missingOutput adds a coverage column showing which lines were not hit:
Name Stmts Miss Cover Missing
---------------------------------------------
math_utils.py 2 0 100%Aim for high coverage on critical business logic, but don't chase 100% — testing trivial getters often adds noise without value.
Common Gotchas
1. Fixture not found. If pytest reports fixture 'foo' not found, check the fixture is in conftest.py or the same file, and that the function is decorated with @pytest.fixture.
2. Import errors at collection time. If pytest can't import your module, it errors before running any test. Run python -c "import your_module" to diagnose.
3. Mutable default arguments in fixtures. Just like regular Python functions, fixtures should avoid mutable default arguments. Use "function" scope (the default) for any fixture that builds a mutable object.
4. assert in helper functions. If you call a helper from a test and that helper contains assert, make sure its name starts with assert_ (pytest convention) so pytest rewrites the assertion for a better error message.
5. Mixing unittest.TestCase and pytest fixtures. pytest runs unittest.TestCase tests, but you cannot inject pytest fixtures into TestCase methods. Use pytest-style classes or the unittest setup methods — not both at once.