pytest is the standard Python test framework. Knowing it well separates "I write some tests" from "I have a real test suite".
Why pytest over unittest
- Less boilerplate (functions, not classes).
- Better assertions (
assert x == ywith rich diff output). - Fixtures (composable test setup).
- Parametrization (run same test with many inputs).
- Rich plugin ecosystem (pytest-cov, pytest-asyncio, pytest-mock).
The basics
# tests/test_math.py
def test_addition():
assert 1 + 1 == 2
def test_division_by_zero():
import pytest
with pytest.raises(ZeroDivisionError):
1 / 0
Run:
pytest # all tests
pytest tests/test_math.py # specific file
pytest -k "addition" # tests matching name
pytest -v # verbose
pytest -x # stop on first failure
pytest --pdb # drop into debugger on failure
Fixtures
Reusable test setup:
import pytest
@pytest.fixture
def user_data():
return {"name": "Alice", "email": "alice@example.com"}
def test_user_name(user_data):
assert user_data["name"] == "Alice"
def test_user_email(user_data):
assert "@" in user_data["email"]
pytest injects the fixture value based on parameter name. Each test gets a fresh instance (by default).
Fixture scopes
@pytest.fixture(scope="function") # default: new instance per test
@pytest.fixture(scope="class") # one per test class
@pytest.fixture(scope="module") # one per test file
@pytest.fixture(scope="session") # one per pytest run
Use session-scoped for expensive setup (DB connection, app startup).
Fixture cleanup
@pytest.fixture
def temp_file():
path = "/tmp/test.txt"
open(path, "w").write("data")
yield path # test runs here
os.remove(path) # cleanup after test
yield is the test boundary; code before = setup; after = teardown.
Fixture composition
@pytest.fixture
def db_connection():
return create_connection()
@pytest.fixture
def user(db_connection):
return create_user(db_connection, name="Alice")
def test_login(user):
assert user.login("password")
Fixtures can depend on fixtures. pytest resolves the chain.
conftest.py
Fixtures defined in conftest.py are available to all tests in that directory and below — no import needed.
tests/
├── conftest.py # fixtures available everywhere
├── api/
│ ├── conftest.py # additional fixtures for api tests
│ └── test_users.py
Parametrization
Run the same test with multiple inputs:
@pytest.mark.parametrize("input,expected", [
(1, 1),
(2, 4),
(3, 9),
(-1, 1),
])
def test_square(input, expected):
assert input * input == expected
Generates 4 distinct tests; each appears separately in output.
Parametrize ids for readability
@pytest.mark.parametrize("input,expected", [
(1, 1),
(2, 4),
], ids=["one_squared", "two_squared"])
Shows nice names in test output.
Parametrize fixtures
@pytest.fixture(params=[1, 2, 3])
def number(request):
return request.param
def test_positive(number):
assert number > 0
Each test runs 3 times, once per param value.
Mocking
Replace external dependencies with controlled fakes:
from unittest.mock import patch, MagicMock
@patch("my_module.requests.get")
def test_api_call(mock_get):
mock_get.return_value.json.return_value = {"result": 42}
result = my_module.fetch_data()
assert result == 42
mock_get.assert_called_once_with("https://api.example.com")
patch replaces an attribute for the test's duration.
Pytest-mock plugin
Cleaner API:
def test_api_call(mocker):
mock_get = mocker.patch("my_module.requests.get")
mock_get.return_value.json.return_value = {"result": 42}
...
mocker is a pytest fixture providing scoped mocks.
What to mock
- External services (HTTP, DB, queues).
- Time (
mocker.patch("time.time")to test time-dependent logic). - Random (set seed or mock).
- File I/O (in some cases; often a temp dir is cleaner).
What NOT to mock:
- Your own code unless absolutely necessary.
- The class under test (defeats the purpose).
- Standard library (usually).
Coverage
pip install pytest-cov
pytest --cov=src --cov-report=html
Generates an HTML report in htmlcov/ showing line-by-line coverage.
Aim for: 70-90% on critical paths; 100% is rarely worth the effort.
Coverage shows WHAT was executed, not WHAT was tested. A line can be covered by a test that doesn't verify anything meaningful.
Async tests
import pytest
@pytest.mark.asyncio
async def test_async_fetch():
result = await fetch_data()
assert result == "expected"
Requires pytest-asyncio. Add to pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto" # auto-detect async tests
Test organization
src/
└── my_package/
├── core.py
└── utils.py
tests/
├── conftest.py # shared fixtures
├── unit/
│ ├── test_core.py
│ └── test_utils.py
└── integration/
├── conftest.py
└── test_api.py
Mirror your source layout. One test file per module (or one per concern within a module).
Test types — the pyramid
/\
/e2e\ ← few; slow; high value but flaky
/------\
/integr. \ ← some; medium speed; cover interactions
/----------\
/ unit \ ← many; fast; cover individual functions
/--------------\
- Unit: one function, mocked deps. Fast (ms). Lots of them.
- Integration: real components together (real DB, no network mocks). Medium (seconds).
- End-to-end: full system from outside. Slow (10+ seconds). Few.
Most tests should be unit; integration covers component boundaries; e2e covers critical user flows.
Test independence
Tests should run in any order, any subset, any number of times. Implications:
- No shared mutable state (use fixtures with fresh instances).
- No "this test depends on that test running first."
- Reset DB / clear caches between tests.
Tools: pytest-randomly runs tests in random order to catch hidden dependencies.
Production patterns
Pattern 1: Test factories
@pytest.fixture
def make_user(db):
def factory(**kwargs):
defaults = {"name": "Test User", "email": "test@example.com"}
return User(**{**defaults, **kwargs})
return factory
def test_admin_user(make_user):
admin = make_user(role="admin")
assert admin.role == "admin"
Flexible test data creation; each test can customize.
Pattern 2: Real DB for integration
@pytest.fixture(scope="session")
def db_engine():
engine = create_engine(TEST_DATABASE_URL)
Base.metadata.create_all(engine)
yield engine
Base.metadata.drop_all(engine)
@pytest.fixture
def db(db_engine):
connection = db_engine.connect()
transaction = connection.begin()
yield connection
transaction.rollback() # roll back per-test
connection.close()
Real DB; per-test transactions roll back. Fast and accurate.
Pattern 3: Snapshots
For complex outputs:
import pytest_snapshot
def test_render(snapshot):
output = render_template(...)
snapshot.assert_match(output, "expected.html")
Useful for HTML, JSON output, generated docs.
Common pytest mistakes
- Tests depend on each other. Order-sensitive = brittle.
- Over-mocking. Test passes; production broken (mocked the bug away).
- Coverage as the goal. 90% coverage of meaningless code is useless.
- No CI integration. Tests that don't run aren't tests.
- Slow test suite. > 5 min = developers stop running it locally.
Takeaway
pytest: standard Python test framework. Fixtures (@pytest.fixture) for setup; scopes for sharing. Parametrize for multiple inputs. Mock external deps (pytest-mock); don't over-mock. Test pyramid: many unit, some integration, few e2e. Coverage as a guide, not a goal. Tests independent and order-insensitive. CI runs them. Fast suite = developers run it; slow suite = ignored.
Production Note: Mocking Rule — Patch Where Used, Not Where Defined
The #1 failure mode with unittest.mock.patch occurs when patching a function at its source definition instead of where the code under test imports it:
# services/billing.py
from time import time # Binds 'time' directly into services.billing namespace!
def process_billing():
current_time = time()
...
If your test executes @patch("time.time"), services.billing.time still points to the original, unpatched function!
Rule: Patch the name in the namespace where it is looked up:
# Correct:
@patch("services.billing.time")
def test_billing(mock_time):
mock_time.return_value = 1700000000.0