pytest fixtures provide data and resources without repeating setup. Mocks and monkeypatch control external state such as environment variables, time, or HTTP. Used sparingly, they create deterministic tests; overused, they validate a simulation instead of the system.

This article builds on the pytest fundamentals guide.

Fixture with cleanup

import pytest

@pytest.fixture
def repository(tmp_path):
    file = tmp_path / "users.json"
    file.write_text("[]", encoding="utf-8")
    yield file

def test_repository_starts_empty(repository):
    assert repository.read_text(encoding="utf-8") == "[]"

Code before yield prepares the resource; code after it can release external resources. Keep function scope unless sharing an expensive resource is safe. Mutable session-scoped state often creates order-dependent tests.

Control environment and calls

def test_production_mode(monkeypatch):
    monkeypatch.setenv("APP_MODE", "production")
    assert current_mode() == "production"

pytest restores the environment afterward. Its monkeypatch guide covers attributes, dictionaries, paths, and environment variables.

For HTTP, patch the name used by your module and assert your function's behavior:

class FakeResponse:
    def json(self):
        return {"status": "ok"}

def test_status(monkeypatch):
    monkeypatch.setattr("app.client.requests.get", lambda *a, **k: FakeResponse())
    assert fetch_status()["status"] == "ok"

Keep integration tests to verify timeouts, errors, and the real HTTP client contract.

Compose fixtures instead of building one giant setup

A fixture can request another fixture. This makes each resource understandable and lets a test request only what it uses:

import json
import pytest

@pytest.fixture
def empty_store(tmp_path):
    path = tmp_path / "users.json"
    path.write_text("[]", encoding="utf-8")
    return path

@pytest.fixture
def populated_store(empty_store):
    users = [{"id": 1, "name": "Ada"}]
    empty_store.write_text(json.dumps(users), encoding="utf-8")
    return empty_store

def test_reads_existing_user(populated_store):
    assert load_users(populated_store)[0]["name"] == "Ada"

Prefer returning a value when no teardown is needed. Use yield when a file handle, server, transaction, or similar resource must be released. If setup fails before reaching yield, teardown in that fixture cannot run, so acquire risky resources in separate fixtures or protect acquisition with a context manager.

Scopes include function, class, module, package, and session. A broader scope can save time, but it also shares state. A session-scoped database filled and mutated by tests is a common source of failures that appear only when the full suite runs. A safer pattern is a session-scoped engine plus a function-scoped transaction that rolls back after each test.

Make test data readable with parametrization

Fixtures are for resources and reusable setup. When only inputs and expected outputs vary, parametrization is clearer:

@pytest.mark.parametrize(
    ("email", "valid"),
    [
        ("[email protected]", True),
        ("missing-at.example.com", False),
        ("", False),
    ],
)
def test_email_validation(email, valid):
    assert is_valid_email(email) is valid

Give complicated cases an id so failure reports explain the scenario. Avoid branching inside a test loop because the first failure prevents later cases from being reported independently. The official parametrization guide covers test, class, and module-level approaches.

Patch where the name is looked up

The most frequent mocking mistake is replacing the object at its definition instead of the name used by the system under test. If app.client contains from requests import get, patch app.client.get, not requests.get. Imports create bindings, and the code consults that local binding at runtime.

Use monkeypatch.setattr(..., raising=True), the default, so a misspelled attribute fails immediately. For dictionaries, use setitem and delitem; for environment variables, use setenv and delenv. monkeypatch.context() can restrict a dangerous patch to a small block:

def test_home_directory(monkeypatch, tmp_path):
    with monkeypatch.context() as patch:
        patch.setattr(Path, "home", lambda: tmp_path)
        assert config_path() == tmp_path / ".myapp.toml"

pytest reverses these changes even after an assertion fails. That cleanup is more reliable than assigning the original value back manually.

Use Mock when interaction is part of the contract

unittest.mock is useful when the important result is an interaction, such as sending one notification after a successful operation:

from unittest.mock import Mock

def test_notifies_after_saving():
    repository = Mock()
    notifier = Mock()
    service = UserService(repository, notifier)

    service.create("[email protected]")

    repository.save.assert_called_once()
    notifier.send.assert_called_once_with("[email protected]")

Use spec or autospec to catch calls to attributes that do not exist on the real collaborator. Still, do not assert every internal call. Such tests break during harmless refactoring and can pass even when the user-visible result is wrong. A small fake implementation is often better for repositories and queues because it behaves like a component rather than a script of expected calls.

When replacing asynchronous functions, use AsyncMock and await the production behavior. For exceptions, configure side_effect; for sequential responses, provide a list. Always test the timeout or error path that motivated the mock.

Control time and randomness at a boundary

Tests become flaky when they read the real clock or generate uncontrolled random values. Instead of patching many standard-library internals, inject a callable:

from datetime import datetime, timezone

def create_record(now=lambda: datetime.now(timezone.utc)):
    return {"created_at": now().isoformat()}

def test_record_uses_current_time():
    fixed = datetime(2026, 1, 2, tzinfo=timezone.utc)
    record = create_record(now=lambda: fixed)
    assert record["created_at"] == "2026-01-02T00:00:00+00:00"

This design makes the dependency explicit and reduces patching. Apply the same idea to UUID generation, random selection, and network clients.

Keep conftest.py predictable

pytest discovers conftest.py files by directory. A fixture defined near a test package is available below that directory without import statements. Use this hierarchy deliberately: suite-wide infrastructure can live at the test root, while feature-specific factories belong beside that feature.

Autouse fixtures should be rare because they change tests without appearing in their signatures. They are reasonable for universal safeguards, such as blocking accidental network access, but hidden mutable setup makes failures harder to understand. Name fixtures for what they provide, such as authenticated_client, rather than how they do it, such as setup_user.

Choose the appropriate level of isolation

A unit test should be fast and deterministic, but replacing every collaborator is not the goal. Pure functions, in-memory SQLite where compatible, temporary directories, and local fake servers can exercise more real behavior than mocks. Integration tests should verify serialization, database constraints, HTTP adapter configuration, and framework wiring. End-to-end tests can cover a few critical journeys.

Review a failing test by asking whether it found a behavior regression or merely an implementation change. A maintainable suite has clear arrange, act, and assert phases; independent tests; meaningful failure messages; and no dependency on execution order. Run tests with randomized order occasionally if the project supports it, and fix leaked state rather than pinning the order.

Shared fixtures can live in conftest.py, but keep them close to consumers and name the resource clearly. Prefer tmp_path, test failure cases, and avoid network calls in unit tests. The fixture reference lists built-ins. A useful test fails when behavior changes, not when irrelevant implementation details move.

Diagnose failures systematically

Run the smallest failing test first, then its module, then the suite. A test that passes alone but fails in the suite usually reveals leaked global state, an unclosed resource, a reused fixture, or reliance on order. Inspect scope and teardown before adding retries.

When a mock assertion fails, inspect the actual arguments and ask whether the expectation represents a public contract. A passing mock test does not prove that a third-party signature still matches unless a spec or integration test checks it. Use pytest -k to select scenarios and pytest -x when the first failure is most informative.

Cover boundary failures, not only success. An HTTP adapter should face timeout, refused connection, invalid JSON, unexpected status, and incomplete valid data. A fake repository can test service rules, but only the real database confirms constraints and transactions. Coverage percentage does not replace meaningful assertions. Keep each test focused on one behavior, remove obsolete mocks during refactoring, and never make a test green before confirming whether the expected behavior deliberately changed.