Unit Testing#
Unit tests verify individual functions, classes, and modules in isolation. Python's stdlib unittest and the ecosystem favorite pytest provide discovery, assertions, fixtures, and mocking. Well-tested code catches regressions early, documents expected behavior, and makes refactoring safe.
How to use this page
Prefer pytest for new projects; know unittest for legacy codebases and standard-library-only constraints. Pair with Error Handling for exception assertions and Type Hints and Annotations for typed test code.
At a glance
| Track | Python Advanced → Testing and Debugging |
| Sections | 13 major topics |
| Outline | Use the right-hand TOC to jump |
Topics: What to unit test · unittest — stdlib framework · pytest — recommended for new code · Fixtures · Parametrized tests · Mocking with unittest.mock · Testing exceptions · Testing file I/O · … (+5 more)
- What to unit test
unittest— stdlib frameworkpytest— recommended for new code- Fixtures
- Parametrized tests
- Mocking with
unittest.mock - Testing exceptions
- Testing file I/O
- Coverage
- Test organization
pytestmarkers- Doctest — inline examples … and 1 more sections
What to unit test#
| Test | Example | Skip |
|---|---|---|
| Pure functions | normalize_email("A@B.com") |
Third-party library internals |
| Business logic | Discount calculation | Framework wiring |
| Edge cases | Empty list, None, boundary values | Trivial getters |
| Error paths | Invalid input raises ValueError |
Implementation details that change often |
Unit tests are fast, isolated, and deterministic — no real network, disk, or clock unless explicitly faked.
unittest — stdlib framework#
# tests/test_math_utils.py
import unittest
from myapp.math_utils import clamp
class TestClamp(unittest.TestCase):
def test_within_range(self) -> None:
self.assertEqual(clamp(5, 0, 10), 5)
def test_below_min(self) -> None:
self.assertEqual(clamp(-1, 0, 10), 0)
def test_invalid_range(self) -> None:
with self.assertRaises(ValueError):
clamp(5, 10, 0)
if __name__ == "__main__":
unittest.main()
| Assertion | Purpose |
|---|---|
assertEqual(a, b) |
Equality |
assertTrue(x) / assertFalse(x) |
Boolean |
assertRaises(Exc) |
Exception context manager |
assertIn(a, b) |
Membership |
assertAlmostEqual(a, b) |
Float tolerance |
setUp / tearDown#
class TestDatabase(unittest.TestCase):
def setUp(self) -> None:
self.db = InMemoryDB()
def tearDown(self) -> None:
self.db.close()
pytest — recommended for new code#
# tests/test_math_utils.py
import pytest
from myapp.math_utils import clamp
def test_within_range():
assert clamp(5, 0, 10) == 5
def test_below_min():
assert clamp(-1, 0, 10) == 0
def test_invalid_range():
with pytest.raises(ValueError, match="min"):
clamp(5, 10, 0)
| Feature | Benefit |
|---|---|
Plain assert |
No assertion method zoo |
| Fixture injection | Reusable setup via function args |
| Parametrize | One test, many inputs |
| Rich failure output | Shows diff on assert failure |
| Plugin ecosystem | cov, mock, httpx, asyncio |
Fixtures#
import pytest
from myapp.catalog import Catalog
@pytest.fixture
def catalog() -> Catalog:
c = Catalog()
c.add("python", price=29.99)
return c
def test_lookup(catalog: Catalog) -> None:
assert catalog.price("python") == 29.99
Fixture scopes#
| Scope | Lifetime |
|---|---|
function |
Per test (default) |
class |
Per test class |
module |
Per file |
session |
Entire test run |
@pytest.fixture(scope="module")
def api_client():
client = create_test_client()
yield client
client.close()
conftest.py#
Place shared fixtures in tests/conftest.py — pytest discovers them automatically for that directory tree.
Parametrized tests#
import pytest
@pytest.mark.parametrize(
"value, lo, hi, expected",
[
(5, 0, 10, 5),
(-1, 0, 10, 0),
(99, 0, 10, 10),
],
)
def test_clamp(value, lo, hi, expected):
assert clamp(value, lo, hi) == expected
One failure does not skip remaining parameter sets — pytest reports each case independently.
Mocking with unittest.mock#
from unittest.mock import patch, MagicMock
import httpx
from myapp.users import fetch_user
@patch("myapp.users.httpx.get")
def test_fetch_user(mock_get: MagicMock) -> None:
mock_get.return_value = MagicMock(
status_code=200,
json=lambda: {"id": 1, "name": "Ada"},
)
mock_get.return_value.raise_for_status = MagicMock()
user = fetch_user(1)
assert user["name"] == "Ada"
mock_get.assert_called_once_with("https://api.example.com/users/1", timeout=10.0)
| Tool | Use |
|---|---|
patch("module.attr") |
Replace object during test |
MagicMock() |
Flexible fake with recorded calls |
side_effect |
Raise exception or return sequential values |
spec=RealClass |
Mock limited to real interface |
Patch where the name is used, not where it is defined
If users.py does import httpx and calls httpx.get, patch myapp.users.httpx.get.
pytest-mock fixture#
def test_fetch(mocker):
mock_get = mocker.patch("myapp.users.httpx.get")
mock_get.return_value.json.return_value = {"id": 1}
...
Testing exceptions#
import pytest
def parse_age(raw: str) -> int:
age = int(raw)
if age < 0:
raise ValueError("age must be non-negative")
return age
def test_negative_age():
with pytest.raises(ValueError, match="non-negative"):
parse_age("-1")
Match the message for documentation value — avoid over-specific regex on full tracebacks.
Testing file I/O#
import json
from pathlib import Path
import pytest
from myapp.loader import load_config
def test_load_config(tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({"debug": True}), encoding="utf-8")
assert load_config(config_file)["debug"] is True
tmp_path is a built-in pytest fixture — prefer over manual temp dirs. See File IO.
Coverage#
| Metric | Meaning |
|---|---|
| Line coverage | % of lines executed |
| Branch coverage | % of if/else branches taken (requires --cov-branch) |
Aim for high coverage on critical paths, not 100% everywhere. Untested error handlers and edge cases are where bugs hide.
Test organization#
project/
├── src/
│ └── myapp/
│ ├── __init__.py
│ └── math_utils.py
└── tests/
├── conftest.py
├── test_math_utils.py
└── integration/
└── test_api.py
| Convention | Rule |
|---|---|
| File names | test_*.py or *_test.py |
| Function names | test_* for pytest |
| Class names | Test* (no __init__ for pytest classes) |
| Mirror structure | tests/test_foo.py tests myapp/foo.py |
pytest markers#
import pytest
@pytest.mark.slow
def test_full_reindex():
...
@pytest.mark.skip(reason="waiting on API v2")
def test_legacy_endpoint():
...
@pytest.mark.xfail(reason="known bug #42")
def test_edge_case():
...
Register markers in pyproject.toml to avoid warnings:
Doctest — inline examples#
Great for small pure functions; pytest scales better for full suites.
Anti-patterns#
| Anti-pattern | Fix |
|---|---|
| Tests depend on execution order | Isolate state per test |
| Sleep for async timing | Mock time or use deterministic waits |
| Hit real APIs in unit tests | Mock HTTP layer — HTTP Requests |
Assert private _method |
Test public behavior |
| Giant fixture setup per test | Narrow fixture scope |
Interview traps (quick reference)#
| Trap | What goes wrong | Safe approach |
|---|---|---|
| "Unit tests need a running server" | Slow, flaky suite | Mock I/O boundaries |
| Patch at definition site | Mock not applied | Patch where imported/used |
assert True placeholder |
False confidence | Meaningful assertions |
| Shared mutable global in tests | Order-dependent failures | Fixtures with fresh state |
| No exception message check | Wrong exception passes | pytest.raises(..., match=) |
| Testing implementation not behavior | Brittle tests | Assert outputs and contracts |
| 100% coverage obsession | Missing logic branches | Cover edge cases and errors |
Mental model checklist#
- What is the difference between unit and integration tests?
- Where should you
patchan imported dependency? - What pytest fixture scope should a database connection use?
- How do parametrized tests improve table-driven coverage?
- Why mock HTTP instead of calling real endpoints?
- What does
tmp_pathprovide?
What's next#
| Topic | Page |
|---|---|
| Exception design | Error Handling |
| Mock HTTP | HTTP Requests |
| File test doubles | File IO |
| Typed tests | Type Hints and Annotations |