Skip to content

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)

  1. What to unit test
  2. unittest — stdlib framework
  3. pytest — recommended for new code
  4. Fixtures
  5. Parametrized tests
  6. Mocking with unittest.mock
  7. Testing exceptions
  8. Testing file I/O
  9. Coverage
  10. Test organization
  11. pytest markers
  12. 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()
python -m unittest discover -s tests -p "test_*.py" -v
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()

# 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)
pip install pytest
pytest tests/ -v
pytest tests/test_math_utils.py::test_below_min -v
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#

pip install pytest-cov
pytest --cov=myapp --cov-report=term-missing tests/
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():
    ...
pytest -m "not slow"

Register markers in pyproject.toml to avoid warnings:

[tool.pytest.ini_options]
markers = [
    "slow: long-running tests",
]

Doctest — inline examples#

def add(a: int, b: int) -> int:
    """Add two integers.

    >>> add(2, 3)
    5
    """
    return a + b
python -m doctest myapp/math_utils.py -v
pytest --doctest-modules src/myapp

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#

  1. What is the difference between unit and integration tests?
  2. Where should you patch an imported dependency?
  3. What pytest fixture scope should a database connection use?
  4. How do parametrized tests improve table-driven coverage?
  5. Why mock HTTP instead of calling real endpoints?
  6. What does tmp_path provide?

What's next#

Topic Page
Exception design Error Handling
Mock HTTP HTTP Requests
File test doubles File IO
Typed tests Type Hints and Annotations