pytest Basics
Install pytest and write your first tests. Test functions start with test_; test files start with test_ or end with _test:
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
# 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
collected 3 items test_math_utils.py ... [100%] 3 passed in 0.03s
Recommended project layout:
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:
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():
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:
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:
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:
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()
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:
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
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:
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
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:
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (deselect with '-m not slow')",
"integration: marks integration tests requiring external services",
]
# 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:
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)
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:
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:
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:
[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"
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.