Python Testing Patterns
When to Use
Use this skill when:
- User asks how to write tests in Python using pytest, including test discovery, fixture wiring, or assertion patterns
- User wants to design pytest fixtures for complex scenarios involving shared state, teardown, parameterization, or scoping
- User asks about
@pytest.mark.parametrizefor table-driven testing, including matrix combinations and conditional skipping - User needs to mock dependencies using
unittest.mock, includingpatch,MagicMock,AsyncMock,patch.dict, orpatch.object - User wants to test async Python code with
pytest-asyncio, including async fixtures, async generators, and timeout behavior - User asks about organizing test suites with
conftest.pyhierarchy, test markers, or coverage configuration - User wants to apply the testing pyramid to a Python project: balancing unit, integration, and contract tests by speed and scope
- User asks about test doubles -- stubs, spies, fakes, and mocks -- and when to use each in a Python context
- User needs guidance on testing patterns specific to Python frameworks such as FastAPI, SQLAlchemy, Celery, or Pydantic
Do NOT use this skill when:
- User wants to set up a new Python project from scratch -- use
python-project-setupinstead - User is asking about general testing philosophy or the testing pyramid as a concept -- use
unit-testing-patternsinstead - User wants structured TDD workflow coaching (red-green-refactor cycles) -- use
tdd-workflowinstead - User is asking about browser-based or end-to-end testing with Playwright or Selenium -- use
e2e-testing-patternsinstead - User wants performance benchmarking or profiling of Python code -- use
python-performanceinstead - User is making high-level decisions about what to test and at what layer -- use
test-strategy-designinstead - User is asking about CI/CD pipeline configuration for running tests -- use
ci-pipeline-designinstead
Process
1. Assess the Testing Context and Test Type
Before writing any code, classify what is being tested and choose the right test category. This determines every decision downstream.
- Pure functions with no I/O or side effects: Write direct unit tests with
assert. No mocking needed. These are the fastest, most reliable tests in the suite. Push as many behaviors as possible into pure functions specifically to make them this easy to test. - Classes with injected dependencies: Write unit tests using
MagicMockorAsyncMockfor collaborators. Verify behavior via return values and call assertions. Prefer constructor injection -- it makes the dependency boundary explicit and avoidspatchentirely. - Database interaction layers (repositories, DAOs): Write integration tests that talk to a real database (PostgreSQL in a Docker container or SQLite in-memory). Use transaction rollback per test to maintain isolation without schema recreation. These belong in a separate pytest mark (
@pytest.mark.integration) and run in CI, not in pre-commit hooks. - HTTP endpoints (FastAPI, Flask): Use framework-provided test clients (
httpx.AsyncClientfor FastAPI,flask.testing.FlaskClientfor Flask). These are integration tests that exercise routing, serialization, and middleware, but mock outbound external calls. - Async code: Check the event loop policy early. Confirm
pytest-asynciois installed andasyncio_mode = "auto"is set. All async test functions and async fixtures work without@pytest.mark.asynciodecoration in auto mode. - Determine test speed targets: Unit tests should run in under 1ms each. A suite of 1000 unit tests should finish in under 2 seconds. Any test taking longer than 100ms is a candidate for mock replacement or integration test reclassification.
2. Design the Fixture Hierarchy
Fixtures are the backbone of a pytest suite. Bad fixture design causes slow, brittle, or non-isolated tests. Apply this decision framework precisely:
- Use
scope="function"(default) for: any fixture that mutates state, any fixture wrapping a mock, any database transaction that rolls back, or anything that has side effects. This is the safe default -- prefer it unless you have a measured performance reason not to. - Use
scope="module"for: expensive read-only setup that is identical for every test in the file, such as parsing a large static test data file or compiling a regex that is expensive to build. - Use
scope="session"for: database engine creation, HTTP test client initialization, external service connections, and other one-time-per-run operations. Never store mutable test state in session-scoped fixtures. - Use
yieldfixtures for all teardown: put setup before theyieldand teardown after it. This ensures cleanup runs even when a test fails. Never usepytest.fixturewith areturnif the fixture allocates resources that need cleanup. - Use
autouse=Truesparingly: Only for behavior that genuinely applies to every test in scope, such as resetting a global configuration registry or clearing a singleton cache. Always document whyautouseis justified. - Use
paramson fixtures to fan out test variants:@pytest.fixture(params=["sqlite", "postgres"], ids=["sqlite", "postgres"])creates one test per parameter value for every test that uses that fixture. This is different fromparametrizeand is appropriate when the fixture itself is the variant (e.g., testing against multiple database backends). - Stack conftest.py files hierarchically:
tests/conftest.pyprovides project-wide fixtures.tests/unit/conftest.pyprovides unit-test-specific fixtures.tests/integration/conftest.pyprovides integration-test-specific fixtures. Never import fixtures across test files manually -- let pytest discover them. - Avoid fixture overuse: A fixture that sets up 15 attributes and is used by 3 tests is a smell. Extract targeted fixtures that set up only what each test needs. Over-specified fixtures make tests fragile to unrelated changes.
3. Choose the Right Test Double Strategy
Python's unittest.mock offers multiple double types. Choosing the wrong one causes brittle tests or misses real bugs.
MagicMock: Use for synchronous dependencies where you want automatic attribute access and call tracking. By default, every attribute access returns anotherMagicMock, which can mask typos -- always usespec=SomeClassto constrain the mock to the real interface:MagicMock(spec=UserRepository).AsyncMock: Use for any dependency whose methods areasync def. Python 3.8+ provides this inunittest.mock. If you patch an async method with a regularMagicMock, the test will not await it correctly and will return a coroutine object instead of a value.patchas a decorator vs. context manager: Use@patch("myapp.services.user_service.EmailSender")when the mock scope is the entire test function. Usewith patch(...) as mock:when the mock applies to only part of the test. Always patch at the point of use (where the name is imported into the module under test), not at the point of definition.patch.object: Use when you need to patch a method on an already-instantiated object or a class attribute without replacing the entire class. Prefer this overpatchwhen the dependency is accessed viaself.something.patch.dict: Use for patchingos.environ,sys.modules, or any dictionary-like configuration store. Alwaysclear=False(the default) to avoid wiping unrelated keys that other code depends on.- Manual fakes: When a third-party interface is complex (e.g., S3 client with 40 methods), write a minimal in-memory fake class that implements only the methods your code uses. This is more maintainable than a
MagicMockwith 40return_valueassignments and is easier to reuse across tests. - Spy pattern with
wraps: UseMagicMock(wraps=real_function)to call through to the real implementation while still tracking calls. Useful for verifying that a real function is called with specific arguments without replacing its behavior. - Dependency injection over patching: If you find yourself patching the same thing in 10 tests, refactor the source code to accept the dependency as a constructor argument. The test suite cost of excessive patching reveals a design problem.
4. Write Parametrized Tests with Intention
@pytest.mark.parametrize is the most underused and most misused pytest feature. Apply it with discipline:
- Use
pytest.param(..., id="name")for every parameter: Test IDs liketest_validate_email[email0]are useless in CI logs. Always name your cases:pytest.param("", id="empty-string"). This makes failure messages immediately diagnostic. - Separate happy-path, edge-case, and error-case parameter lists: Don't mix valid and invalid inputs into one parametrize block. The behavior being tested is different, and the assertion is different. Use three parametrize blocks with three test functions.
- Use indirect parametrize for fixture-based variants:
@pytest.mark.parametrize("user_fixture", ["admin_user", "guest_user"], indirect=True)passes the parameter to a fixture nameduser_fixturerather than directly to the test. This allows fixture setup logic to depend on the parameter. - Stack decorators for a cartesian product: Two
@pytest.mark.parametrizedecorators create a cartesian product of cases. Use this only when you genuinely need all combinations. If you have 3 email types and 4 locale settings and need all 12 combinations tested, stacking is correct. If you only need 4 specific combinations, write them explicitly to avoid testing meaningless combinations. - Use
pytest.mark.skipandpytest.mark.xfailwithin parametrize:pytest.param("edge-input", id="known-bug", marks=pytest.mark.xfail(reason="GH-1234"))marks a single parametrize case as expected-to-fail without skipping the rest of the cases. - Limit parametrize breadth to 10-15 cases per block: Beyond 15 cases, consider whether you are testing the same function boundary or whether you should add a fuzz testing tool like
hypothesisfor property-based testing of the same domain.
5. Configure and Write Async Tests Correctly
Async testing has specific pitfalls that cause tests to silently pass when they should fail, or to deadlock in CI.
- Install and configure
pytest-asyncio: Addasyncio_mode = "auto"under[tool.pytest.ini_options]inpyproject.toml. This mode detectsasync def test_*functions automatically and runs them under an event loop. Without this, callingasync def test_something()without@pytest.mark.asynciosilently skips the coroutine body. - Event loop scope for fixtures: As of
pytest-asyncio0.21+, the event loop is scoped at thefunctionlevel by default. If a session-scoped async fixture (e.g., a database connection pool) needs to share an event loop with tests, setasyncio_mode = "auto"and use@pytest_asyncio.fixture(scope="session")-- not@pytest.fixture. Mixing scopes incorrectly causesEvent loop is closederrors in the second test. - Mock async context managers: Code like
async with session.begin(): ...requires a mock that supports__aenter__and__aexit__. UseAsyncMock-- it automatically supports async context manager protocol. ForAsyncMockinstances,mock.__aenter__.return_valuesets the value yielded by theasync withblock. - Test timeout behavior: Use
asyncio.wait_for(coroutine, timeout=5.0)inside the test to assert that an operation completes within a time budget. Never usetime.sleep()in async tests -- it blocks the event loop and makes tests fragile. For code that usesasyncio.sleep()internally, patch it:patch("asyncio.sleep", new_callable=AsyncMock). - Testing async generators: Use
async for item in async_generator_function():directly in the test body and collect results into a list for assertion. Do not try to uselist()on an async generator -- it does not work synchronously. - Mixing sync and async in one suite: pytest runs sync and async tests in the same suite without issue under
asyncio_mode = "auto". The event loop is set up only for async tests. Do not convert sync tests to async unless the code under test is actually async.
6. Organize Test Files and Apply Markers
Test organization determines how useful the suite is at scale. A 500-test suite with no organization is nearly as bad as no tests.
- Mirror the source layout exactly: If your package is
src/myapp/services/user_service.py, the test file istests/unit/services/test_user_service.py. This makes it trivial to find tests for any module and is required by some coverage tools for accurate missing-line reporting. - Apply custom markers strategically: Define markers in
pyproject.tomlunder[tool.pytest.ini_options] markers = [...]. Standard markers to define:integration(requires external services),slow(runs longer than 1 second),smoke(critical path, run first),contract(pact or schema validation tests). Run subsets withpytest -m "not integration"in pre-commit andpytest -m "integration"in CI. - Always run with
--strict-markers: This flag causes pytest to fail with an error if a test uses a marker that is not registered. Add it permanently:addopts = "--strict-markers"inpyproject.toml. - Use classes for grouping, not for sharing state: A
class TestUserCreation:groups related tests under a descriptive namespace. Never use class-level attributes as shared mutable state between tests. If tests in a class share a fixture, pass it through method parameters -- do not assign it toselfin asetup_method. - Use
conftest.pyscoping to limit fixture visibility: A fixture defined intests/unit/conftest.pyis invisible totests/integration/. This prevents integration fixtures (like database connections) from accidentally being used in unit tests, which would make them slow and require external services. - Name test functions as declarative sentences:
test_create_user_raises_duplicate_email_error_when_email_already_registeredis better thantest_duplicate_email. The verbose name is the documentation. When this test fails in CI at 2am, the name tells you exactly what broke without opening the file.
7. Configure Coverage and Quality Gates
Coverage without a quality gate is a vanity metric. Coverage with the wrong threshold is a false sense of security.
- Target 80-90% line coverage for business logic modules: 100% line coverage is often counterproductive -- it forces testing of trivial getters and
if TYPE_CHECKING:blocks. Exclude those patterns via[tool.coverage.report] omitandexclude_linesinpyproject.toml. - Use branch coverage, not just line coverage:
coverage run --branchdetects untestedif/elsebranches that line coverage misses. Addbranch = trueunder[tool.coverage.run]. - Configure
fail_under: Setfail_under = 85under[tool.coverage.report]. This makespytest --covexit with code 2 if coverage drops below 85%, blocking CI merges. - Always exclude from coverage:
if __name__ == "__main__":blocks,@overloaddecorated functions, abstract methods, andTYPE_CHECKINGimports. Add these toexclude_linesas regex patterns. - Use
pytest-covintegration, not separatecoverage run:pytest --cov=myapp --cov-report=term-missing --cov-report=xmlruns coverage alongside tests and generates both terminal and XML reports in one step. The XML report feeds into SonarQube, Codecov, or similar tools. - Review the coverage report for missing branches, not just lines: A line covered by a parametrize case that only tests the happy path will show as covered even though the error branch is never executed. Always read the branch miss report.
8. Enforce Test Quality with Static Analysis and Linting
Tests are production code. They deserve the same quality bar.
- Run
mypyover test files: If source code is fully typed, test files should be too.MagicMockreturn values default toAny, which can hide type errors. Usespec=parameters to get typed mocks, and cast where necessary. - Use
ruffwith pytest-specific rules enabled: Enable thePTrule set inruff(the flake8-pytest-style rules). These catch common mistakes: usingassertin fixtures, usingpytest.raisesoutside a context manager, missingpytest.markregistration, and others. - Lint for
assertin fixture bodies: Fixtures thatassertinstead ofraisehide setup failures as test failures. ThePTruff rules catch this. - Use
pytest --tb=shortas the default traceback style: Addaddopts = "--tb=short"topyproject.toml. Full tracebacks in CI logs make failures harder to scan. Short tracebacks show the failing assertion line and the immediate call context -- enough to diagnose most failures.
Output Format
When helping a user design or review a test suite, produce output in this structure:
Test File Structure
tests/
├── conftest.py # project-wide fixtures
├── unit/
│ ├── conftest.py # unit-test-specific fixtures
│ ├── services/
│ │ └── test_{module_name}.py
│ └── models/
│ └── test_{model_name}.py
├── integration/
│ ├── conftest.py # DB connections, test clients
│ └── test_{integration_scenario}.py
└── contract/
└── test_{service_name}_contract.py
pyproject.toml Configuration Block
[tool.pytest.ini_options]
asyncio_mode = "auto"
addopts = "--strict-markers --tb=short"
testpaths = ["tests"]
markers = [
"integration: marks tests that require external services (deselect with '-m not integration')",
"slow: marks tests that take longer than 1 second",
"smoke: marks critical-path tests that should run first",
"contract: marks schema or pact contract validation tests",
]
[tool.coverage.run]
source = ["src"]
branch = true
omit = ["*/__init__.py", "*/conftest.py"]
[tool.coverage.report]
fail_under = 85
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
"@overload",
"raise NotImplementedError",
]
Conftest.py Template
# tests/conftest.py
"""Project-wide test fixtures."""
import pytest
from unittest.mock import AsyncMock, MagicMock
# -- Dependency mocks (function-scoped -- fresh per test)
@pytest.fixture
def {dependency_name}_mock() -> MagicMock:
"""Isolated mock for {DependencyClass} with spec enforcement."""
mock = MagicMock(spec={DependencyClass})
mock.{primary_method}.return_value = {sensible_default}
return mock
@pytest.fixture
def {async_dependency}_mock() -> AsyncMock:
"""Isolated async mock for {AsyncDependencyClass}."""
mock = AsyncMock(spec={AsyncDependencyClass})
mock.{async_method}.return_value = {sensible_default}
return mock
# -- Subject under test
@pytest.fixture
def {subject}(
{dependency_name}_mock: MagicMock,
{async_dependency}_mock: AsyncMock,
) -> {SubjectClass}:
"""Fully wired {SubjectClass} with all dependencies mocked."""
return {SubjectClass}(
{dependency_arg}={dependency_name}_mock,
{async_dependency_arg}={async_dependency}_mock,
)
Test File Template
# tests/unit/services/test_{module}.py
"""Tests for {SubjectClass}: {one-sentence description of what is verified}."""
import pytest
from unittest.mock import MagicMock, call
from myapp.services.{module} import {SubjectClass}, {RelevantError}
class Test{PrimaryBehavior}:
"""Tests for the {specific behavior group} workflow."""
def test_{behavior}_succeeds_when_{precondition}(
self,
{subject}: {SubjectClass},
{dependency}_mock: MagicMock,
) -> None:
"""Verify {what the test proves in plain language}."""
# Arrange
{dependency}_mock.{method}.return_value = {specific_value}
# Act
result = {subject}.{method_under_test}({input})
# Assert
assert result.{field} == {expected_value}
{dependency}_mock.{method}.assert_called_once_with({expected_args})
def test_{behavior}_raises_{error}_when_{violation}(
self,
{subject}: {SubjectClass},
{dependency}_mock: MagicMock,
) -> None:
"""Verify {ErrorClass} is raised when {condition}."""
{dependency}_mock.{method}.return_value = {value_triggering_error}
with pytest.raises({RelevantError}, match="{expected_message_substring}"):
{subject}.{method_under_test}({triggering_input})
@pytest.mark.parametrize(
"{input_param},{expected_param}",
[
pytest.param({input_1}, {expected_1}, id="{case_name_1}"),
pytest.param({input_2}, {expected_2}, id="{case_name_2}"),
pytest.param({input_3}, {expected_3}, id="{case_name_3}"),
pytest.param({input_4}, {expected_4}, id="{case_name_4}"),
],
)
def test_{behavior}_handles_various_{input_type}(
self,
{subject}: {SubjectClass},
{input_param}: {InputType},
{expected_param}: {ExpectedType},
) -> None:
"""Verify {behavior} is correct across all valid {input_type} variants."""
result = {subject}.{method_under_test}({input_param})
assert result == {expected_param}
class Test{SecondaryBehavior}:
"""Tests for {second behavior group}."""
async def test_{async_behavior}_awaits_{dependency}(
self,
{subject}: {SubjectClass},
{async_dependency}_mock: AsyncMock,
) -> None:
"""Verify {async_behavior} correctly awaits {async_dependency}."""
await {subject}.{async_method}({input})
{async_dependency}_mock.{async_method}.assert_awaited_once_with(
{expected_kwarg}={expected_value}
)
Rules
Always use
spec=when creatingMagicMockinstances.MagicMock(spec=UserRepository)raisesAttributeErrorif test code accesses an attribute that does not exist on the real class. Withoutspec=, typos in mock attribute access silently return newMagicMockobjects, letting tests pass while the production code has a bug.Never patch at the definition site -- always patch at the import site. If
user_service.pycontainsfrom myapp.email import EmailSender, patchmyapp.user_service.EmailSender, notmyapp.email.EmailSender. Patching the wrong location leaves the production reference unchanged and makes the patch invisible to the code under test.Never use
unittest.TestCasein new pytest tests.TestCasedisables pytest features including fixtures,pytest.raises, and parametrize. It forcessetUp/tearDownover composable fixtures. Always write plain functions or plain classes (no inheritance) for new test code. ExistingTestCaseclasses can coexist -- do not rewrite them, just stop adding to that pattern.Always use
pytest.raisesas a context manager with amatch=argument.with pytest.raises(ValueError, match="invalid email"):verifies both the exception type and that the message contains the expected substring (matched as a regex). Withoutmatch=, aValueErrorraised for a completely different reason will make the test pass.Never allow test functions to exceed 30 lines. A test longer than 30 lines is either testing multiple behaviors (split it) or has setup that belongs in a fixture (extract it). Long tests are a reliable smell for poor fixture design and poor separation of concerns.
Always use
assert_called_once_withoverassert_called_with.assert_called_withonly checks the most recent call, not the number of calls. A function called twice with different arguments passesassert_called_withon the second call. Useassert_called_once_withto verify both that the call happened and that it happened exactly once.Never share mutable default state between parametrize cases.
pytest.param({"key": "value"})passes the same dictionary object to each test iteration if the list is defined once at module level. This causes test pollution when one test mutates the input. Always use a factory fixture or define immutable inputs (tuples, strings, ints) in parametrize blocks. For mutable inputs, usecopy.deepcopyin the test body or a fixture.Never use
time.sleep(),datetime.now()without mocking, orrandomwithout seeding in tests. These make tests non-deterministic. Mockdatetime.nowusingfreezegunorunittest.mock.patch("module.datetime"). Mockrandomby seeding:random.seed(42)in a fixture or by injecting aRandominstance. Replacetime.sleepcalls in async code with a patchedasyncio.sleep.Always register custom markers in
pyproject.tomland run with--strict-markers. Unregistered markers silently do nothing --@pytest.mark.integartion(typo) will not deselect the test when you runpytest -m "not integration". This causes integration tests to run in environments without database access, producing misleading failures.Never assert on mock call counts using
mock.call_count == Ndirectly. Useassert_called_once(),assert_called_once_with(),assert_any_call(), orassert_has_calls([call(...), call(...)])for readable failure messages. A baremock.call_count == 2assertion producesassert 1 == 2in CI with no context about which mock or what the calls were.Always isolate filesystem access using
tmp_path. pytest provides thetmp_pathfixture (typepathlib.Path) that gives each test a unique temporary directory cleaned up after the test. Never write test files to the current working directory or/tmpdirectly, as this causes test pollution and CI failures on parallel execution.Always scope mock patches to the narrowest possible context. A
@patchdecorator applied to a test function patches for the entire function body. If the mock is only needed for one assertion, use thewith patch(...) as mock:context manager scoped to that block. Overly broad patches hide real calls that happen outside the intended scope.
Edge Cases
Legacy unittest.TestCase Coexistence
When a codebase has hundreds of existing TestCase classes, do not rewrite them. pytest discovers and runs TestCase subclasses natively. Write all new tests as plain pytest functions in separate files. Gradually migrate TestCase methods only when you are already modifying that test class for another reason. To use pytest fixtures inside TestCase methods, use pytest.mark and self. attribute assignment in setUp -- but be aware this is a transitional pattern, not a final destination. The goal is to stop the proliferation of TestCase usage and let the pattern die naturally.
Async Event Loop Conflicts with Session-Scoped Fixtures
When a session-scoped async fixture (e.g., a database connection pool) is combined with function-scoped async tests, pytest-asyncio must share the event loop across the session. As of pytest-asyncio 0.21, the correct approach is to define a session-scoped event loop fixture explicitly:
import asyncio
import pytest
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
Without this, a session-scoped async fixture creates its event loop, completes setup, and the loop is closed before any test runs -- causing RuntimeError: Event loop is closed on the first test. This fixture override tells pytest-asyncio to use one loop for the session. Note: this approach is deprecated in pytest-asyncio 0.23+ in favor of loop_scope fixture parameters -- check the installed version before applying.
Patching Properties and Cached Properties
@property and @functools.cached_property cannot be patched using patch directly because MagicMock does not implement the descriptor protocol. Use patch.object with new_callable=PropertyMock:
from unittest.mock import patch, PropertyMock
with patch.object(MyClass, "my_property", new_callable=PropertyMock) as mock_prop:
mock_prop.return_value = 42
result = obj.my_property
assert result == 42
For cached_property, additionally clear the instance cache after patching: del obj.__dict__["my_property"] resets the cached value so the mock takes effect on the next access.
Testing Code That Uses os.environ or Config at Import Time
Code that reads os.environ at import time (at module scope) cannot be patched after import -- the value is already captured. This is a design problem in production code, but the test-side workaround is to force module reimport under the patched environment:
import importlib
from unittest.mock import patch
def test_config_with_custom_env():
with patch.dict("os.environ", {"DATABASE_URL": "sqlite:///:memory:"}):
import myapp.config
importlib.reload(myapp.config)
assert myapp.config.DATABASE_URL == "sqlite:///:memory:"
The correct long-term fix is to move environment reads into a function or class that is called at runtime, not at import time. Document this in the test with a comment pointing to the design smell.
Flaky Tests from Non-Deterministic Ordering
pytest does not guarantee test execution order within a file unless you use pytest-ordering. If tests appear to be order-dependent, it indicates shared mutable state. Common causes:
- A class-level list or dictionary being mutated by one test and read by another
- A module-level singleton being modified
- A database not being rolled back between tests
Diagnose with pytest --randomly-seed=12345 (requires pytest-randomly) to run in a consistent but non-alphabetical order. Use pytest -p no:randomly to run in collection order for comparison. Never add retry logic (via pytest-retry or pytest-rerunfailures) to hide a flaky test -- fix the isolation. The only legitimate use of pytest-rerunfailures is for tests that exercise genuinely non-deterministic external services (network calls in integration tests), and those should be marked @pytest.mark.flaky(reruns=3) with a comment explaining why.
Testing Pydantic Models and Validators
Pydantic v2 models validate on construction, not on assignment by default. When testing Pydantic models, test the model's behavior -- validation, computed fields, and serialization -- not its internal structure:
import pytest
from pydantic import ValidationError
from myapp.schemas import CreateUserRequest
def test_rejects_invalid_email():
with pytest.raises(ValidationError) as exc_info:
CreateUserRequest(email="not-an-email", name="Alice")
errors = exc_info.value.errors()
assert len(errors) == 1
assert errors[0]["loc"] == ("email",)
assert errors[0]["type"] == "value_error"
def test_normalizes_email_to_lowercase():
request = CreateUserRequest(email="Alice@COMPANY.COM", name="Alice")
assert request.email == "alice@company.com"
Never access model.__fields__ or model.model_fields in tests -- those are implementation details. Test what the model does, not how it is defined.
Parallel Test Execution with pytest-xdist
Running tests with pytest -n auto (requires pytest-xdist) distributes tests across CPU cores. This breaks any test that uses a shared resource without locking:
- Database: Each worker needs its own database schema or isolated schema prefix. Use
worker_idfrom thexdistfixture to generate unique schema names:f"test_{worker_id}". - Port binding: Integration tests that start a server on a fixed port will conflict. Use
port=0(OS-assigned) and read the assigned port after binding. - File system: Tests writing to overlapping file paths will race. Use
tmp_path-- each test gets a unique path even underxdist. - Session-scoped fixtures:
scope="session"fixtures run once per worker, not once per session when usingxdist. Usescope="session"withpytest-xdist'sxdist_groupmarker to pin related tests to the same worker if they must share session state.
Example
Input: "I have a PaymentProcessor class that depends on a PaymentGateway (async external API client) and a TransactionRepository (database layer). It has a process_payment method that validates the amount, calls the gateway, and saves the transaction. How do I write comprehensive tests for this?"
Assumed source code structure:
# src/myapp/services/payment_processor.py
from decimal import Decimal
from myapp.gateways.payment_gateway import PaymentGateway, GatewayError
from myapp.repositories.transaction_repo import TransactionRepository
from myapp.models import Transaction, TransactionStatus
class PaymentAmountError(ValueError):
pass
class PaymentProcessor:
def __init__(
self,
gateway: PaymentGateway,
repo: TransactionRepository,
) -> None:
self._gateway = gateway
self._repo = repo
async def process_payment(
self, user_id: str, amount: Decimal, currency: str
) -> Transaction:
if amount <= Decimal("0"):
raise PaymentAmountError(f"Amount must be positive, got {amount}")
if amount > Decimal("10000"):
raise PaymentAmountError(f"Amount exceeds maximum of 10000, got {amount}")
gateway_ref = await self._gateway.charge(
user_id=user_id, amount=amount, currency=currency
)
transaction = await self._repo.create(
user_id=user_id,
amount=amount,
currency=currency,
gateway_ref=gateway_ref,
status=TransactionStatus.COMPLETED,
)
return transaction
Output:
# pyproject.toml (relevant sections)
[tool.pytest.ini_options]
asyncio_mode = "auto"
addopts = "--strict-markers --tb=short --cov=src --cov-report=term-missing"
testpaths = ["tests"]
markers = [
"integration: requires external services or database",
"slow: runs longer than 1 second",
]
[tool.coverage.run]
branch = true
source = ["src"]
[tool.coverage.report]
fail_under = 85
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
# tests/unit/services/conftest.py
"""Fixtures for PaymentProcessor unit tests."""
from decimal import Decimal
from unittest.mock import AsyncMock, MagicMock
import pytest
from myapp.gateways.payment_gateway import PaymentGateway
from myapp.models import Transaction, TransactionStatus
from myapp.repositories.transaction_repo import TransactionRepository
from myapp.services.payment_processor import PaymentProcessor
@pytest.fixture
def gateway_mock() -> AsyncMock:
"""Async mock for PaymentGateway with spec enforcement.
Default: charge succeeds and returns a gateway reference string.
Override return_value or side_effect per-test for error scenarios.
"""
mock = AsyncMock(spec=PaymentGateway)
mock.charge.return_value = "gw_ref_abc123"
return mock
@pytest.fixture
def repo_mock() -> AsyncMock:
"""Async mock for TransactionRepository with spec enforcement.
Default: create returns a completed Transaction with predictable IDs.
"""
mock = AsyncMock(spec=TransactionRepository)
mock.create.return_value = Transaction(
id="txn_001",
user_id="usr_42",
amount=Decimal("99.99"),
currency="USD",
gateway_ref="gw_ref_abc123",
status=TransactionStatus.COMPLETED,
)
return mock
@pytest.fixture
def processor(gateway_mock: AsyncMock, repo_mock: AsyncMock) -> PaymentProcessor:
"""PaymentProcessor wired with all dependencies mocked."""
return PaymentProcessor(gateway=gateway_mock, repo=repo_mock)
# tests/unit/services/test_payment_processor.py
"""Tests for PaymentProcessor.process_payment behavior.
Coverage targets:
- Happy path: valid amount triggers gateway charge and repo create
- Amount validation: zero, negative, above maximum, boundary values
- Gateway errors: GatewayError propagation behavior
- Call ordering: gateway charged before repo saved (no orphaned saves)
"""
from decimal import Decimal
from unittest.mock import AsyncMock, call
import pytest
from myapp.gateways.payment_gateway import GatewayError
from myapp.models import TransactionStatus
from myapp.services.payment_processor import PaymentAmountError, PaymentProcessor
class TestProcessPaymentHappyPath:
"""Tests for the successful payment processing workflow."""
async def test_returns_completed_transaction_for_valid_payment(
self,
processor: PaymentProcessor,
repo_mock: AsyncMock,
) -> None:
"""Verify process_payment returns the repository-created transaction."""
result = await processor.process_payment(
user_id="usr_42",
amount=Decimal("99.99"),
currency="USD",
)
assert result.id == "txn_001"
assert result.status == TransactionStatus.COMPLETED
assert result.gateway_ref == "gw_ref_abc123"
async def test_charges_gateway_with_correct_arguments(
self,
processor: PaymentProcessor,
gateway_mock: AsyncMock,
) -> None:
"""Verify gateway.charge is called with exact user_id, amount, currency."""
await processor.process_payment(
user_id="usr_42",
amount=Decimal("250.00"),
currency="EUR",
)
gateway_mock.charge.assert_awaited_once_with(
user_id="usr_42",
amount=Decimal("250.00"),
currency="EUR",
)
async def test_saves_transaction_with_gateway_reference(
self,
processor: PaymentProcessor,
gateway_mock: AsyncMock,
repo_mock: AsyncMock,
) -> None:
"""Verify repo.create receives the gateway_ref returned by gateway.charge."""
gateway_mock.charge.return_value = "gw_ref_xyz999"
await processor.process_payment(
user_id="usr_42",
amount=Decimal("50.00"),
currency="GBP",
)
repo_mock.create.assert_awaited_once_with(
user_id="usr_42",
amount=Decimal("50.00"),
currency="GBP",
gateway_ref="gw_ref_xyz999", # the ref from the gateway, not a hardcoded value
status=TransactionStatus.COMPLETED,
)
async def test_gateway_is_charged_before_repo_is_saved(
self,
processor: PaymentProcessor,
gateway_mock: AsyncMock,
repo_mock: AsyncMock,
) -> None:
"""Verify call ordering: charge before create (prevent orphaned DB records)."""
call_order = []
gateway_mock.charge.side_effect = lambda **_: call_order.append("charge") or "gw_ref"
repo_mock.create.side_effect = lambda **_: call_order.append("create")
# Side effects that append to a list and return None will cause the fixture
# return_value to be ignored -- adjust to return a Transaction for this test
from myapp.models import Transaction
repo_mock.create.side_effect = None # reset; configure inline below
async def _charge(**kwargs):
call_order.append("charge")
return "gw_ref_ordered"
async def _create(**kwargs):
call_order.append("create")
return repo_mock.create.return_value
gateway_mock.charge.side_effect = _charge
repo_mock.create.side_effect = _create
await processor.process_payment(
user_id="usr_42", amount=Decimal("10.00"), currency="USD"
)
assert call_order == ["charge", "create"], (
"Gateway must be charged before the transaction is persisted"
)
class TestProcessPaymentAmountValidation
…(truncated)