W3docs

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?

Featureunittestpytest
Test syntaxClass + methodPlain function
Assertionsself.assertEqual(a, b)assert a == b
FixturessetUp / tearDown@pytest.fixture (composable)
ParametrizeManual loop@pytest.mark.parametrize
Plugin ecosystemMinimal1 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 pytest

Verify the installation:

pytest --version
# pytest 8.x.x

See 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_*.py or *_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 + b

Now 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) == 0

Run the tests:

pytest test_math_utils.py

Output:

collected 3 items

test_math_utils.py ...                                                 [100%]

3 passed in 0.01s

Each 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 differ

A failure output looks like:

AssertionError: assert [1, 2, 4] == [1, 2, 3]
  At index 2: 4 != 3

Floating-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) == expected

pytest 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] PASSED

This 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() == 1

pytest 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:

ScopeCreated 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 — a pathlib.Path pointing 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 — captures stdout / stderr output 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.0

After 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 True

Class-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 == 7

Classes 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 True

Custom 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 slow

Run 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 output

Test Coverage

Install the coverage plugin to measure which lines your tests exercise:

pip install pytest-cov
pytest --cov=math_utils --cov-report=term-missing

Output 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.

Practice

Practice
Which decorator marks a pytest function as a fixture?
Which decorator marks a pytest function as a fixture?
Practice
What does pytest.approx() help you do in tests?
What does pytest.approx() help you do in tests?
Practice
Where should you put fixtures that need to be shared across multiple test files?
Where should you put fixtures that need to be shared across multiple test files?
Was this page helpful?