Use parametrization for input/output matrices, not loops
Keep fixtures in app-specific conftest.py files
Use pytestmark for module-level markers
Aim for meaningful coverage, not just high percentages
Keep imports at the top of the file, never inside test functions
Use blank lines to separate AAA sections, no comments needed
Use modern type hints: dict[str, Any], list[int], str | None (not Dict, List, Optional)
Consolidate assertions: Verify status code, response body, and side effects (DB state) in a single test function. Do not split these into separate tests.
Avoid implementation details: Do not test logging calls, private methods, path construction strings, or simple wrapper delegation.
Dataclasses for Parametrization: If a parametrized test needs more than 2 arguments, use a dataclass to structure the test cases.
Project Structure
Always organize tests using src layout with unit/integration split:
Unit tests: Mock everything, run in milliseconds, no external services.
Integration tests: Real dependencies via testcontainers.
Anti-Patterns (Do Not Generate)
Test Classes: Do not use class TestFoo: to group tests. Use plain functions with descriptive names like testfoodoes_x(). Test classes add unnecessary indentation and self parameters.
Logging Verification: Do not test that logger.error was called. Exception raising is sufficient contract verification.
Wrapper Tests: Do not test methods that simply call another method (delegation). Test the underlying logic or the full chain.
Path Construction: Do not write tests solely to verify a URL string is built correctly; this is covered by the actual API call test.
Atomic Fragmentation: Do not write separate tests for statuscode, responsedata, and db_state. Combine them into one behavioral test.
Test Naming
Good names — specific about function and behavior:
def test_withdraw_raises_on_insufficient_funds(make_account):
account = make_account(balance=50.00)
with pytest.raises(InsufficientFundsError) as exc_info:
account.withdraw(100.00)
assert exc_info.value.available == 50.00
assert exc_info.value.requested == 100.00
Test Consolidation
Combine when one operation affects multiple fields:
# Good: One test verifies all effects of mark_completed()
def test_mark_order_completed_updates_status_and_timestamp(db_session, make_order):
order = make_order(status="pending", completed_at=None)
mark_completed(order.id)
result = db_session.query(Order).filter_by(id=order.id).first()
assert result.status == "completed"
assert result.completed_at is not None
# Bad: Separate tests for each field
def test_mark_order_completed_sets_status(): ...
def test_mark_order_completed_sets_timestamp(): ...
Parametrize for input variations:
# Good: One parametrized test
@pytest.mark.parametrize("mark_error", [
pytest.param(False, id="without_error"),
pytest.param(True, id="with_error"),
])
def test_create_job_status(db_session, mark_error):
create_job_status("run-123", mark_error=mark_error)
result = db_session.query(JobStatus).first()
assert result.has_error is mark_error
# Bad: Separate tests for True and False
def test_create_job_status_without_error(): ...
def test_create_job_status_with_error(): ...
Skip trivial tests:
# Skip: Testing that False stays False adds no value
def test_error_stays_false_when_not_marked(): ... # Don't write this
Consolidate Initialization Tests: Instead of testing every service property separately, verify the container in one go.
# Good: Comprehensive Container Test
def test_service_container_initializes_all_services(mock_client):
container = ServiceContainer(mock_client)
# Verify all services exist and share the client
assert isinstance(container.users, UserService)
assert isinstance(container.orders, OrderService)
assert container.users.client is mock_client
assert container.orders.client is mock_client
# Patch where it's used, not where it's defined
mocker.patch("myservice.handlers.requests.get")
# Patch object attribute
mocker.patch.object(MyClass, "method", return_value="mocked")
# Patch with side effect
mocker.patch("module.func", side_effect=ValueError("error"))
from freezegun import freeze_time
def test_session_expiry(session_service, admin_user):
with freeze_time("2023-01-01 12:00:00"):
session = session_service.create_session(user=admin_user)
with freeze_time("2023-01-01 13:00:00"): # 1 hour later
assert session_service.is_expired(session) is True
testcontainers (Integration Tests)
PostgreSQL:
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="module")
def postgres_container():
with PostgresContainer("postgres:15") as postgres:
yield postgres
@pytest.fixture
def db_session(postgres_container):
engine = create_engine(postgres_container.get_connection_url())
Base.metadata.create_all(engine)
with Session(engine) as session:
yield session
session.rollback()
Redis:
from testcontainers.redis import RedisContainer
@pytest.fixture(scope="module")
def redis_container():
with RedisContainer("redis:7") as redis:
yield redis
Place container fixtures in tests/integration/conftest.py with scope="module" or scope="session".
Parametrization
Rule: Use pytest.mark.parametrize for inputs. If the test case requires more than 2 arguments, define a dataclass at module level.
Dataclass Placement
Define parametrization dataclasses after imports, before tests:
from dataclasses import dataclass, field
from typing import Any
import pytest
pytestmark = pytest.mark.unit
@dataclass
class ApiRequestCase:
method: str
path: str
status_code: int
response_data: dict[str, Any]
request_kwargs: dict[str, Any] = field(default_factory=dict)
assertion_checks: dict[str, Any] = field(default_factory=dict)
def test_api_client_request_successful_scenarios(case):
...
Complex Parametrization (Dataclass Pattern)
Use dataclasses for 3+ parameters with modern type hints:
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ApiCase:
payload: dict[str, Any]
expected_status: int
expected_error: str | None = None
headers: dict[str, str] = field(default_factory=dict)
@pytest.mark.parametrize("case", [
ApiCase(
payload={"email": "bad-format", "name": "Test"},
expected_status=400,
expected_error="Invalid email"
),
ApiCase(
payload={"email": "[email protected]"},
expected_status=400,
expected_error="Field 'name' required"
),
], ids=lambda c: f"status_{c.expected_status}")
def test_create_user_validation(client, case):
response = client.post("/users", json=case.payload, headers=case.headers)
assert response.status_code == case.expected_status
if case.expected_error:
assert case.expected_error in response.json()["detail"]
ID Generation Tips:
Use lambda to generate IDs from dataclass fields
Keep IDs concise but meaningful
Do not add fields solely for test IDs (like description)
Run by marker: pytest -m "not slow" or pytest -m integration
Running Tests
uv run pytest # All tests
uv run pytest -m unit # Unit tests only
uv run pytest -m integration # Integration tests only
uv run pytest tests/unit/test_module.py::test_name # Specific test
uv run pytest -v -s # Verbose with print output
uv run pytest --cov=src --cov-report=term-missing # Coverage report
uv run pytest --cov-fail-under=80 # Enforce 80% coverage
uv run pytest -k "user" # Match test names