pytest — The De-Facto Standard
Why Testing Matters
The Problem: Untested code is broken by default — you just haven't found the bug yet. Refactoring becomes terrifying without a safety net.
The Solution: pytest's plain-assert syntax, fixtures, parametrize, mocking, and coverage make writing tests cheap and reading failures painless.
Real Impact: Teams that test seriously refactor confidently, ship more often, and spend less time firefighting in production.
Real-World Analogy
Think of tests as guardrails on a mountain road:
- Unit test = a guardrail on one curve — catches a specific kind of mistake
- Fixture = the rest stop with snacks and tools needed for the next stretch
- Parametrize = the same guardrail extended across every similar curve
- Mock = a cardboard cutout of the cliff so you can practice without falling
- Coverage = the map showing which roads still don't have guardrails
The standard library ships unittest, but pytest has won the ecosystem. Better assertions, fixtures, plugins, and zero boilerplate.
(.venv) $ pip install pytest pytest-cov
Your First Test
# myapp/math.py
def add(a, b):
return a + b
# tests/test_math.py
from myapp.math import add
def test_add():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
(.venv) $ pytest -v
tests/test_math.py::test_add PASSED
tests/test_math.py::test_add_negative PASSED
pytest Conventions
- Files named
test_*.pyor*_test.py - Functions named
test_* - Classes named
Test*(no__init__) - Use plain
assert— pytest rewrites it for rich failure output
Fixtures
Fixtures provide reusable setup. Declare them with @pytest.fixture and inject by name.
import pytest
@pytest.fixture
def sample_user():
return {"name": "Alice", "age": 30}
def test_user_name(sample_user):
assert sample_user["name"] == "Alice"
Fixtures with Setup & Teardown
@pytest.fixture
def tmp_db():
db = Database(":memory:")
db.migrate()
yield db # test runs here
db.close() # teardown
Scopes — Reuse Across Tests
@pytest.fixture(scope="session")
def api_client():
client = create_client()
yield client
client.close()
# Scopes: function (default), class, module, session
conftest.py
Place fixtures in conftest.py to share across all tests in the directory.
Parametrize
Run the same test against many inputs.
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(100, 200, 300),
])
def test_add(a, b, expected):
assert add(a, b) == expected
4 separate test cases, each shown individually in output. Failures point to the exact parameters.
Marking Tests
@pytest.mark.skip(reason="not yet implemented")
def test_feature(): ...
@pytest.mark.skipif(sys.version_info < (3, 12), reason="needs 3.12+")
def test_new_syntax(): ...
@pytest.mark.xfail # expected to fail (known bug)
def test_broken(): ...
@pytest.mark.slow # custom marker
def test_long_running(): ...
$ pytest -m "not slow" # skip slow tests
Testing Exceptions
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
1 / 0
def test_error_message():
with pytest.raises(ValueError, match="invalid email"):
validate_email("not-an-email")
Mocking
unittest.mock ships with the standard library. Use mocker from pytest-mock for cleaner integration.
from unittest.mock import patch, MagicMock
def test_get_user():
with patch("myapp.api.requests.get") as mock_get:
mock_get.return_value.json.return_value = {"name": "Alice"}
result = get_user(42)
assert result["name"] == "Alice"
mock_get.assert_called_once_with("https://api.example.com/users/42")
monkeypatch — pytest's built-in
def test_env(monkeypatch):
monkeypatch.setenv("API_KEY", "test123")
monkeypatch.setattr("myapp.api.requests.get", fake_get)
...
Mock at the import point
If myapp.api does from requests import get, you must patch myapp.api.get, NOT requests.get. Patch where the name is looked up, not where it lives.
Coverage
(.venv) $ pytest --cov=myapp --cov-report=term-missing
--------- coverage: platform darwin, python 3.12.3 ----------
Name Stmts Miss Cover Missing
-------------------------------------------------
myapp/core.py 45 2 96% 34-35
myapp/cli.py 28 0 100%
100% coverage is not the goal — meaningful tests are. But coverage reports highlight forgotten branches.
🎯 Practice Exercises
Exercise 1: First test suite
Pick a small function (calculator, validator). Write 5 tests covering happy path and edge cases.
Exercise 2: Parametrize
Convert a function with 10 test cases into one parametrized test.
Exercise 3: Fixture chain
Build a fixture for a temp DB, another that uses it to seed sample data. Use both in a test.
Exercise 4: Mock external API
Test a function that calls a public API. Mock requests.get to return fixed JSON. Verify the function processes it correctly.