Skip to main content

pytest - The Industry-Standard Test Framework

Reading time: ~35 minutes | Level: Intermediate → Engineering

Before reading further, predict exactly what pytest displays when this test fails:

# No imports. No class. No setUp. pytest discovers and runs this automatically.
def test_add():
assert 1 + 1 == 3
Show Answer
FAILED test_math.py::test_add - assert 2 == 3
+ where 2 = 1 + 1

pytest rewrites the assertion to show you the actual values at the point of failure - not just "assertion failed". You see 2 == 3, with the explanation that 2 came from evaluating 1 + 1.

For more complex expressions:

def test_user_name():
user = {"name": "Alice", "role": "admin"}
assert user["name"] == "Bob"
FAILED test_users.py::test_user_name - assert 'Alice' == 'Bob'

How does pytest do this without modifying your source file and without you using any special assertion function?

At import time, pytest intercepts the module loading of files it collects. It passes the AST (Abstract Syntax Tree) of each test file through a rewriter that transforms every assert statement. The expression assert user["name"] == "Bob" becomes code that evaluates both sides, stores the values, and on failure, constructs a detailed message from those stored values.

This happens via a custom import hook registered in conftest.py's compilation pipeline. Your source file on disk is never modified - only the bytecode loaded into memory is different.

This is the same AST transformation mechanism described in Module 03 (Python Internals). Understanding compile(), ast.parse(), and import hooks makes pytest's assertion rewriting mechanical, not magical.

This is why pytest dominates Python testing. Plain assert statements produce informative failures. No assertEqual, no assertIn, no assertRaises ceremony. The framework does the work.

What You Will Learn

  • Why pytest over unittest: the concrete design advantages
  • Test discovery: the naming conventions and how collection works
  • Assertion rewriting: the AST transformation mechanism at import time
  • Fixtures: @pytest.fixture, scope (function/class/module/session), yield for teardown
  • Fixture injection by name: how pytest uses inspect.signature to wire dependencies
  • conftest.py: sharing fixtures across files, plugin registration
  • @pytest.mark.parametrize: single and multi-parameter, indirect, custom IDs
  • Built-in marks: skip, skipif, xfail, xpass
  • pytest.raises, pytest.warns, pytest.approx
  • Built-in fixtures: tmp_path, capsys, capfd, monkeypatch
  • Running pytest: all essential flags and expressions
  • pytest.ini / pyproject.toml configuration
  • Essential plugins: pytest-cov, pytest-xdist, pytest-mock, pytest-asyncio

Prerequisites

  • Lesson 01 - unittest (understanding what problem pytest is solving)
  • Module 02 - Functional Programming (decorators power @pytest.fixture and @pytest.mark.parametrize)
  • Module 03 - Python Internals (AST transformation, inspect.signature, import hooks - all used internally by pytest)

Part 1 - Why pytest Over unittest

The Concrete Differences

# unittest style - every test is a method in a class
import unittest

class TestBankAccount(unittest.TestCase):
def setUp(self):
self.account = BankAccount(balance=100)

def test_deposit(self):
self.account.deposit(50)
self.assertEqual(self.account.balance, 150)

def test_withdraw_raises_on_insufficient_funds(self):
with self.assertRaises(InsufficientFundsError):
self.account.withdraw(200)
# pytest style - plain functions, plain assert
import pytest

def test_deposit(bank_account): # fixture injected by name
bank_account.deposit(50)
assert bank_account.balance == 150 # plain assert, informative failure output

def test_withdraw_raises_on_insufficient_funds(bank_account):
with pytest.raises(InsufficientFundsError):
bank_account.withdraw(200)

The differences are not cosmetic:

Featureunittestpytest
Test structureMust be methods in a TestCase classPlain functions or methods
AssertionsMust use self.assertEqual, self.assertIn, etc.Plain assert with rewritten output
Setup/teardownsetUp/tearDown methodsFixtures with yield
Shared fixturessetUpClass on each classconftest.py shared across files
Parametrisationself.subTest loop@pytest.mark.parametrize (clean, separate test IDs)
Failure outputMinimal by defaultShows actual vs expected values
Plugin ecosystemMinimalpytest-cov, pytest-xdist, pytest-mock, pytest-asyncio, 1000+ plugins
DiscoveryExplicit or by patternAutomatic by convention

pytest also runs unittest.TestCase subclasses without modification. Migrating a codebase from unittest to pytest means changing nothing in the test files - just running pytest instead of python -m unittest.

Part 2 - Test Discovery

pytest discovers tests automatically based on naming conventions:

project/
├── src/
│ └── bank/
│ ├── account.py
│ └── payment.py
├── tests/
│ ├── conftest.py # shared fixtures
│ ├── test_account.py # discovered: matches test_*.py
│ ├── test_payment.py # discovered
│ ├── account_test.py # discovered: matches *_test.py
│ └── helpers.py # NOT discovered: no test_ prefix/suffix
└── pyproject.toml

Within a discovered file:

  • Functions named test_* are collected
  • Classes named Test* (no __init__) are collected; their test_* methods are collected
  • Everything else is ignored

Part 3 - Assertion Rewriting

How It Works

When pytest collects tests/test_account.py, it does not import it with the standard import machinery. Instead, it uses a custom importer - AssertionRewritingHook - that intercepts the import, reads the source, parses it into an AST, transforms every assert statement node, compiles the modified AST to bytecode, and executes that bytecode.

Your source file is never changed. The transformation happens in memory, in the bytecode that Python executes.

The transformation of assert a == b becomes approximately:

# Your source:
assert user["name"] == "Bob"

# What pytest executes (conceptually):
_left = user["name"]
_right = "Bob"
if not (_left == _right):
raise AssertionError(
f"assert {_left!r} == {_right!r}\n"
f" + where {_left!r} = user['name']"
)

This is why pytest knows what the values were at the time of failure - it evaluated and stored both sides before testing them.

What This Means in Practice

def test_complex_assertion():
users = get_all_users()
active_users = [u for u in users if u.is_active]
assert len(active_users) > 0

Failure output:

FAILED test_users.py::test_complex_assertion
assert 0 > 0
+ where 0 = len([])
+ where [] = [u for u in users if u.is_active]

pytest traces the expression tree and shows you every intermediate value. No custom assertion method required.

:::note Assertion Rewriting Only Works for Files pytest Collects Assertion rewriting is applied to test files during collection. Helper modules - utility functions you import into your tests - are not rewritten.

If you have assertion logic in a shared helper module and want rewriting:

# conftest.py
import pytest
pytest.register_assert_rewrite("tests.helpers")

This must be called before the module is imported. conftest.py is loaded before test collection, making it the correct place for this registration. :::

Part 4 - Fixtures

The Core Concept

A fixture is a function decorated with @pytest.fixture that provides a test with a configured object, resource, or piece of data. pytest injects fixtures into test functions by name - using inspect.signature to read the parameter names of the test function, then looking up matching fixtures.

import pytest

@pytest.fixture
def bank_account():
"""Provides a BankAccount with a starting balance of 100."""
return BankAccount(balance=100)

def test_deposit(bank_account): # pytest sees "bank_account" parameter
bank_account.deposit(50) # resolves to the fixture above
assert bank_account.balance == 150

def test_balance_initially_correct(bank_account):
assert bank_account.balance == 100 # fresh instance - fixtures are called per-test by default

Every test that requests bank_account gets a fresh instance. The fixture function is called once per test by default.

Yield Fixtures for Teardown

yield fixtures are the pytest equivalent of setUp/tearDown in one function. Code before yield is setup; code after yield is teardown:

import pytest
import sqlite3

@pytest.fixture
def db_connection():
"""Provide a fresh in-memory database connection for each test."""
conn = sqlite3.connect(":memory:")
conn.execute("""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL
)
""")
conn.commit()

yield conn # <-- test receives the connection here

conn.close() # <-- runs after the test completes, even on failure

def test_insert_user(db_connection):
db_connection.execute("INSERT INTO users (username) VALUES ('alice')")
db_connection.commit()
cursor = db_connection.execute("SELECT COUNT(*) FROM users")
assert cursor.fetchone()[0] == 1

The yield statement is the handoff point. pytest calls the fixture, runs everything up to yield, passes the yielded value to the test, runs the test, then resumes after yield to execute cleanup - even if the test raised an exception.

Fixture Scope

Fixtures have a scope parameter that controls how often the fixture function is called:

ScopeCalled once perUse for
function (default)Each test functionPer-test isolated state
classEach test classShared state within a class
moduleEach test module fileShared state across a file
sessionThe entire test runExpensive shared resources
import pytest

@pytest.fixture(scope="session")
def database_server():
"""Start a real database server once for the entire test session."""
server = PostgresTestServer()
server.start()
yield server
server.stop()

@pytest.fixture(scope="module")
def db_schema(database_server):
"""Create the schema once per module - reuse the session-scoped server."""
database_server.execute_sql_file("schema.sql")
yield database_server
database_server.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public;")

@pytest.fixture(scope="function")
def clean_db(db_schema):
"""Wrap each test in a transaction that is rolled back after the test."""
transaction = db_schema.begin()
yield db_schema
transaction.rollback() # each test sees a clean database

This layered pattern - session for server startup, module for schema creation, function for transaction isolation - is the standard approach for integration test suites that need both speed and isolation.

:::tip Use session-Scoped Fixtures for Expensive Setup Starting a database server, launching an HTTP server, loading a large ML model - these operations take seconds. Running them once per session instead of once per test can reduce a test suite from minutes to seconds.

@pytest.fixture(scope="session")
def ml_model():
"""Load the model once. All tests share it."""
return load_model("model_weights.pt") # takes 3 seconds

With function scope and 100 tests: 300 seconds of model loading. With session scope: 3 seconds. Same correctness, 100× faster. :::

Fixture Composition - Fixtures Requesting Fixtures

Fixtures can depend on other fixtures. pytest resolves the dependency graph automatically:

@pytest.fixture(scope="session")
def server():
s = start_test_server()
yield s
s.shutdown()

@pytest.fixture(scope="function")
def client(server): # requests the session-scoped server fixture
c = TestClient(server.url)
yield c
c.close()

@pytest.fixture
def authenticated_client(client): # requests the function-scoped client fixture
client.login(username="test_user", password="test_pass")
return client

def test_protected_route(authenticated_client): # gets the fully configured client
response = authenticated_client.get("/api/profile")
assert response.status_code == 200

pytest builds the dependency graph, respects scope constraints (a function-scoped fixture cannot depend on a session-scoped fixture in a way that creates contradictions), and calls each fixture the correct number of times.

:::warning Fixture Order and Circular Dependencies pytest resolves fixture dependencies at collection time. Circular dependencies - fixture A requires fixture B which requires fixture A - raise an error immediately during collection, not during test execution.

Scope constraints are also enforced: a session-scoped fixture cannot depend on a function-scoped fixture. The function fixture would be recreated for every test, making the session fixture's behaviour undefined.

# This raises an error:
@pytest.fixture(scope="session")
def session_thing(function_thing): # ERROR: wider scope cannot depend on narrower scope
...

@pytest.fixture(scope="function")
def function_thing():
...

If you see ScopeMismatch, the fix is to widen the depended-on fixture's scope or narrow the requesting fixture's scope. :::

Part 5 - conftest.py

conftest.py is pytest's mechanism for sharing fixtures, hooks, and plugins across test files without explicit imports. pytest automatically discovers conftest.py files by walking up the directory tree from each test file.

tests/
├── conftest.py # fixtures available to ALL tests
├── unit/
│ ├── conftest.py # fixtures available to unit/ tests only
│ └── test_account.py
└── integration/
├── conftest.py # fixtures available to integration/ tests only
└── test_payment_api.py
# tests/conftest.py - shared by all tests
import pytest
from myapp import create_app
from myapp.database import db

@pytest.fixture(scope="session")
def app():
"""Create a Flask/Django test application."""
application = create_app({"TESTING": True, "DATABASE_URL": "sqlite:///:memory:"})
yield application

@pytest.fixture(scope="session")
def client(app):
"""Provide a test HTTP client."""
return app.test_client()

@pytest.fixture(autouse=True)
def clean_database(app):
"""Automatically wrap every test in a database transaction and roll back."""
with app.app_context():
db.session.begin_nested()
yield
db.session.rollback()

autouse=True applies a fixture to every test in scope automatically - no need to request it by name. This is how you enforce database cleanup or authentication state across an entire test suite without touching individual test functions.

conftest.py Hooks

conftest.py is also the place to register pytest hooks - functions that run at specific points in the collection and execution lifecycle:

# conftest.py

def pytest_configure(config):
"""Register custom markers to avoid PytestUnknownMarkWarning."""
config.addinivalue_line("markers", "slow: marks tests as slow (deselect with -m 'not slow')")
config.addinivalue_line("markers", "integration: marks integration tests")

def pytest_collection_modifyitems(config, items):
"""Automatically mark tests in the integration/ directory."""
for item in items:
if "integration" in str(item.fspath):
item.add_marker(pytest.mark.integration)
if "slow" in item.keywords:
item.add_marker(pytest.mark.slow)

Part 6 - Parametrize

@pytest.mark.parametrize is the clean way to run one test function with multiple input/output combinations. Each combination becomes a separate test with its own ID in the output.

Single Parameter

import pytest

@pytest.mark.parametrize("value,expected", [
("hello world", "Hello World"),
("ALREADY UPPER", "Already Upper"),
("mixed Case", "Mixed Case"),
("", ""),
("single", "Single"),
])
def test_title_case(value, expected):
assert to_title_case(value) == expected

Output on failure:

FAILED test_strings.py::test_title_case[ALREADY UPPER-Already Upper]
FAILED test_strings.py::test_title_case[-]

Each case has its own ID ([value-expected]) and runs independently. A failure in one case does not stop the others - unlike a loop without self.subTest.

Multiple Parameters

@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, -50, 50),
])
def test_add(a, b, expected):
assert add(a, b) == expected

Custom IDs

@pytest.mark.parametrize("http_method,path,expected_status", [
("GET", "/api/users", 200),
("POST", "/api/users", 201),
("DELETE", "/api/users/1", 204),
("GET", "/api/missing", 404),
], ids=["list_users", "create_user", "delete_user", "not_found"])
def test_api_routes(client, http_method, path, expected_status):
response = client.request(http_method, path)
assert response.status_code == expected_status

Custom IDs produce readable test output:

PASSED test_api.py::test_api_routes[list_users]
PASSED test_api.py::test_api_routes[create_user]

Indirect Parametrize

indirect=True passes parameter values through a fixture instead of directly to the test. The fixture receives the parameter value via request.param:

@pytest.fixture
def browser(request):
"""Parametrically create different browser clients."""
browser_name = request.param # "chrome", "firefox", or "safari"
driver = create_driver(browser_name)
yield driver
driver.quit()

@pytest.mark.parametrize("browser", ["chrome", "firefox", "safari"], indirect=True)
def test_login_page(browser):
browser.get("https://example.com/login")
assert browser.find_element("id", "login-form").is_displayed()

This pattern is common for cross-browser testing, multi-environment testing, and any scenario where you need fixtures to be parametrised.

Part 7 - Built-in Marks

skip and skipif

import pytest
import sys

@pytest.mark.skip(reason="Upstream API is down for maintenance")
def test_payment_processing():
...

@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only: symlink semantics")
def test_symlink_resolution():
...

@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="tomllib added in Python 3.11"
)
def test_toml_config_loading():
import tomllib
...

xfail - Expected Failure

@pytest.mark.xfail(reason="Known bug in upstream library - issue #1234")
def test_unicode_edge_case():
result = upstream_library.process("\u200b") # zero-width space
assert result == ""
  • If the test fails (as expected): reported as xfail - CI passes
  • If the test passes (unexpectedly): reported as xpass - your signal to remove the mark

xfail is the correct way to document known failures without blocking CI. It is better than skip because a xpass tells you the bug was fixed.

@pytest.mark.xfail(strict=True, reason="This MUST fail - if it passes, mark is wrong")
def test_always_expected_to_fail():
...
# strict=True: xpass becomes a test failure - enforces the expectation

Part 8 - pytest.raises, pytest.warns, pytest.approx

pytest.raises

import pytest

def test_zero_division():
with pytest.raises(ZeroDivisionError):
1 / 0

def test_raises_with_message():
with pytest.raises(ValueError, match=r"invalid literal.*'abc'"):
int("abc")
# match is a regex against str(exception) - use it to verify error messages

def test_raises_captures_exception():
with pytest.raises(KeyError) as exc_info:
d = {}
_ = d["missing"]

assert exc_info.value.args[0] == "missing"
assert exc_info.type is KeyError

pytest.warns

import warnings
import pytest

def test_deprecated_function_warns():
with pytest.warns(DeprecationWarning, match="use new_function instead"):
old_function()

pytest.approx - Float Comparison

def test_float_arithmetic():
result = 0.1 + 0.2
assert result == pytest.approx(0.3) # default tolerance: 1e-6 relative
assert result == pytest.approx(0.3, rel=1e-3) # 0.1% relative tolerance
assert result == pytest.approx(0.3, abs=1e-9) # absolute tolerance

def test_list_of_floats():
results = [0.1 + 0.2, 0.3 + 0.0, 1.0 / 3.0]
assert results == pytest.approx([0.3, 0.3, 0.333333], rel=1e-5)

def test_dict_of_floats():
metrics = {"accuracy": 0.9501, "loss": 0.0499}
assert metrics == pytest.approx({"accuracy": 0.95, "loss": 0.05}, rel=0.01)

pytest.approx is the correct tool for any floating-point comparison. Never use == for floats directly.

Part 9 - Built-in Fixtures

tmp_path - Temporary File System

def test_config_file_parsing(tmp_path):
"""tmp_path is a pathlib.Path pointing to a unique temporary directory."""
config_file = tmp_path / "config.json"
config_file.write_text('{"debug": true, "port": 8080}')

config = load_config(config_file)

assert config["debug"] is True
assert config["port"] == 8080
# tmp_path is automatically cleaned up after the test

capsys - Capturing stdout/stderr

def test_print_report(capsys):
generate_report(title="Q4 Results", total=42_000)

captured = capsys.readouterr()
assert "Q4 Results" in captured.out
assert "42,000" in captured.out
assert captured.err == "" # no errors written to stderr

def test_error_goes_to_stderr(capsys):
log_error("connection failed")

captured = capsys.readouterr()
assert "connection failed" in captured.err

:::danger Never Use print() for Test Debugging print() output is captured and hidden by default in pytest. If you are printing values to understand a test failure, you will not see them without running pytest -s (disable capture) or using capsys.readouterr().

# What developers do that produces invisible output:
def test_something():
result = compute()
print(f"DEBUG: result = {result}") # you will NOT see this
assert result == expected

# What to do instead - use the debugger:
def test_something():
result = compute()
import pdb; pdb.set_trace() # drops into interactive debugger
assert result == expected

# Or use pytest's built-in output:
def test_something(capsys):
result = compute()
capsys.readouterr() # capture and inspect programmatically
assert result == expected

If you genuinely need to see print output during a run: pytest -s disables capture globally; pytest -s test_file.py::test_something disables it for one test. :::

monkeypatch - Safe Test-Scoped Patching

monkeypatch is a built-in fixture that patches objects, environment variables, and sys.path for the duration of a single test, then restores the original values automatically:

def test_reads_api_key_from_environment(monkeypatch):
monkeypatch.setenv("STRIPE_API_KEY", "sk_test_abc123")

key = get_api_key() # reads from os.environ

assert key == "sk_test_abc123"
# After the test: STRIPE_API_KEY is restored to its original value (or unset)

def test_without_api_key_raises(monkeypatch):
monkeypatch.delenv("STRIPE_API_KEY", raising=False) # remove if set, ignore if not

with pytest.raises(EnvironmentError):
get_api_key()

def test_uses_current_time(monkeypatch):
from datetime import datetime

fixed_time = datetime(2024, 1, 15, 12, 0, 0)
monkeypatch.setattr("mymodule.datetime", lambda: fixed_time)

result = generate_timestamp_report()
assert "2024-01-15" in result

def test_custom_home_directory(monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.chdir(tmp_path) # change working directory for this test

config_path = get_config_path()
assert config_path.parent == tmp_path

monkeypatch is cleaner than unittest.mock.patch for simple attribute and environment variable changes: no context manager, no decorator, automatic restoration. For complex mocking (tracking calls, setting side_effect, verifying call arguments), unittest.mock.patch or pytest-mock's mocker fixture are more appropriate.

Part 10 - Running pytest

Essential Flags

# Run everything
pytest

# Verbose: show each test name and PASSED/FAILED status
pytest -v

# Show extra info: fixture values, local variables on failure
pytest -v --tb=long

# Short traceback - good for CI logs
pytest --tb=short

# Stop after the first failure
pytest -x

# Stop after N failures
pytest --maxfail=3

# Run only the last failed tests (uses .pytest_cache)
pytest --lf

# Run last failed tests first, then the rest
pytest --ff

Selecting Tests

# By keyword expression (matches test name, class name, file name)
pytest -k "deposit or withdraw" # tests with "deposit" OR "withdraw" in name
pytest -k "not slow" # exclude tests marked slow
pytest -k "TestBankAccount and deposit" # class AND method name

# By marker
pytest -m "integration" # only integration tests
pytest -m "not slow" # skip slow tests
pytest -m "integration and not slow" # composed markers

# By file and test
pytest tests/test_account.py
pytest tests/test_account.py::TestBankAccount
pytest tests/test_account.py::TestBankAccount::test_deposit
pytest tests/test_account.py::test_deposit # function directly in file

Parallel Execution with pytest-xdist

# Install: pip install pytest-xdist

# Run on 4 CPU cores
pytest -n 4

# Run on all available cores
pytest -n auto

# Distribute by file (all tests in a file go to the same worker)
pytest -n auto --dist=loadfile

:::warning Parallel Tests Must Be Fully Isolated pytest-xdist runs tests in separate processes. Tests that share mutable state - a global database, a shared file, a module-level cache - will produce race conditions or unpredictable failures when run in parallel.

The standard solution: each worker gets its own database (using a worker-unique connection string from worker_id in conftest.py) and its own temporary directory (use tmp_path - it is unique per test). :::

Part 11 - Configuration

[tool.pytest.ini_options]
# Root directory for test discovery
testpaths = ["tests"]

# File naming patterns
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]

# Default markers (avoids PytestUnknownMarkWarning)
markers = [
"slow: marks tests as slow-running",
"integration: marks integration tests requiring a live database",
"unit: marks fast unit tests with no external dependencies",
]

# Default flags applied to every run
addopts = [
"--tb=short",
"--strict-markers", # fail on unknown markers
"-v",
]

# Coverage settings (requires pytest-cov)
# addopts = ["--cov=src", "--cov-report=term-missing", "--cov-fail-under=80"]

pytest.ini (Alternative)

[pytest]
testpaths = tests
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*
markers =
slow: marks tests as slow-running
integration: marks integration tests
addopts = --tb=short --strict-markers -v

Part 12 - Essential Plugins

PluginInstallPurpose
pytest-covpip install pytest-covCode coverage - pytest --cov=src --cov-report=html
pytest-xdistpip install pytest-xdistParallel execution - pytest -n auto
pytest-mockpip install pytest-mockmocker fixture - clean API over unittest.mock
pytest-asynciopip install pytest-asyncioasync def test_* functions for asyncio code
Fakerpip install FakerGenerate realistic test data (names, emails, addresses)
factory-boypip install factory-boyObject factories for complex test data
responsespip install responsesMock requests HTTP calls without patching
freezegunpip install freezegunFreeze time in tests - @freeze_time("2024-01-15")
pytest-envpip install pytest-envSet environment variables via pytest.ini

pytest-mock in Practice

# pytest-mock provides the `mocker` fixture - a thin wrapper over unittest.mock
def test_charge_calls_stripe(mocker):
mock_create = mocker.patch("payment.stripe.Charge.create")
mock_create.return_value = {"id": "ch_test", "status": "succeeded"}

result = charge_customer("cus_abc", 2000)

mock_create.assert_called_once_with(
customer="cus_abc",
amount=2000,
currency="usd"
)
# The patch is automatically undone after the test - no context manager needed

pytest-asyncio in Practice

import pytest
import pytest_asyncio

@pytest_asyncio.fixture
async def async_client():
async with AsyncClient(app=app, base_url="http://test") as client:
yield client

@pytest.mark.asyncio
async def test_async_endpoint(async_client):
response = await async_client.get("/api/users")
assert response.status_code == 200
assert isinstance(response.json(), list)

Key Takeaways

  • pytest discovers tests automatically by naming convention - no class inheritance required.
  • Assertion rewriting happens via AST transformation at import time. Your source is never modified; only the in-memory bytecode changes. This is why plain assert produces informative failure output.
  • Fixtures are injected by name using inspect.signature. This is functional dependency injection - the same mechanism powers FastAPI's route parameter injection.
  • Scope (function/class/module/session) controls how often a fixture is called. Expensive setup belongs in session scope; per-test isolation belongs in function scope.
  • conftest.py shares fixtures and hooks across test files without explicit imports.
  • @pytest.mark.parametrize gives each input combination its own test ID and independent pass/fail status.
  • monkeypatch is the clean tool for patching environment variables and simple attributes in a test-scoped way with automatic restoration.
  • capsys captures stdout/stderr for assertion - never rely on seeing print() output in normal test runs.
  • pytest.approx is the correct tool for floating-point comparison - never use == directly on floats.
  • pytest-xdist parallelises test execution across CPU cores. Tests must be fully isolated to run correctly in parallel.

Graded Practice

Level 1 - Predict and Identify

Problem 1: What does this fixture's scope mean, and how many times will it be called if there are 3 test files each containing 5 tests that request it?

@pytest.fixture(scope="module")
def database_schema():
create_schema()
yield
drop_schema()
Show Answer

scope="module" means the fixture is called once per test module file - not once per test.

With 3 test files each containing 5 tests (15 tests total):

  • database_schema is called 3 times - once before the first test in each file
  • drop_schema() (the teardown after yield) is called 3 times - once after the last test in each file

If the scope were function (the default), it would be called 15 times. If the scope were session, it would be called 1 time total.

module scope is the right choice when schema creation is fast enough to do per-file but you want isolation between test files.

Problem 2: Predict what happens when pytest collects this file. How many tests are collected, and what are their IDs?

import pytest

@pytest.mark.parametrize("n,expected", [
(0, 1),
(1, 1),
(5, 120),
(10, 3628800),
])
def test_factorial(n, expected):
assert factorial(n) == expected
Show Answer

4 tests are collected, with IDs:

test_math.py::test_factorial[0-1]
test_math.py::test_factorial[1-1]
test_math.py::test_factorial[5-120]
test_math.py::test_factorial[10-3628800]

Each parametrize combination generates an independent test. The ID format is [param1-param2] using the string representation of each parameter value.

If two parameter combinations produce the same ID (e.g., two rows with n=1), pytest appends a number suffix: [1-1] and [1-10].

Level 2 - Debug and Fix

Problem 3: This fixture is supposed to provide a temporary config file. The test always fails with FileNotFoundError. Why, and how do you fix it?

import pytest

@pytest.fixture
def config_file():
import tempfile, os
fd, path = tempfile.mkstemp(suffix=".json")
os.write(fd, b'{"debug": true}')
return path # returns the path string

def test_config_loading(config_file):
config = load_config(config_file)
assert config["debug"] is True
Show Answer

The bug: os.write(fd, ...) writes to the file descriptor, but the file descriptor is never closed before the test runs load_config. Some operating systems (particularly Windows) prevent reading an open file descriptor from another process or even the same process through a different handle.

But the deeper issue: the fixture does not close the file descriptor before returning the path. Even on POSIX systems where this works, the file descriptor is leaked.

Additionally, the fixture has no teardown - the temp file is never deleted after the test.

Fixed version using tmp_path:

@pytest.fixture
def config_file(tmp_path):
path = tmp_path / "config.json"
path.write_text('{"debug": true}')
return path # file is written and closed; tmp_path cleanup is automatic

def test_config_loading(config_file):
config = load_config(config_file)
assert config["debug"] is True

Fixed version using yield with explicit cleanup:

@pytest.fixture
def config_file():
import tempfile, os
fd, path = tempfile.mkstemp(suffix=".json")
os.write(fd, b'{"debug": true}')
os.close(fd) # close the file descriptor before yielding
yield path
os.unlink(path) # clean up after the test

def test_config_loading(config_file):
config = load_config(config_file)
assert config["debug"] is True

The tmp_path approach is preferred - it uses pytest's built-in management and removes the need for manual cleanup entirely.

Problem 4: This test is intended to verify that get_user() makes exactly one HTTP request. But the real requests.get is being called and the test errors with ConnectionError. Identify what is wrong and fix it.

# user_service.py
from requests import get

def get_user(user_id: int) -> dict:
response = get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()

# test_user_service.py
import pytest
from unittest.mock import patch, MagicMock

def test_get_user_makes_one_request():
with patch("requests.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {"id": 1, "name": "Alice"}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response

from user_service import get_user
result = get_user(1)

assert result == {"id": 1, "name": "Alice"}
mock_get.assert_called_once()
Show Answer

The bug: user_service.py uses from requests import get - it imports get as a direct name in the user_service module's namespace.

Patching requests.get changes the get attribute on the requests module object. But user_service.py already holds a reference to the original get function - the name get in user_service points to the real function, not to requests.get. Patching requests.get after the import has no effect on user_service's get name.

The fix: patch user_service.get - the name as it exists in user_service's namespace:

def test_get_user_makes_one_request():
with patch("user_service.get") as mock_get: # patch where the name is used
mock_response = MagicMock()
mock_response.json.return_value = {"id": 1, "name": "Alice"}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response

from user_service import get_user
result = get_user(1)

assert result == {"id": 1, "name": "Alice"}
mock_get.assert_called_once_with("https://api.example.com/users/1")

The rule: patch the name in the namespace where it is used, not in the namespace where it is defined.

  • from requests import get → patch user_service.get
  • import requests; requests.get(...) → patch user_service.requests.get
  • import requests; requests.get(...) → also valid to patch requests.get in this case

Level 3 - Design

Problem 5: You are building an integration test suite for a REST API backed by PostgreSQL. The suite has 200 tests. Design a conftest.py fixture architecture that:

  • Starts a PostgreSQL test server once for the entire run (expensive - takes 5 seconds)
  • Creates the database schema once per test module (moderate cost - takes 0.5 seconds)
  • Wraps every test in a transaction that is rolled back after the test (free - ensures isolation)
  • Provides an authenticated HTTP test client for tests that need it
  • Provides an unauthenticated client for tests that verify auth requirements

Specify the scope of each fixture and explain the teardown strategy.

Show Answer
# tests/conftest.py

import pytest
from myapp import create_app
from myapp.database import db, engine

# ─── SESSION SCOPE: Start once, shared by all 200 tests ───────────────────────

@pytest.fixture(scope="session")
def postgres_server():
"""Start the PostgreSQL test server. 5-second startup, runs once."""
server = TestPostgresServer(port=15432)
server.start()
yield server
server.stop() # teardown: stop the server after all tests complete

@pytest.fixture(scope="session")
def app(postgres_server):
"""Create the Flask/FastAPI app pointing at the test database."""
application = create_app({
"TESTING": True,
"DATABASE_URL": postgres_server.url,
})
yield application

# ─── MODULE SCOPE: Schema creation, once per test file ────────────────────────

@pytest.fixture(scope="module")
def db_schema(app):
"""Create all tables once per module. Dropped and recreated per file."""
with app.app_context():
db.create_all()
yield
db.drop_all() # teardown: clean slate for the next module

# ─── FUNCTION SCOPE: Transaction isolation, once per test ─────────────────────

@pytest.fixture(autouse=True)
def db_transaction(app, db_schema):
"""
Wrap every test in a savepoint transaction.
Automatically applied to all tests (autouse=True).
"""
with app.app_context():
connection = engine.connect()
transaction = connection.begin()
# Override the session to use this connection
db.session.bind = connection
yield
transaction.rollback() # roll back every test - no persistent state
connection.close()

# ─── CLIENT FIXTURES: Authenticated and unauthenticated ───────────────────────

@pytest.fixture
def client(app):
"""Unauthenticated HTTP client. Use for testing auth-required endpoints."""
return app.test_client()

@pytest.fixture
def auth_client(app):
"""
Authenticated HTTP client with a valid session token.
Creates a test user within the current transaction (rolled back after test).
"""
test_user = User(username="test_user", email="[email protected]")
test_user.set_password("test_pass")
db.session.add(test_user)
db.session.flush() # assign ID without committing

c = app.test_client()
with c.session_transaction() as session:
session["user_id"] = test_user.id

return c

Scope reasoning:

  • postgres_server - session: 5-second startup. 200 tests × 5 seconds = 1000 seconds without this. One startup total.
  • app - session: app configuration does not change between tests; safe to share.
  • db_schema - module: 0.5-second cost per file. 200 tests across 20 files = 10 seconds total vs 100 seconds at function scope.
  • db_transaction - function with autouse=True: each test gets a clean database state via rollback. No test can affect another.
  • client/auth_client - function: fresh client state per test; auth state is local to the transaction.

Teardown strategy: Every resource is cleaned up in the same fixture that created it, using yield. The transaction rollback in db_transaction makes the module-level db.drop_all() mostly a formality - no data survives between tests regardless. The postgres_server.stop() at session teardown is the final cleanup.

© 2026 EngineersOfAI. All rights reserved.