Python Testing with pytest

Write reliable Python tests with pytest - fixtures, parametrize, marks, mocking, and coverage - all with plain assert statements.

Intermediate

pytest Basics

Install pytest and write your first tests. Test functions start with test_; test files start with test_ or end with _test:

Bash
pip install pytest

# Run all tests
pytest

# Run with verbose output
pytest -v

# Run specific file
pytest tests/test_math.py

# Run specific test function
pytest tests/test_math.py::test_add

# Stop on first failure
pytest -x

# Show print() output during tests
pytest -s
Python
# Module under test: math_utils.py
def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ZeroDivisionError('cannot divide by zero')
    return a / b

# Test file: test_math_utils.py
from math_utils import add, divide

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

def test_add_negative():
    assert add(-1, -1) == -2

def test_add_floats():
    result = add(0.1, 0.2)
    assert abs(result - 0.3) < 1e-10   # float comparison
pytest output
collected 3 items

test_math_utils.py ...                                   [100%]

3 passed in 0.03s

Recommended project layout:

Bash
myproject/
    src/
        myproject/
            __init__.py
            utils.py
    tests/
        conftest.py          # shared fixtures
        test_utils.py
    pyproject.toml           # [tool.pytest.ini_options]

Assertions and Exceptions

pytest rewrites assert statements to show detailed failure diffs automatically:

Python
def test_string():
    result = 'hello world'
    assert 'world' in result
    assert result.startswith('hello')
    assert len(result) == 11

def test_list():
    items = [1, 2, 3, 4, 5]
    assert 3 in items
    assert items[0] == 1
    assert sorted(items) == items   # pytest shows diff on failure

def test_dict():
    data = {'name': 'Alice', 'age': 30}
    assert data['name'] == 'Alice'
    assert 'email' not in data

# Floating point - use pytest.approx
import pytest

def test_float():
    assert 0.1 + 0.2 == pytest.approx(0.3)
    assert 1.0 == pytest.approx(1.0, rel=1e-6)

def test_list_approx():
    result = [0.1 + 0.2, 0.3 + 0.4]
    assert result == pytest.approx([0.3, 0.7])

Test that exceptions are raised with pytest.raises():

Python
import pytest
from math_utils import divide

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

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

def test_raises_captures_exception():
    with pytest.raises(ValueError) as exc_info:
        int('not a number')
    assert 'invalid literal' in str(exc_info.value)

Fixtures

Fixtures provide reusable setup - declare them as function parameters and pytest injects them automatically:

Python
import pytest

@pytest.fixture
def sample_users():
    return [
        {'id': 1, 'name': 'Alice', 'email': 'alice@example.com'},
        {'id': 2, 'name': 'Bob',   'email': 'bob@example.com'},
    ]

def test_user_count(sample_users):
    assert len(sample_users) == 2

def test_first_user(sample_users):
    assert sample_users[0]['name'] == 'Alice'

def test_user_emails(sample_users):
    emails = [u['email'] for u in sample_users]
    assert 'alice@example.com' in emails

Fixtures with teardown using yield:

Python
import pytest
import tempfile, os

@pytest.fixture
def temp_file():
    f = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
    f.write('test data')
    f.close()
    yield f.name          # test runs here
    os.unlink(f.name)     # teardown: runs after test

def test_read_temp_file(temp_file):
    with open(temp_file) as f:
        content = f.read()
    assert content == 'test data'

Fixture scope controls how often a fixture is called:

Python
import pytest

# function scope (default) - called once per test function
@pytest.fixture
def fresh_list():
    return []

# module scope - called once per test module
@pytest.fixture(scope='module')
def db_connection():
    conn = create_test_db()
    yield conn
    conn.close()

# session scope - called once per entire test session
@pytest.fixture(scope='session')
def app_config():
    return {'env': 'test', 'debug': True}

# autouse=True - automatically used by all tests in scope
@pytest.fixture(autouse=True)
def reset_state():
    yield
    cleanup_global_state()
conftest.py for shared fixtures

Fixtures defined in conftest.py are automatically available to all test files in the same directory and subdirectories - no import needed. Use a top-level conftest.py for project-wide fixtures (database connections, app instances) and per-directory conftest.py files for module-specific fixtures.

@pytest.mark.parametrize

Run the same test with multiple inputs without writing separate test functions:

Python
import pytest
from math_utils import add

@pytest.mark.parametrize('a, b, expected', [
    (1,  2,  3),
    (0,  0,  0),
    (-1, 1,  0),
    (10, -5, 5),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

# Named test cases with ids=
@pytest.mark.parametrize('value, expected', [
    ('',    False),
    ('  ',  False),
    ('hi',  True),
    ('123', True),
], ids=['empty', 'spaces', 'word', 'digits'])
def test_is_non_empty(value, expected):
    assert bool(value.strip()) == expected
pytest -v output
test_parametrize.py::test_add[1-2-3] PASSED
test_parametrize.py::test_add[0-0-0] PASSED
test_parametrize.py::test_add[-1-1-0] PASSED
test_parametrize.py::test_add[10--5-5] PASSED

Combine parametrize with fixtures and stack multiple parametrize decorators:

Python
import pytest

@pytest.mark.parametrize('op', ['add', 'sub'])
@pytest.mark.parametrize('n', [1, 2, 3])
def test_operations(n, op):
    # Generates 6 tests: (1,add) (1,sub) (2,add) (2,sub) (3,add) (3,sub)
    assert isinstance(n, int)
    assert op in ('add', 'sub')

Marks and Skipping

Python
import pytest, sys

# Skip unconditionally
@pytest.mark.skip(reason='not implemented yet')
def test_future_feature():
    pass

# Skip conditionally
@pytest.mark.skipif(sys.platform == 'win32', reason='Linux-only test')
def test_unix_permissions():
    pass

# Mark as expected failure
@pytest.mark.xfail(reason='known bug in external library')
def test_known_broken():
    assert 1 == 2   # expected to fail - shown as 'x' not 'F'

# Custom marks (register in pyproject.toml to avoid warnings)
@pytest.mark.slow
def test_large_dataset():
    pass

@pytest.mark.integration
def test_real_database():
    pass

Register custom marks in pyproject.toml:

Python
[tool.pytest.ini_options]
markers = [
    "slow: marks tests as slow (deselect with '-m not slow')",
    "integration: marks integration tests requiring external services",
]
Bash
# Run only slow tests
pytest -m slow

# Exclude slow tests
pytest -m "not slow"

# Run integration tests
pytest -m integration

Mocking with unittest.mock

Use mocks to replace external dependencies (HTTP calls, databases, file I/O) in unit tests:

Python
from unittest.mock import MagicMock, patch

# MagicMock - a flexible mock object
mock = MagicMock()
mock.method(1, 2, 3)
mock.method.assert_called_once_with(1, 2, 3)
mock.attr = 'value'
print(mock.attr)        # 'value'
print(mock.undefined)   # another MagicMock (no AttributeError)
Python
from unittest.mock import patch, MagicMock
import requests

# Code under test
def get_user(user_id: int) -> dict:
    response = requests.get(f'https://api.example.com/users/{user_id}')
    response.raise_for_status()
    return response.json()

# Test - patch the target WHERE IT IS USED (not where it is defined)
def test_get_user():
    mock_response = MagicMock()
    mock_response.json.return_value = {'id': 1, 'name': 'Alice'}
    mock_response.raise_for_status.return_value = None

    with patch('requests.get', return_value=mock_response) as mock_get:
        user = get_user(1)

    mock_get.assert_called_once_with('https://api.example.com/users/1')
    assert user == {'id': 1, 'name': 'Alice'}

Using @patch as a decorator and patching side effects:

Python
from unittest.mock import patch, MagicMock
import pytest

@patch('requests.get')
def test_api_error(mock_get):
    mock_get.side_effect = requests.exceptions.ConnectionError('timeout')
    with pytest.raises(requests.exceptions.ConnectionError):
        get_user(1)

@patch('builtins.open', create=True)
def test_reads_config(mock_open):
    mock_open.return_value.__enter__.return_value.read.return_value = '{"host": "test"}'
    # test function that opens a config file...

# Multiple patches - decorators apply bottom-up
@patch('module.ServiceB')
@patch('module.ServiceA')
def test_two_services(mock_a, mock_b):
    # mock_a is ServiceA, mock_b is ServiceB
    pass

Coverage Reports

Use pytest-cov to measure which lines of code your tests exercise:

Bash
pip install pytest-cov

# Run tests with coverage for src/
pytest --cov=src tests/

# Show missing lines
pytest --cov=src --cov-report=term-missing tests/

# Generate HTML report
pytest --cov=src --cov-report=html tests/
open htmlcov/index.html

# Fail if coverage drops below 80%
pytest --cov=src --cov-fail-under=80 tests/

Configure coverage in pyproject.toml:

Python
[tool.coverage.run]
source = ["src"]
omit = ["src/*/migrations/*", "tests/*"]

[tool.coverage.report]
fail_under = 80
show_missing = true
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
    "if __name__ == .__main__.:",
]

[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=term-missing"
100% coverage is not the goal

Coverage measures which lines are executed, not whether your tests are meaningful. A test with assert True achieves 100% coverage but tests nothing. Aim for ~80% coverage on business logic; skip boilerplate, config files, and one-line scripts. Focus on testing edge cases, error paths, and contracts - not chasing a number.

Frequently Asked Questions

pytest has a simpler API - use plain assert statements instead of self.assertEqual(). It produces detailed failure messages by introspecting the assertion. Fixtures are more flexible than setUp/tearDown. Parametrize replaces repetitive test cases. pytest can also run unittest test cases, so you don't have to rewrite existing tests.

A fixture is a function decorated with @pytest.fixture that provides setup data or objects to tests. Tests declare fixtures as parameters - pytest calls them automatically and injects the return value. Fixtures can have scopes: function (default, called per test), class, module, session (called once per test session). Fixtures can yield for teardown code after the test.

Use pytest -k "test_name" to run tests matching a substring expression. Use pytest path/test_file.py::test_function for a specific test. Use pytest -m marker_name to run marked tests. Use pytest --lf to rerun only the tests that failed last time. Use pytest -x to stop on first failure.

MagicMock is a mock object - it accepts any method call or attribute access and records them for inspection. patch() is a context manager/decorator that temporarily replaces a name in a module with a mock during the test. You use both together: @patch('module.ClassName') to replace something with a MagicMock so tests don't call real external services.