Unit and Integration Testing with Pytest

Updated

September 7, 2026

Unit and Integration Testing with Pytest

After reading this chapter, you will master testing in Python using pytest, write declarative assertions without boilerplate classes, manage test dependencies and teardown with fixtures, parameterize test suites across input matrices with @pytest.mark.parametrize, and isolate side effects using unittest.mock.

Mental model

Unlike legacy frameworks (unittest.TestCase) requiring verbose inheritance hierarchies and specialized assertion methods (self.assertEqual), pytest intercepts Python’s native assert statement via AST rewriting to generate comprehensive failure diffs:

Test Execution Pipeline:
  pytest CLI
    │
    ▼ Test Discovery
  Searches for test_*.py and *_test.py files
    │
    ▼ Dependency Injection
  Resolves requested fixture parameters from fixture tree
    │
    ▼ AST Assertion Rewriting
  assert calculated == expected ──▶ On failure: prints detailed variable introspection
    │
    ▼ Teardown
  Executes post-yield cleanup in reverse fixture dependency order

Minimal example

Save as test_metrics_engine.py:

# test_metrics_engine.py
import pytest

def calculate_percentile(values: list[float], percentile: float) -> float:
    """Compute the specified percentile (0.0 - 1.0) of a sorted list."""
    if not values:
        raise ValueError("Cannot calculate percentile of empty sequence")
    if not (0.0 <= percentile <= 1.0):
        raise ValueError("Percentile must fall between 0.0 and 1.0")

    sorted_vals = sorted(values)
    k = (len(sorted_vals) - 1) * percentile
    f = int(k)
    c = f + 1 if f + 1 < len(sorted_vals) else f
    d = k - f
    return sorted_vals[f] + d * (sorted_vals[c] - sorted_vals[f])

# 1. Parameterized test matrix
@pytest.mark.parametrize(
    "percentile,expected",
    [
        (0.0, 10.0),   # Min
        (0.5, 30.0),   # Median
        (1.0, 50.0),   # Max
    ]
)
def test_percentile_calculations(percentile: float, expected: float) -> None:
    data = [10.0, 20.0, 30.0, 40.0, 50.0]
    result = calculate_percentile(data, percentile)
    assert result == pytest.approx(expected, rel=1e-3)

# 2. Testing error boundaries with pytest.raises
def test_percentile_empty_data_raises() -> None:
    with pytest.raises(ValueError, match="empty sequence"):
        calculate_percentile([], 0.5)

if __name__ == "__main__":
    import sys
    # Direct invocation runner
    pytest.main(["-v", __file__])

Run via uv run python test_metrics_engine.py:

============================= test session starts ==============================
...
test_metrics_engine.py::test_percentile_calculations[0.0-10.0] PASSED   [ 25%]
test_metrics_engine.py::test_percentile_calculations[0.5-30.0] PASSED   [ 50%]
test_metrics_engine.py::test_percentile_calculations[1.0-50.0] PASSED   [ 75%]
test_metrics_engine.py::test_percentile_empty_data_raises PASSED        [100%]
============================== 4 passed in 0.02s ===============================

Worked examples

Case 1: Fixtures with Setup and Teardown Lifecycles

Fixtures using yield execute setup code before the test, suspend execution, pass the resource to the test, and execute cleanup code after the test completes:

# test_storage_fixture.py
import pytest
import tempfile
import sqlite3
from pathlib import Path
from collections.abc import Generator

@pytest.fixture
def db_conn() -> Generator[sqlite3.Connection, None, None]:
    """Provide a fresh in-memory database with pre-populated schema."""
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE inventory (sku TEXT PRIMARY KEY, qty INT)")
    conn.execute("INSERT INTO inventory VALUES ('SKU-100', 42)")
    conn.commit()

    # Yield control to the test function
    yield conn

    # Teardown code runs after test finishes
    conn.close()

def test_inventory_query(db_conn: sqlite3.Connection) -> None:
    cursor = db_conn.execute("SELECT qty FROM inventory WHERE sku = 'SKU-100'")
    qty = cursor.fetchone()[0]
    assert qty == 42

Case 2: Isolating External Dependencies with unittest.mock

When testing code that calls external web services, email servers, or payment gateways, use mock to isolate the unit under test:

# test_auth_client.py
from unittest.mock import patch, MagicMock

class Authenticator:
    def check_remote_token(self, token: str) -> bool:
        # In production: makes a real network HTTP call to an OAuth provider
        raise NotImplementedError("Real network endpoint")

def grant_access(user_token: str, auth: Authenticator) -> str:
    if auth.check_remote_token(user_token):
        return "ACCESS_GRANTED"
    return "ACCESS_DENIED"

def test_grant_access_with_mock() -> None:
    # Create a mock authenticator
    mock_auth = MagicMock(spec=Authenticator)
    mock_auth.check_remote_token.return_value = True

    status = grant_access("valid_token_string", mock_auth)
    assert status == "ACCESS_GRANTED"
    mock_auth.check_remote_token.assert_called_once_with("valid_token_string")

if __name__ == "__main__":
    test_grant_access_with_mock()
    print("Mock unit test passed successfully!")

Run:

uv run python test_auth_client.py

Output:

Mock unit test passed successfully!

Pitfalls

Pitfall 1: Mutating Shared Session Fixtures

If a fixture has scope="session" or scope="module", all tests share the exact same instance. If Test A mutates that instance, Test B may fail spuriously depending on test execution order. Default to scope="function" unless resources are read-only or strictly stateless.

Pitfall 2: Testing Implementation Details Instead of Contracts

Tests that assert every internal private method call (mock_obj._internal_step.assert_called()) become brittle and break upon simple refactoring. Test observable inputs and outputs.


Exercises

  1. Write a parametrized test suite for an IP address validator testing 5 valid IPs and 5 invalid IPs.
  2. Create a temporary directory fixture using tempfile.TemporaryDirectory that creates a test file and verifies deletion on test completion.
  3. Use unittest.mock.patch("time.time") to test a rate limiter with simulated deterministically advancing time.
  4. Use pytest -k and pytest -m to organize tests using custom markers (@pytest.mark.slow, @pytest.mark.integration).

Further reading

  • Pytest Documentation: Fixtures, Marks, and Parametrization.
  • Python Standard Library: unittest.mock documentation.
  • Brian Okken: Python Testing with pytest, Second Edition.