pytest is the de facto standard for testing Python code in 2026 โ simpler and more powerful than the built-in unittest. Good tests catch bugs before production, enable confident refactoring, and document how your code should behave. This guide takes you from your first test to advanced patterns.
๐ Table of Contents
- Setup
- Your First Test
- The AAA Pattern (Arrange, Act, Assert)
- Fixtures โ Reusable Test Setup
- Parametrization โ Test Many Cases
- Mocking โ Isolate Code from Dependencies
- Testing Exceptions and Edge Cases
- Measuring Coverage
- Configuration (pytest.ini)
- Best Practices
- Frequently Asked Questions
- Conclusion
Setup
pip install pytest pytest-cov
# Project structure
myproject/
โโโ src/
โ โโโ calculator.py
โโโ tests/
โ โโโ test_calculator.py
โโโ pytest.ini
Your First Test
# src/calculator.py
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
# tests/test_calculator.py
from src.calculator import add, divide
import pytest
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
def test_divide():
assert divide(10, 2) == 5
def test_divide_by_zero_raises():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
# Run the tests
pytest # run all tests
pytest -v # verbose (shows each test name)
pytest tests/test_calculator.py::test_add # run one test
pytest -k "divide" # run tests matching "divide"
The AAA Pattern (Arrange, Act, Assert)
def test_user_discount():
# Arrange โ set up test data
user = User(name="Alice", is_premium=True)
cart = Cart(total=100)
# Act โ call the code under test
final_price = apply_discount(cart, user)
# Assert โ verify the result
assert final_price == 80 # 20% premium discount
Fixtures โ Reusable Test Setup
import pytest
@pytest.fixture
def sample_user():
# Provides a fresh user for each test that needs one
return User(name="Alice", email="alice@example.com")
@pytest.fixture
def db_connection():
# Setup and teardown - code after yield runs as cleanup
conn = create_test_db()
yield conn # test runs here
conn.close() # cleanup after test
# Tests receive fixtures as arguments
def test_user_email(sample_user):
assert sample_user.email == "alice@example.com"
def test_save_user(sample_user, db_connection):
db_connection.save(sample_user)
assert db_connection.count() == 1
Parametrization โ Test Many Cases
import pytest
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_add_many_cases(a, b, expected):
assert add(a, b) == expected
# Runs 4 separate tests, one per tuple โ clear failure reporting per case
Mocking โ Isolate Code from Dependencies
from unittest.mock import patch, MagicMock
# Mock an external API call so tests don't hit the network
def test_fetch_user_data():
with patch('src.api.requests.get') as mock_get:
mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}
mock_get.return_value.status_code = 200
result = fetch_user_data(1)
assert result["name"] == "Alice"
mock_get.assert_called_once_with("https://api.example.com/users/1")
# Mock with pytest-mock (cleaner)
def test_with_mocker(mocker):
mock_db = mocker.patch('src.service.database')
mock_db.get_user.return_value = {"name": "Bob"}
result = get_user_name(1)
assert result == "Bob"
Testing Exceptions and Edge Cases
def test_raises_on_invalid_input():
with pytest.raises(ValueError) as exc_info:
validate_age(-5)
assert "must be positive" in str(exc_info.value)
def test_edge_cases():
assert process([]) == [] # empty input
assert process([1]) == [1] # single item
assert process(None) is None # None handling
Measuring Coverage
# Run tests with coverage report
pytest --cov=src --cov-report=term-missing
# Output shows which lines aren't tested:
# Name Stmts Miss Cover Missing
# src/calculator.py 10 1 90% 15
# Generate an HTML report
pytest --cov=src --cov-report=html
# Open htmlcov/index.html to see line-by-line coverage
Configuration (pytest.ini)
# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
addopts = -v --cov=src --cov-report=term-missing
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: integration tests
Best Practices
- One assertion concept per test: Each test verifies one behavior โ failures point directly to the problem
- Descriptive names:
test_divide_by_zero_raises_valueerrortells you what broke without reading the code - Test behavior, not implementation: Test what the function does, not how โ so refactoring doesn’t break tests
- Keep tests fast and isolated: Each test should run independently in any order
- Mock external dependencies: Databases, APIs, and file systems should be mocked in unit tests
- Aim for meaningful coverage: 80%+ is a good target, but cover critical paths thoroughly rather than chasing 100%
Frequently Asked Questions
Q: pytest vs unittest?
A: pytest is simpler (plain assert, no boilerplate classes), more powerful (fixtures, parametrization), and has a rich plugin ecosystem. It can run unittest tests too. Use pytest for new projects.
Q: How much test coverage do I need?
A: 80% is a common target. But coverage is a guide, not a goal โ 100% coverage with weak assertions is worse than 80% with thorough tests of critical logic. Focus on covering the code that matters.
Q: Should I mock the database or use a test database?
A: Mock it in unit tests (fast, isolated). Use a real test database in integration tests (verifies actual queries work). Both have their place โ unit tests for logic, integration tests for the data layer.
Q: What’s the difference between a fixture and a regular function?
A: Fixtures are managed by pytest โ they’re set up before tests that request them and can clean up after (via yield). They provide reusable, isolated test dependencies with automatic lifecycle management.
Q: How do I test async code?
A: Install pytest-asyncio and mark tests with @pytest.mark.asyncio. Then you can await inside your test functions to test async functions.
Conclusion
pytest makes Python testing simple and powerful. Start with plain assert statements in the AAA pattern, use fixtures for reusable setup, parametrize to cover many cases concisely, and mock external dependencies to keep tests fast and isolated. Measure coverage to find untested code, but prioritize testing critical logic thoroughly over chasing 100%. Good tests are an investment that pays off every time you refactor or add features with confidence.
๐ You might also like
๐ Share this article




โ๏ธ Leave a Comment