Claude Code Plugins

Community-maintained marketplace

Feedback

python-testing-standards

@clostaunau/holiday-card
0
0

Comprehensive Python testing best practices, pytest conventions, test structure patterns (AAA, Given-When-Then), fixture usage, mocking strategies, code coverage standards, and common anti-patterns. Essential reference for code reviews, test writing, and ensuring high-quality Python test suites with pytest, unittest.mock, and pytest-cov.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name python-testing-standards
description Comprehensive Python testing best practices, pytest conventions, test structure patterns (AAA, Given-When-Then), fixture usage, mocking strategies, code coverage standards, and common anti-patterns. Essential reference for code reviews, test writing, and ensuring high-quality Python test suites with pytest, unittest.mock, and pytest-cov.
allowed-tools Read, Write, Grep, Glob

Python Testing Standards

Purpose

This skill provides comprehensive testing standards and best practices for Python projects using pytest. It serves as a reference guide during code reviews to ensure test quality, maintainability, and adherence to Python testing conventions.

When to use this skill:

  • Conducting code reviews of Python test files
  • Writing new test suites
  • Refactoring existing tests
  • Evaluating test coverage and quality
  • Teaching testing best practices to team members

Context

High-quality tests are essential for maintaining reliable Python applications. This skill documents industry-standard testing practices using pytest, the de facto testing framework for modern Python development. These standards emphasize:

  • Clarity: Tests should be easy to read and understand
  • Maintainability: Tests should be easy to update as code evolves
  • Reliability: Tests should be deterministic and fast
  • Coverage: Tests should cover behavior, not just lines of code
  • Isolation: Tests should be independent and not affect each other

This skill is designed to be referenced by the uncle-duke-python agent during code reviews and by developers when writing tests.

Prerequisites

Required Knowledge:

  • Python fundamentals
  • Basic understanding of testing concepts
  • Familiarity with pytest (or willingness to learn)

Required Tools:

  • pytest (pip install pytest)
  • pytest-cov for coverage (pip install pytest-cov)
  • pytest-mock for mocking (pip install pytest-mock)
  • unittest.mock (built into Python 3.3+)

Expected Project Structure:

project/
├── src/
│   └── mypackage/
│       ├── __init__.py
│       └── module.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── unit/
│   │   └── test_module.py
│   └── integration/
│       └── test_integration.py
├── pytest.ini
└── requirements-dev.txt

Instructions

Task 1: Verify pytest Conventions

1.1 Test File Naming

Rule: Test files MUST follow one of these patterns:

  • test_*.py (preferred)
  • *_test.py

Examples:

Good:

# File: test_user_service.py
# File: test_authentication.py
# File: user_service_test.py  # acceptable but less common

Bad:

# File: user_tests.py  # missing 'test_' prefix
# File: test-user-service.py  # hyphens instead of underscores
# File: TestUserService.py  # CamelCase instead of snake_case

Why: pytest discovers test files by these patterns. Non-standard names won't be automatically discovered.

1.2 Test Function Naming

Rule: Test functions MUST start with test_ and describe the scenario being tested.

Pattern: test_<what>_<when>_<expected_behavior>

Good:

def test_user_login_with_valid_credentials_returns_token():
    """Test that valid credentials return an auth token."""
    pass

def test_user_login_with_invalid_password_raises_authentication_error():
    """Test that invalid password raises AuthenticationError."""
    pass

def test_calculate_total_with_empty_cart_returns_zero():
    """Test that empty cart returns zero total."""
    pass

Bad:

def test_login():  # Too vague
    pass

def test_user_service():  # Doesn't describe what's being tested
    pass

def testLogin():  # Missing underscore, CamelCase
    pass

def test_1():  # Non-descriptive
    pass

Why: Descriptive test names serve as documentation. When a test fails, the name should immediately tell you what broke.

1.3 Test Class Naming

Rule: Test classes MUST start with Test (no _test suffix).

Good:

class TestUserAuthentication:
    """Tests for user authentication functionality."""

    def test_login_with_valid_credentials_succeeds(self):
        pass

    def test_login_with_invalid_credentials_fails(self):
        pass

class TestShoppingCart:
    """Tests for shopping cart operations."""

    def test_add_item_increases_count(self):
        pass

Bad:

class UserAuthenticationTests:  # Missing 'Test' prefix
    pass

class Test_UserAuth:  # Unnecessary underscore
    pass

class testUserAuth:  # Lowercase 'test'
    pass

Why: pytest discovers test classes by the Test prefix. Classes provide logical grouping of related tests.

When to use classes vs functions:

  • Use classes when tests share setup/teardown logic or fixtures
  • Use functions for simple, independent tests
  • Don't use classes just for grouping without shared behavior

1.4 Test Organization and Structure

Directory Structure:

tests/
├── __init__.py              # Makes tests a package
├── conftest.py              # Shared fixtures and configuration
├── unit/                    # Unit tests (fast, isolated)
│   ├── __init__.py
│   ├── test_models.py
│   ├── test_services.py
│   └── test_utils.py
├── integration/             # Integration tests (slower, external deps)
│   ├── __init__.py
│   ├── test_database.py
│   └── test_api_endpoints.py
└── e2e/                     # End-to-end tests (slowest)
    ├── __init__.py
    └── test_user_workflows.py

File Organization:

Within a test file, organize tests logically:

"""Tests for user authentication service."""

import pytest
from myapp.auth import AuthenticationService

# Fixtures at the top
@pytest.fixture
def auth_service():
    """Fixture providing authentication service instance."""
    return AuthenticationService()

# Test classes grouped by functionality
class TestUserLogin:
    """Tests for user login functionality."""

    def test_login_with_valid_credentials_succeeds(self, auth_service):
        pass

    def test_login_with_invalid_password_fails(self, auth_service):
        pass

class TestUserLogout:
    """Tests for user logout functionality."""

    def test_logout_invalidates_session(self, auth_service):
        pass

# Standalone functions for simple tests
def test_password_hashing_is_deterministic():
    """Test that same password always produces same hash."""
    pass

Task 2: Apply Test Structure Patterns

2.1 Arrange-Act-Assert (AAA) Pattern

Rule: Structure all tests using the AAA pattern with clear separation.

Pattern:

  1. Arrange: Set up test data and preconditions
  2. Act: Execute the behavior being tested
  3. Assert: Verify the expected outcome

Good:

def test_calculate_total_with_discount_applies_correctly():
    """Test that discount is correctly applied to cart total."""
    # Arrange
    cart = ShoppingCart()
    cart.add_item(Item(name="Widget", price=100.00))
    cart.add_item(Item(name="Gadget", price=50.00))
    discount = Discount(percentage=10)

    # Act
    total = cart.calculate_total(discount=discount)

    # Assert
    assert total == 135.00  # (100 + 50) * 0.9

Good (with comments):

def test_user_registration_creates_new_user():
    """Test that user registration creates a new user record."""
    # Arrange
    user_data = {
        "username": "testuser",
        "email": "test@example.com",
        "password": "SecurePass123!"
    }
    user_service = UserService()

    # Act
    user = user_service.register(user_data)

    # Assert
    assert user.username == "testuser"
    assert user.email == "test@example.com"
    assert user.id is not None
    assert user.password_hash != user_data["password"]  # Hashed

Bad:

def test_user_operations():
    # Everything mixed together
    user_service = UserService()
    user = user_service.register({"username": "test", "email": "test@example.com"})
    assert user.username == "test"
    user.update_email("new@example.com")
    assert user.email == "new@example.com"
    user_service.delete(user.id)
    assert user_service.get(user.id) is None

Why bad: Tests multiple behaviors, hard to debug when it fails, violates single responsibility.

2.2 Given-When-Then (BDD Pattern)

Alternative to AAA: Given-When-Then is equivalent but uses BDD terminology.

Pattern:

  1. Given: Preconditions and setup (same as Arrange)
  2. When: Action being tested (same as Act)
  3. Then: Expected outcome (same as Assert)

Good:

def test_withdraw_with_sufficient_balance_succeeds():
    """
    Given an account with a balance of $100
    When withdrawing $30
    Then the withdrawal succeeds and balance is $70
    """
    # Given
    account = Account(balance=100.00)

    # When
    result = account.withdraw(30.00)

    # Then
    assert result is True
    assert account.balance == 70.00

Usage: Use Given-When-Then for behavior-driven tests, especially in integration/e2e tests. Use AAA for unit tests. Be consistent within a project.

2.3 Test Isolation

Rule: Each test MUST be completely independent and not rely on execution order.

Good:

@pytest.fixture
def clean_database():
    """Provide a clean database for each test."""
    db = Database()
    db.clear()
    yield db
    db.clear()  # Cleanup after test

def test_create_user_adds_to_database(clean_database):
    """Test user creation adds record to database."""
    user = User(username="test")
    clean_database.save(user)

    assert clean_database.count() == 1

def test_delete_user_removes_from_database(clean_database):
    """Test user deletion removes record from database."""
    user = User(username="test")
    clean_database.save(user)
    clean_database.delete(user.id)

    assert clean_database.count() == 0

Bad:

# Tests depend on execution order
db = Database()

def test_1_create_user():
    """This test must run first."""
    user = User(username="test")
    db.save(user)
    assert db.count() == 1

def test_2_delete_user():
    """This test depends on test_1 running first."""
    # Assumes user from test_1 exists
    users = db.get_all()
    db.delete(users[0].id)
    assert db.count() == 0

Why bad: Tests are brittle, fail when run in isolation or different order, hard to debug.

2.4 Test Data Management

Rule: Use fixtures, factories, or builders for test data. Avoid magic values.

Good:

@pytest.fixture
def sample_user():
    """Provide a sample user for testing."""
    return User(
        username="john_doe",
        email="john@example.com",
        age=30,
        is_active=True
    )

@pytest.fixture
def user_factory():
    """Provide a factory for creating test users."""
    def _create_user(**kwargs):
        defaults = {
            "username": "test_user",
            "email": "test@example.com",
            "age": 25,
            "is_active": True
        }
        defaults.update(kwargs)
        return User(**defaults)
    return _create_user

def test_user_validation_with_invalid_age(user_factory):
    """Test that invalid age raises validation error."""
    user = user_factory(age=-5)

    with pytest.raises(ValidationError):
        user.validate()

Bad:

def test_user_creation():
    # Magic values scattered throughout
    user = User("john", "john@example.com", 30, True)
    assert user.username == "john"

def test_user_update():
    # Same magic values repeated
    user = User("john", "john@example.com", 30, True)
    user.update_age(31)
    assert user.age == 31

Why bad: Hard to maintain, unclear what values mean, violates DRY principle.

Task 3: Implement Fixture Best Practices

3.1 Fixture Scopes

Rule: Choose the appropriate scope based on fixture cost and state.

Available Scopes:

  • function: Default, runs before each test function (most common)
  • class: Runs once per test class
  • module: Runs once per module
  • session: Runs once per test session

Good:

@pytest.fixture(scope="session")
def database_engine():
    """Create database engine once per test session."""
    engine = create_engine("postgresql://localhost/test_db")
    yield engine
    engine.dispose()

@pytest.fixture(scope="module")
def database_schema(database_engine):
    """Create database schema once per module."""
    Base.metadata.create_all(database_engine)
    yield
    Base.metadata.drop_all(database_engine)

@pytest.fixture(scope="function")
def database_session(database_engine):
    """Provide a clean database session for each test."""
    connection = database_engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)

    yield session

    session.close()
    transaction.rollback()
    connection.close()

Scope Selection Guidelines:

  • Use function scope for fixtures that need to be fresh for each test
  • Use module or session scope for expensive setup (database connections, API clients)
  • NEVER use broader scope for fixtures that maintain state between tests
  • Always clean up in broader-scoped fixtures

Bad:

@pytest.fixture(scope="session")
def user_list():
    """DON'T DO THIS - mutable state in session scope."""
    return []  # This list will be shared across ALL tests!

def test_add_user(user_list):
    user_list.append("user1")
    assert len(user_list) == 1  # Might fail if other tests run first

def test_remove_user(user_list):
    # Depends on state from other tests
    user_list.remove("user1")

Why bad: Shared mutable state causes tests to interfere with each other.

3.2 Fixture Dependencies

Rule: Fixtures can depend on other fixtures to build complex test scenarios.

Good:

@pytest.fixture
def database():
    """Provide database connection."""
    db = Database("test.db")
    yield db
    db.close()

@pytest.fixture
def user(database):
    """Provide a test user in the database."""
    user = User(username="testuser")
    database.save(user)
    return user

@pytest.fixture
def authenticated_user(user):
    """Provide an authenticated user."""
    user.login()
    return user

def test_user_can_access_profile(authenticated_user, database):
    """Test authenticated user can access their profile."""
    profile = database.get_profile(authenticated_user.id)
    assert profile is not None
    assert profile.user_id == authenticated_user.id

Dependency Chain: databaseuserauthenticated_user

This approach builds complex test scenarios from simple, reusable fixtures.

3.3 Fixture Naming Conventions

Rule: Fixture names should be clear, descriptive nouns or noun phrases.

Good:

@pytest.fixture
def database_session():
    """Provide a database session."""
    pass

@pytest.fixture
def mock_email_service():
    """Provide a mocked email service."""
    pass

@pytest.fixture
def sample_user_data():
    """Provide sample user data dictionary."""
    pass

@pytest.fixture
def http_client():
    """Provide an HTTP client for API testing."""
    pass

Bad:

@pytest.fixture
def get_db():  # Verb, not noun
    pass

@pytest.fixture
def data():  # Too vague
    pass

@pytest.fixture
def fixture1():  # Non-descriptive
    pass

@pytest.fixture
def temp():  # Unclear what it provides
    pass

3.4 Fixtures vs Helper Functions

Rule: Use fixtures for setup/teardown and state. Use helper functions for operations.

Good:

# Fixture for state/setup
@pytest.fixture
def user():
    """Provide a test user."""
    return User(username="test")

# Helper function for operations
def create_post(user, title, content):
    """Helper to create a post for testing."""
    return Post(author=user, title=title, content=content)

def test_user_can_create_post(user):
    """Test that user can create a post."""
    post = create_post(user, "Test Title", "Test Content")

    assert post.author == user
    assert post.title == "Test Title"

When to use fixtures:

  • Setting up test data or objects
  • Managing resources (database connections, file handles)
  • Setup/teardown logic
  • Sharing state across multiple tests

When to use helper functions:

  • Performing operations in tests
  • Reducing code duplication in test bodies
  • Complex assertions
  • Test data generation with many parameters

3.5 autouse Fixtures

Rule: Use autouse=True sparingly, only for fixtures that should always run.

Good:

@pytest.fixture(autouse=True)
def reset_global_state():
    """Reset global application state before each test."""
    AppConfig.reset()
    Cache.clear()
    yield
    # Cleanup after test

@pytest.fixture(autouse=True, scope="session")
def configure_logging():
    """Configure logging for test session."""
    logging.basicConfig(level=logging.DEBUG)

Bad:

@pytest.fixture(autouse=True)
def create_test_user():
    """DON'T DO THIS - not all tests need a user."""
    return User(username="test")  # Wastes resources for tests that don't need it

Use autouse when:

  • Resetting global state
  • Configuring logging/warnings
  • Setting up test environment variables
  • Cleanup that should always happen

Don't use autouse when:

  • Only some tests need the fixture
  • The fixture is expensive to create
  • It makes test dependencies unclear

3.6 conftest.py Best Practices

Rule: Place shared fixtures in conftest.py at appropriate levels.

Directory Structure:

tests/
├── conftest.py              # Shared across ALL tests
├── unit/
│   ├── conftest.py          # Shared across unit tests only
│   └── test_services.py
└── integration/
    ├── conftest.py          # Shared across integration tests only
    └── test_database.py

Example conftest.py:

"""Shared fixtures for all tests."""

import pytest
from myapp import create_app
from myapp.database import Database

@pytest.fixture(scope="session")
def app():
    """Provide application instance for testing."""
    app = create_app(testing=True)
    return app

@pytest.fixture
def client(app):
    """Provide test client for making HTTP requests."""
    return app.test_client()

@pytest.fixture
def database():
    """Provide clean database for testing."""
    db = Database(":memory:")
    db.create_schema()
    yield db
    db.close()

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

Task 4: Apply Parametrization

Rule: Use @pytest.mark.parametrize to test multiple inputs without duplicating code.

Good:

@pytest.mark.parametrize("value,expected", [
    (0, False),
    (1, True),
    (42, True),
    (-1, True),
    (-42, True),
])
def test_is_non_zero(value, expected):
    """Test is_non_zero function with various inputs."""
    assert is_non_zero(value) == expected

Multiple Parameters:

@pytest.mark.parametrize("username,email,valid", [
    ("john_doe", "john@example.com", True),
    ("", "john@example.com", False),  # Empty username
    ("john_doe", "", False),  # Empty email
    ("john_doe", "invalid-email", False),  # Invalid email format
    ("ab", "short@example.com", False),  # Username too short
])
def test_user_validation(username, email, valid):
    """Test user validation with various inputs."""
    user = User(username=username, email=email)

    if valid:
        user.validate()  # Should not raise
    else:
        with pytest.raises(ValidationError):
            user.validate()

Using pytest.param for Test IDs:

@pytest.mark.parametrize("input_data,expected", [
    pytest.param(
        {"username": "john", "age": 30},
        User(username="john", age=30),
        id="valid_user"
    ),
    pytest.param(
        {"username": "", "age": 30},
        ValidationError,
        id="empty_username"
    ),
    pytest.param(
        {"username": "john", "age": -5},
        ValidationError,
        id="negative_age"
    ),
])
def test_user_creation(input_data, expected):
    """Test user creation with various inputs."""
    if isinstance(expected, type) and issubclass(expected, Exception):
        with pytest.raises(expected):
            User(**input_data)
    else:
        user = User(**input_data)
        assert user.username == expected.username
        assert user.age == expected.age

Parametrizing Fixtures:

@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def database(request):
    """Provide different database backends for testing."""
    db_type = request.param

    if db_type == "sqlite":
        db = SQLiteDatabase(":memory:")
    elif db_type == "postgresql":
        db = PostgreSQLDatabase("localhost", "test_db")
    elif db_type == "mysql":
        db = MySQLDatabase("localhost", "test_db")

    db.create_schema()
    yield db
    db.drop_schema()
    db.close()

def test_database_insert(database):
    """Test insert works on all database backends."""
    # This test runs 3 times, once for each database type
    database.insert("users", {"username": "test"})
    assert database.count("users") == 1

Task 5: Implement Mocking and Patching Best Practices

5.1 When to Mock vs When Not to Mock

Mock:

  • External services (APIs, databases, email services)
  • Slow operations (file I/O, network calls)
  • Non-deterministic operations (random, datetime)
  • Side effects you want to verify (logging, events)

Don't Mock:

  • Simple data structures (dictionaries, lists)
  • Pure functions with no side effects
  • Your own internal business logic (test it directly)
  • Value objects and entities

Good (mocking external service):

def test_send_welcome_email_calls_email_service(mocker):
    """Test that user registration sends welcome email."""
    # Mock external email service
    mock_email = mocker.patch("myapp.services.EmailService.send")

    user_service = UserService()
    user = user_service.register("john@example.com")

    # Verify email service was called
    mock_email.assert_called_once_with(
        to="john@example.com",
        subject="Welcome!",
        template="welcome"
    )

Bad (mocking internal logic):

def test_calculate_total(mocker):
    """DON'T DO THIS - mocking the thing you're testing."""
    cart = ShoppingCart()

    # Mocking internal method defeats the purpose of testing
    mocker.patch.object(cart, "calculate_total", return_value=100.0)

    assert cart.calculate_total() == 100.0  # Pointless test

5.2 unittest.mock Usage

Basic Mocking:

from unittest.mock import Mock, MagicMock, patch

def test_user_service_with_mock_database():
    """Test user service with mocked database."""
    # Create a mock database
    mock_db = Mock()
    mock_db.save.return_value = True
    mock_db.get.return_value = User(id=1, username="test")

    user_service = UserService(database=mock_db)
    user = user_service.create_user("test")

    # Verify interactions
    mock_db.save.assert_called_once()
    assert user.username == "test"

Using MagicMock for Magic Methods:

def test_context_manager():
    """Test code that uses context managers."""
    mock_file = MagicMock()
    mock_file.__enter__.return_value = mock_file
    mock_file.read.return_value = "test content"

    with mock_file as f:
        content = f.read()

    assert content == "test content"
    mock_file.__enter__.assert_called_once()
    mock_file.__exit__.assert_called_once()

5.3 pytest-mock Plugin

Preferred Approach: Use pytest-mock's mocker fixture for cleaner syntax.

Good:

def test_get_user_data_from_api(mocker):
    """Test fetching user data from external API."""
    # Mock the requests.get call
    mock_get = mocker.patch("requests.get")
    mock_get.return_value.json.return_value = {
        "id": 1,
        "name": "John Doe"
    }
    mock_get.return_value.status_code = 200

    api_client = APIClient()
    user_data = api_client.get_user(user_id=1)

    # Verify API was called correctly
    mock_get.assert_called_once_with(
        "https://api.example.com/users/1",
        headers={"Authorization": "Bearer token"}
    )
    assert user_data["name"] == "John Doe"

Mocking Class Methods:

def test_service_calls_repository(mocker):
    """Test that service layer calls repository correctly."""
    # Mock the repository method
    mock_find = mocker.patch("myapp.repositories.UserRepository.find_by_email")
    mock_find.return_value = User(id=1, email="test@example.com")

    service = UserService()
    user = service.get_user_by_email("test@example.com")

    mock_find.assert_called_once_with("test@example.com")
    assert user.email == "test@example.com"

5.4 Patching Best Practices

Rule: Patch where the object is used, not where it's defined.

Good:

# myapp/services.py
from myapp.repositories import UserRepository

class UserService:
    def get_user(self, user_id):
        return UserRepository.find(user_id)

# tests/test_services.py
def test_get_user(mocker):
    """Patch where UserRepository is USED."""
    # Correct: patch in myapp.services where it's imported
    mock_find = mocker.patch("myapp.services.UserRepository.find")
    mock_find.return_value = User(id=1)

    service = UserService()
    user = service.get_user(1)

    assert user.id == 1

Bad:

def test_get_user(mocker):
    """DON'T DO THIS - patching where it's defined."""
    # Wrong: patch in myapp.repositories where it's defined
    mock_find = mocker.patch("myapp.repositories.UserRepository.find")
    # This won't work because UserService imported it before the patch

Patching Built-ins:

def test_file_processing(mocker):
    """Test file processing with mocked open."""
    mock_open = mocker.patch("builtins.open", mocker.mock_open(read_data="test data"))

    processor = FileProcessor()
    content = processor.read_file("test.txt")

    mock_open.assert_called_once_with("test.txt", "r")
    assert content == "test data"

Patching datetime:

from datetime import datetime

def test_timestamp_generation(mocker):
    """Test timestamp generation with frozen time."""
    # Mock datetime.now()
    mock_datetime = mocker.patch("myapp.utils.datetime")
    mock_datetime.now.return_value = datetime(2024, 1, 1, 12, 0, 0)

    timestamp = generate_timestamp()

    assert timestamp == "2024-01-01 12:00:00"

5.5 Mock Assertions

Rule: Always verify that mocks were called correctly.

Common Assertions:

def test_mock_assertions(mocker):
    """Demonstrate common mock assertions."""
    mock_service = mocker.Mock()

    # Call the mock
    mock_service.send_email("test@example.com", "Hello")
    mock_service.send_email("other@example.com", "World")

    # Verify it was called
    assert mock_service.send_email.called
    assert mock_service.send_email.call_count == 2

    # Verify specific calls
    mock_service.send_email.assert_called_with("other@example.com", "World")
    mock_service.send_email.assert_any_call("test@example.com", "Hello")

    # Verify all calls
    assert mock_service.send_email.call_args_list == [
        mocker.call("test@example.com", "Hello"),
        mocker.call("other@example.com", "World"),
    ]

Verify Mock Not Called:

def test_service_does_not_send_email_for_inactive_users(mocker):
    """Test that inactive users don't receive emails."""
    mock_email = mocker.patch("myapp.services.EmailService.send")

    user_service = UserService()
    user_service.notify_user(User(is_active=False))

    # Verify email was NOT sent
    mock_email.assert_not_called()

Side Effects:

def test_retry_logic_with_failures(mocker):
    """Test retry logic when API calls fail."""
    mock_api = mocker.Mock()
    # First two calls raise exception, third succeeds
    mock_api.fetch.side_effect = [
        ConnectionError("Failed"),
        ConnectionError("Failed"),
        {"status": "success"}
    ]

    client = APIClient(api=mock_api)
    result = client.fetch_with_retry(max_retries=3)

    assert result == {"status": "success"}
    assert mock_api.fetch.call_count == 3

Task 6: Implement Code Coverage Best Practices

6.1 Coverage Thresholds

Recommended Thresholds:

  • Overall project: 80% minimum
  • New code: 90% minimum (enforce in CI)
  • Critical paths: 100% (authentication, payment, security)

pytest.ini Configuration:

[pytest]
# Run with coverage by default in CI
addopts = --cov=myapp --cov-report=html --cov-report=term --cov-fail-under=80

# Coverage options
[coverage:run]
omit =
    */tests/*
    */migrations/*
    */venv/*
    */__pycache__/*
    */site-packages/*

[coverage:report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:
    if TYPE_CHECKING:
    @abstractmethod

Running Coverage:

# Generate coverage report
pytest --cov=myapp --cov-report=html --cov-report=term

# View HTML report
open htmlcov/index.html

# Fail if coverage below threshold
pytest --cov=myapp --cov-fail-under=80

# Show missing lines
pytest --cov=myapp --cov-report=term-missing

6.2 What to Test (and What Not to Test)

DO Test:

Business Logic:

def calculate_discount(price, customer_tier):
    """Calculate discount based on customer tier."""
    if customer_tier == "gold":
        return price * 0.20
    elif customer_tier == "silver":
        return price * 0.10
    return 0.0

# TEST THIS - it's core business logic
def test_calculate_discount_for_gold_tier():
    assert calculate_discount(100.0, "gold") == 20.0

Edge Cases and Boundaries:

def test_get_user_with_nonexistent_id_raises_error():
    """Test that fetching non-existent user raises NotFound."""
    with pytest.raises(NotFoundError):
        user_service.get_user(user_id=999999)

def test_divide_by_zero_raises_error():
    """Test division by zero raises appropriate error."""
    with pytest.raises(ZeroDivisionError):
        calculator.divide(10, 0)

Error Handling:

def test_invalid_email_format_raises_validation_error():
    """Test that invalid email format raises ValidationError."""
    with pytest.raises(ValidationError, match="Invalid email format"):
        User(email="not-an-email")

Public API/Interface:

class UserService:
    def register(self, email, password):  # Public API - TEST
        """Register a new user."""
        user = self._create_user(email)  # Private - don't test directly
        self._send_welcome_email(user)  # Private - don't test directly
        return user

# Test the public method, not private helpers
def test_register_creates_user_and_sends_email(mocker):
    mock_email = mocker.patch("myapp.services.EmailService.send")

    user = user_service.register("test@example.com", "password")

    assert user.email == "test@example.com"
    mock_email.assert_called_once()

DON'T Test:

Framework/Library Code:

# DON'T test that Django's ORM works
def test_user_save():
    """DON'T DO THIS - testing Django, not your code."""
    user = User(username="test")
    user.save()
    assert User.objects.filter(username="test").exists()

Trivial Getters/Setters:

class User:
    @property
    def username(self):
        return self._username  # DON'T test this

    @username.setter
    def username(self, value):
        self._username = value  # DON'T test this

Private Implementation Details:

class Calculator:
    def add(self, a, b):
        return self._perform_addition(a, b)

    def _perform_addition(self, a, b):  # Private method
        return a + b

# DON'T test _perform_addition directly
# Test the public add() method instead

Generated Code:

# DON'T test auto-generated migration files, __init__.py, etc.

6.3 Coverage Tools

pytest-cov:

# Install
pip install pytest-cov

# Basic usage
pytest --cov=myapp

# With HTML report
pytest --cov=myapp --cov-report=html

# Show missing lines
pytest --cov=myapp --cov-report=term-missing

# Multiple formats
pytest --cov=myapp --cov-report=html --cov-report=term --cov-report=xml

Coverage.py (underlying tool):

# Run tests with coverage
coverage run -m pytest

# Generate report
coverage report

# HTML report
coverage html

# Combine coverage from multiple runs
coverage combine
coverage report

Branch Coverage:

[coverage:run]
branch = True  # Enable branch coverage (not just line coverage)
def example(x):
    if x > 0:  # Branch 1
        return "positive"
    else:  # Branch 2
        return "non-positive"

# Line coverage: 100% if you test with x=1
# Branch coverage: 50% - you need to test both x>0 and x<=0

Task 7: Avoid Common Anti-Patterns

7.1 Anti-Pattern: Testing Implementation Details

Bad:

def test_user_service_implementation_details():
    """DON'T DO THIS - testing how it works, not what it does."""
    service = UserService()

    # Testing that internal _validate method is called
    with patch.object(service, "_validate") as mock_validate:
        service.register("test@example.com")
        mock_validate.assert_called_once()

Good:

def test_user_service_validates_email():
    """Test the behavior: invalid emails are rejected."""
    service = UserService()

    # Test the behavior, not the implementation
    with pytest.raises(ValidationError):
        service.register("invalid-email")

Why: Implementation details change. Tests should verify behavior, not how the behavior is achieved.

7.2 Anti-Pattern: Overly Complex Test Setup

Bad:

def test_complex_scenario():
    """DON'T DO THIS - setup is too complex."""
    # 50 lines of setup code
    db = Database()
    db.connect()
    db.create_tables()
    user1 = User(username="user1")
    user2 = User(username="user2")
    db.save(user1)
    db.save(user2)
    post1 = Post(author=user1, title="Post 1")
    post2 = Post(author=user2, title="Post 2")
    db.save(post1)
    db.save(post2)
    comment1 = Comment(post=post1, author=user2, text="Comment")
    db.save(comment1)
    # ... many more lines

    # Finally, the actual test
    result = service.get_posts()
    assert len(result) == 2

Good:

@pytest.fixture
def database_with_posts():
    """Provide database with test posts."""
    db = Database()
    db.create_schema()

    users = [User(username=f"user{i}") for i in range(2)]
    posts = [Post(author=users[i], title=f"Post {i}") for i in range(2)]

    for user in users:
        db.save(user)
    for post in posts:
        db.save(post)

    yield db
    db.close()

def test_get_posts(database_with_posts):
    """Test retrieving posts."""
    result = service.get_posts()
    assert len(result) == 2

Why: Complex setup makes tests hard to read and maintain. Use fixtures and factories.

7.3 Anti-Pattern: Flaky Tests

Bad:

import time
import random

def test_flaky_timing():
    """DON'T DO THIS - test depends on timing."""
    start = time.time()
    process_data()
    duration = time.time() - start

    # Flaky: might fail on slow systems
    assert duration < 1.0

def test_flaky_random():
    """DON'T DO THIS - test uses uncontrolled randomness."""
    result = generate_random_value()
    assert result > 5  # Might fail randomly

def test_flaky_order():
    """DON'T DO THIS - test depends on iteration order."""
    users = User.objects.all()  # Order not guaranteed
    assert users[0].username == "alice"

Good:

def test_with_mocked_time(mocker):
    """Test with controlled time."""
    mock_time = mocker.patch("time.time")
    mock_time.side_effect = [0.0, 0.5]  # Controlled values

    start = time.time()
    process_data()
    duration = time.time() - start

    assert duration == 0.5

def test_with_seeded_random(mocker):
    """Test with controlled randomness."""
    mocker.patch("random.randint", return_value=7)

    result = generate_random_value()
    assert result == 7

def test_with_explicit_order():
    """Test with guaranteed order."""
    users = User.objects.all().order_by("username")
    assert users[0].username == "alice"

Why: Flaky tests erode trust in the test suite and waste developer time.

7.4 Anti-Pattern: Too Many Assertions in One Test

Bad:

def test_user_operations():
    """DON'T DO THIS - testing multiple behaviors."""
    # Test 1: User creation
    user = User(username="test", email="test@example.com")
    assert user.username == "test"
    assert user.email == "test@example.com"

    # Test 2: User validation
    user.validate()
    assert user.is_valid is True

    # Test 3: User saving
    user.save()
    assert user.id is not None

    # Test 4: User updating
    user.username = "updated"
    user.save()
    assert user.username == "updated"

    # Test 5: User deletion
    user.delete()
    assert User.objects.filter(id=user.id).count() == 0

Good:

def test_user_creation():
    """Test user is created with correct attributes."""
    user = User(username="test", email="test@example.com")

    assert user.username == "test"
    assert user.email == "test@example.com"

def test_user_validation_succeeds_for_valid_data():
    """Test validation succeeds for valid user data."""
    user = User(username="test", email="test@example.com")

    user.validate()

    assert user.is_valid is True

def test_user_save_assigns_id():
    """Test saving user assigns an ID."""
    user = User(username="test", email="test@example.com")

    user.save()

    assert user.id is not None

# ... separate tests for other behaviors

Guideline: One logical assertion per test. Multiple assert statements are okay if they verify the same behavior.

Acceptable:

def test_user_creation_sets_all_attributes():
    """Test user creation sets all required attributes."""
    user = User(username="test", email="test@example.com", age=30)

    # Multiple assertions, but all verify the same behavior (creation)
    assert user.username == "test"
    assert user.email == "test@example.com"
    assert user.age == 30
    assert user.is_active is True  # Default value

7.5 Anti-Pattern: Not Testing Edge Cases

Bad:

def test_divide():
    """Only tests the happy path."""
    assert divide(10, 2) == 5.0

Good:

def test_divide_positive_numbers():
    """Test division of positive numbers."""
    assert divide(10, 2) == 5.0

def test_divide_negative_numbers():
    """Test division with negative numbers."""
    assert divide(-10, 2) == -5.0
    assert divide(10, -2) == -5.0

def test_divide_by_zero_raises_error():
    """Test division by zero raises ZeroDivisionError."""
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

def test_divide_zero_by_number():
    """Test zero divided by number returns zero."""
    assert divide(0, 5) == 0.0

def test_divide_floats():
    """Test division with floating point numbers."""
    assert divide(10.5, 2.0) == 5.25

Common Edge Cases to Test:

  • Empty collections ([], {}, "")
  • None values
  • Zero
  • Negative numbers
  • Very large numbers
  • Boundary values (max/min)
  • Invalid input types
  • Special characters in strings

7.6 Anti-Pattern: Missing Negative Test Cases

Bad:

def test_create_user():
    """Only tests successful creation."""
    user = user_service.create("test@example.com", "password123")
    assert user is not None

Good:

def test_create_user_with_valid_data_succeeds():
    """Test user creation succeeds with valid data."""
    user = user_service.create("test@example.com", "password123")
    assert user is not None

def test_create_user_with_invalid_email_raises_error():
    """Test user creation fails with invalid email."""
    with pytest.raises(ValidationError):
        user_service.create("invalid-email", "password123")

def test_create_user_with_short_password_raises_error():
    """Test user creation fails with short password."""
    with pytest.raises(ValidationError):
        user_service.create("test@example.com", "pass")

def test_create_user_with_duplicate_email_raises_error():
    """Test user creation fails with duplicate email."""
    user_service.create("test@example.com", "password123")

    with pytest.raises(DuplicateEmailError):
        user_service.create("test@example.com", "password456")

Why: Negative tests verify error handling and validation logic.


Best Practices Summary

1. One Assertion Per Test (Guideline)

Guideline: Each test should verify one logical behavior. Multiple assert statements are acceptable if they all verify the same behavior.

Good:

def test_user_registration_creates_complete_user():
    """Test user registration creates user with all attributes."""
    user = register_user("john@example.com", "password")

    # All assertions verify the same behavior (successful registration)
    assert user.email == "john@example.com"
    assert user.is_active is True
    assert user.created_at is not None

2. Test Names That Describe the Scenario

Pattern: test_<what>_<when>_<expected>

Good:

def test_withdraw_with_insufficient_balance_raises_error():
    """Test withdrawing more than balance raises InsufficientFundsError."""
    pass

def test_login_with_valid_credentials_returns_token():
    """Test login with valid credentials returns auth token."""
    pass

3. Fast Tests

Rule: Unit tests should run in milliseconds, entire suite in seconds.

Strategies:

  • Use in-memory databases (SQLite :memory:)
  • Mock external dependencies
  • Use fixtures with appropriate scopes
  • Avoid unnecessary I/O operations
  • Run slow tests separately (@pytest.mark.slow)
@pytest.mark.slow
def test_full_database_migration():
    """Slow test - run separately with pytest -m slow."""
    pass

# Run fast tests only
# pytest -m "not slow"

4. Independent Tests

Rule: Tests must not depend on each other or on execution order.

Good:

@pytest.fixture(autouse=True)
def reset_database():
    """Reset database before each test."""
    db.clear()
    yield
    db.clear()

def test_create_user():
    """Each test starts with clean state."""
    user = create_user("test")
    assert db.count() == 1

def test_delete_user():
    """Independent of test_create_user."""
    user = create_user("test")
    delete_user(user.id)
    assert db.count() == 0

5. Repeatable Tests

Rule: Tests should produce the same results every time they run.

Avoid:

  • Uncontrolled randomness
  • System time dependencies
  • External API calls
  • Filesystem dependencies
  • Network dependencies

Use:

  • Mocked random with fixed seed
  • Mocked datetime
  • Mocked external services
  • Temporary directories/files
  • In-memory resources

6. Self-Validating Tests

Rule: Tests should clearly pass or fail without human interpretation.

Good:

def test_calculate_total():
    """Test clearly passes or fails."""
    total = calculate_total([10, 20, 30])
    assert total == 60  # Clear pass/fail

Bad:

def test_calculate_total():
    """Requires human to interpret output."""
    total = calculate_total([10, 20, 30])
    print(f"Total: {total}")  # No assertion - requires manual check

Examples

Example 1: Complete Test File

File: tests/unit/test_user_service.py

"""Tests for user service."""

import pytest
from myapp.models import User
from myapp.services import UserService
from myapp.exceptions import ValidationError, DuplicateEmailError


@pytest.fixture
def user_service():
    """Provide user service instance."""
    return UserService()


@pytest.fixture
def sample_user_data():
    """Provide sample valid user data."""
    return {
        "email": "john@example.com",
        "password": "SecurePass123!",
        "username": "john_doe"
    }


class TestUserRegistration:
    """Tests for user registration functionality."""

    def test_register_with_valid_data_creates_user(self, user_service, sample_user_data):
        """Test that registration with valid data creates a user."""
        # Arrange
        # (data provided by fixture)

        # Act
        user = user_service.register(**sample_user_data)

        # Assert
        assert user.email == sample_user_data["email"]
        assert user.username == sample_user_data["username"]
        assert user.id is not None
        assert user.is_active is True

    def test_register_with_invalid_email_raises_error(self, user_service):
        """Test that invalid email raises ValidationError."""
        # Arrange
        invalid_data = {
            "email": "not-an-email",
            "password": "SecurePass123!",
            "username": "john_doe"
        }

        # Act & Assert
        with pytest.raises(ValidationError, match="Invalid email format"):
            user_service.register(**invalid_data)

    def test_register_with_duplicate_email_raises_error(self, user_service, sample_user_data):
        """Test that duplicate email raises DuplicateEmailError."""
        # Arrange
        user_service.register(**sample_user_data)

        # Act & Assert
        with pytest.raises(DuplicateEmailError):
            user_service.register(**sample_user_data)

    @pytest.mark.parametrize("password,should_fail", [
        ("short", True),  # Too short
        ("nodigits!", True),  # No digits
        ("NoSpecialChar1", True),  # No special char
        ("Valid1Pass!", False),  # Valid
        ("AnotherGood2@", False),  # Valid
    ])
    def test_register_password_validation(self, user_service, password, should_fail):
        """Test password validation with various inputs."""
        # Arrange
        user_data = {
            "email": "test@example.com",
            "password": password,
            "username": "testuser"
        }

        # Act & Assert
        if should_fail:
            with pytest.raises(ValidationError):
                user_service.register(**user_data)
        else:
            user = user_service.register(**user_data)
            assert user is not None


class TestUserAuthentication:
    """Tests for user authentication functionality."""

    @pytest.fixture
    def registered_user(self, user_service, sample_user_data):
        """Provide a registered user for testing."""
        return user_service.register(**sample_user_data)

    def test_login_with_valid_credentials_returns_token(
        self, user_service, registered_user, sample_user_data
    ):
        """Test that valid credentials return an auth token."""
        # Arrange
        email = sample_user_data["email"]
        password = sample_user_data["password"]

        # Act
        token = user_service.login(email, password)

        # Assert
        assert token is not None
        assert isinstance(token, str)
        assert len(token) > 0

    def test_login_with_invalid_password_raises_error(
        self, user_service, registered_user, sample_user_data
    ):
        """Test that invalid password raises AuthenticationError."""
        # Arrange
        email = sample_user_data["email"]
        wrong_password = "WrongPassword123!"

        # Act & Assert
        with pytest.raises(AuthenticationError):
            user_service.login(email, wrong_password)

    def test_login_with_nonexistent_user_raises_error(self, user_service):
        """Test that non-existent user raises AuthenticationError."""
        # Arrange
        email = "nonexistent@example.com"
        password = "Password123!"

        # Act & Assert
        with pytest.raises(AuthenticationError):
            user_service.login(email, password)


class TestUserEmailNotification:
    """Tests for user email notification functionality."""

    def test_register_sends_welcome_email(self, user_service, sample_user_data, mocker):
        """Test that registration sends welcome email."""
        # Arrange
        mock_email = mocker.patch("myapp.services.EmailService.send")

        # Act
        user = user_service.register(**sample_user_data)

        # Assert
        mock_email.assert_called_once_with(
            to=sample_user_data["email"],
            subject="Welcome to MyApp!",
            template="welcome",
            context={"username": user.username}
        )

    def test_register_does_not_send_email_on_failure(
        self, user_service, sample_user_data, mocker
    ):
        """Test that failed registration does not send email."""
        # Arrange
        mock_email = mocker.patch("myapp.services.EmailService.send")
        invalid_data = {**sample_user_data, "email": "invalid-email"}

        # Act
        with pytest.raises(ValidationError):
            user_service.register(**invalid_data)

        # Assert
        mock_email.assert_not_called()

Example 2: Testing with Fixtures and Factories

"""Example using fixtures and factories for test data."""

import pytest
from myapp.models import User, Post, Comment


@pytest.fixture
def user_factory(db):
    """Provide factory for creating test users."""
    created_users = []

    def _create_user(**kwargs):
        defaults = {
            "username": f"user_{len(created_users)}",
            "email": f"user{len(created_users)}@example.com",
            "is_active": True
        }
        defaults.update(kwargs)
        user = User(**defaults)
        db.save(user)
        created_users.append(user)
        return user

    yield _create_user

    # Cleanup
    for user in created_users:
        db.delete(user)


@pytest.fixture
def post_factory(db, user_factory):
    """Provide factory for creating test posts."""
    created_posts = []

    def _create_post(**kwargs):
        if "author" not in kwargs:
            kwargs["author"] = user_factory()

        defaults = {
            "title": f"Post {len(created_posts)}",
            "content": "Test content"
        }
        defaults.update(kwargs)
        post = Post(**defaults)
        db.save(post)
        created_posts.append(post)
        return post

    yield _create_post

    # Cleanup
    for post in created_posts:
        db.delete(post)


def test_user_can_create_multiple_posts(user_factory, post_factory):
    """Test that user can create multiple posts."""
    # Arrange
    user = user_factory(username="author")

    # Act
    post1 = post_factory(author=user, title="First Post")
    post2 = post_factory(author=user, title="Second Post")

    # Assert
    assert post1.author == user
    assert post2.author == user
    assert user.posts.count() == 2


def test_post_without_author_creates_default_user(post_factory):
    """Test that post without author gets default user."""
    # Act
    post = post_factory(title="Test Post")

    # Assert
    assert post.author is not None
    assert post.author.username.startswith("user_")

Example 3: Integration Test with Database

"""Integration tests with real database."""

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from myapp.models import Base, User
from myapp.repositories import UserRepository


@pytest.fixture(scope="module")
def engine():
    """Create in-memory SQLite engine for testing."""
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    yield engine
    Base.metadata.drop_all(engine)


@pytest.fixture
def db_session(engine):
    """Provide database session with transaction rollback."""
    connection = engine.connect()
    transaction = connection.begin()
    Session = sessionmaker(bind=connection)
    session = Session()

    yield session

    session.close()
    transaction.rollback()
    connection.close()


@pytest.fixture
def user_repository(db_session):
    """Provide user repository instance."""
    return UserRepository(session=db_session)


def test_create_user_persists_to_database(user_repository):
    """Test that created user is persisted to database."""
    # Arrange
    user_data = {
        "username": "testuser",
        "email": "test@example.com"
    }

    # Act
    user = user_repository.create(**user_data)
    retrieved_user = user_repository.find_by_id(user.id)

    # Assert
    assert retrieved_user is not None
    assert retrieved_user.username == "testuser"
    assert retrieved_user.email == "test@example.com"


def test_find_by_email_returns_correct_user(user_repository):
    """Test finding user by email returns correct user."""
    # Arrange
    user1 = user_repository.create(username="user1", email="user1@example.com")
    user2 = user_repository.create(username="user2", email="user2@example.com")

    # Act
    found_user = user_repository.find_by_email("user2@example.com")

    # Assert
    assert found_user is not None
    assert found_user.id == user2.id
    assert found_user.username == "user2"

Templates

Template 1: Basic Unit Test

Located at: templates/unit_test_template.py

Purpose: Template for basic unit tests using AAA pattern.

Usage:

  1. Copy template to tests/unit/test_[module_name].py
  2. Replace [MODULE_NAME] with actual module name
  3. Replace [function/class] with code under test
  4. Add test cases following the pattern

See: /Users/clo/developer/gh-clostaunau/holiday-card/.claude/skills/python-testing-standards/templates/unit_test_template.py

Template 2: Integration Test with Database

Located at: templates/integration_test_template.py

Purpose: Template for integration tests with database fixtures.

Usage:

  1. Copy template to tests/integration/test_[feature_name].py
  2. Configure database engine for your database
  3. Add test cases using db_session fixture

See: /Users/clo/developer/gh-clostaunau/holiday-card/.claude/skills/python-testing-standards/templates/integration_test_template.py

Template 3: Test with Mocking

Located at: templates/mock_test_template.py

Purpose: Template for tests using mocks and patches.

Usage:

  1. Copy template to appropriate test directory
  2. Replace mock targets with actual dependencies
  3. Add verification assertions

See: /Users/clo/developer/gh-clostaunau/holiday-card/.claude/skills/python-testing-standards/templates/mock_test_template.py


Decision Trees

When to Mock vs Test Real Implementation

Is the dependency external (API, database, file system)?
├─ Yes → Mock it
│   └─ Example: mock requests.get(), EmailService.send()
└─ No → Is it slow (>100ms)?
    ├─ Yes → Mock it
    │   └─ Example: mock expensive computations, large file processing
    └─ No → Is it non-deterministic (random, time)?
        ├─ Yes → Mock it
        │   └─ Example: mock datetime.now(), random.randint()
        └─ No → Is it your internal business logic?
            ├─ Yes → Test it directly (don't mock)
            │   └─ Example: test calculation functions, validators
            └─ No → Is it a framework/library?
                ├─ Yes → Don't test it (trust the framework)
                └─ No → Consider mocking if needed for isolation

Choosing Fixture Scope

Does the fixture need to be fresh for each test?
├─ Yes → Use scope="function" (default)
│   └─ Example: test data, mutable objects
└─ No → Is the fixture expensive to create?
    ├─ Yes → Is it stateless/read-only?
    │   ├─ Yes → Use scope="module" or "session"
    │   │   └─ Example: database connection, API client
    │   └─ No → Use scope="function" with cleanup
    │       └─ Example: database with data
    └─ No → Use scope="function" for simplicity

Test Organization Strategy

What are you testing?
├─ Single function/method with no dependencies
│   └─ Use standalone test function
│       └─ Example: def test_calculate_sum()
├─ Multiple related functions/methods
│   └─ Use test class for grouping
│       └─ Example: class TestUserAuthentication
├─ Tests sharing setup/teardown
│   └─ Use test class with fixtures
│       └─ Example: class TestDatabaseOperations with setup fixtures
└─ Different test types (unit/integration/e2e)
    └─ Use separate directories
        └─ Example: tests/unit/, tests/integration/, tests/e2e/

Common Pitfalls

Pitfall 1: Mocking Too Much

Problem: Over-mocking makes tests brittle and defeats the purpose of testing.

Why it happens: Desire for fast tests or lack of understanding of what to mock.

How to avoid:

  • Only mock external dependencies and slow operations
  • Test real implementation of your business logic
  • Use integration tests for testing components together

Example:

Bad:

def test_user_service_register(mocker):
    """Over-mocked test that doesn't test anything real."""
    mock_user = mocker.Mock()
    mock_validator = mocker.patch("myapp.validators.EmailValidator")
    mock_hasher = mocker.patch("myapp.security.hash_password")
    mock_repo = mocker.patch("myapp.repositories.UserRepository")

    # This test doesn't test any real code!
    service = UserService()
    service.register("test@example.com", "password")

    mock_validator.validate.assert_called_once()
    mock_hasher.assert_called_once()
    mock_repo.save.assert_called_once()

Good:

def test_user_service_register(mocker):
    """Test real logic, mock only external dependencies."""
    # Only mock external dependencies (database, email)
    mock_email = mocker.patch("myapp.services.EmailService.send")

    # Test real validation, hashing, and service logic
    service = UserService()
    user = service.register("test@example.com", "SecurePass123!")

    # Verify real behavior
    assert user.email == "test@example.com"
    assert user.password_hash != "SecurePass123!"  # Password was hashed
    mock_email.assert_called_once()  # Email was sent

Pitfall 2: Testing Private Methods

Problem: Tests are coupled to implementation details and break on refactoring.

Why it happens: Misconception that 100% coverage requires testing private methods.

How to avoid:

  • Test public API/interface only
  • Private methods are tested indirectly through public methods
  • If a private method seems complex enough to test directly, it might deserve to be a separate class

Example:

Bad:

class UserService:
    def register(self, email, password):
        self._validate_email(email)
        self._validate_password(password)
        return self._create_user(email, password)

    def _validate_email(self, email):
        # Private validation logic
        pass

    def _create_user(self, email, password):
        # Private creation logic
        pass

# DON'T DO THIS
def test_validate_email():
    """Testing private method directly."""
    service = UserService()
    service._validate_email("test@example.com")  # Bad!

Good:

# Test the public API
def test_register_with_invalid_email_raises_error():
    """Test registration validates email (tests _validate_email indirectly)."""
    service = UserService()

    with pytest.raises(ValidationError):
        service.register("invalid-email", "SecurePass123!")

def test_register_with_valid_data_creates_user():
    """Test registration creates user (tests _create_user indirectly)."""
    service = UserService()

    user = service.register("test@example.com", "SecurePass123!")

    assert user.email == "test@example.com"

Pitfall 3: Shared State Between Tests

Problem: Tests fail or pass depending on execution order.

Why it happens: Using module-level or class-level mutable state without proper cleanup.

How to avoid:

  • Use fixtures with proper scope
  • Clean up state in fixtures (use yield for teardown)
  • Avoid module-level globals
  • Use autouse=True fixtures for necessary cleanup

Example:

Bad:

# Module-level shared state
_test_users = []

def test_create_user():
    """Test depends on _test_users being empty."""
    user = User(username="test")
    _test_users.append(user)
    assert len(_test_users) == 1  # Fails if other tests ran first!

def test_delete_user():
    """Test depends on test_create_user."""
    _test_users.pop()
    assert len(_test_users) == 0

Good:

@pytest.fixture
def user_list():
    """Provide fresh list for each test."""
    users = []
    yield users
    users.clear()  # Cleanup (though not needed with function scope)

def test_create_user(user_list):
    """Test with isolated state."""
    user = User(username="test")
    user_list.append(user)
    assert len(user_list) == 1

def test_delete_user(user_list):
    """Test with isolated state."""
    user = User(username="test")
    user_list.append(user)
    user_list.pop()
    assert len(user_list) == 0

Pitfall 4: Not Cleaning Up Resources

Problem: Tests leave behind files, database records, or open connections.

Why it happens: Forgetting to add cleanup code or not using fixtures properly.

How to avoid:

  • Use fixtures with yield for setup/teardown
  • Use context managers
  • Use temp directories for file tests
  • Use transaction rollback for database tests

Example:

Bad:

def test_file_processing():
    """Test leaves file behind."""
    with open("test_file.txt", "w") as f:
        f.write("test data")

    result = process_file("test_file.txt")
    assert result == "processed"
    # File left behind!

Good:

import tempfile
import os

@pytest.fixture
def test_file():
    """Provide temporary test file."""
    fd, path = tempfile.mkstemp(suffix=".txt")

    # Write test data
    with os.fdopen(fd, "w") as f:
        f.write("test data")

    yield path

    # Cleanup
    if os.path.exists(path):
        os.remove(path)

def test_file_processing(test_file):
    """Test with automatic cleanup."""
    result = process_file(test_file)
    assert result == "processed"
    # File automatically cleaned up

Pitfall 5: Unclear Test Failure Messages

Problem: When test fails, it's not clear what went wrong.

Why it happens: Using bare assertions without context or descriptive messages.

How to avoid:

  • Use descriptive test names
  • Add assertion messages for complex checks
  • Use pytest's assertion rewriting (automatic for simple assertions)
  • Use pytest.raises() with match parameter

Example:

Bad:

def test_user():
    """Vague test name."""
    u = User("test@example.com")
    assert u.email == "test@example.org"  # Failure message unclear

When this fails:

assert 'test@example.com' == 'test@example.org'

Good:

def test_user_email_is_stored_correctly():
    """Descriptive test name explains what's being tested."""
    # Arrange
    email = "test@example.com"

    # Act
    user = User(email)

    # Assert
    assert user.email == email, f"Expected user email to be {email}, got {user.email}"

When this fails:

AssertionError: Expected user email to be test@example.com, got test@example.org

For Exceptions:

Good:

def test_invalid_email_raises_validation_error():
    """Test that invalid email raises ValidationError with helpful message."""
    with pytest.raises(ValidationError, match="Invalid email format"):
        User("not-an-email")

Checklist

Use this checklist during code reviews to verify test quality:

Test File Organization

  • Test files named test_*.py or *_test.py
  • Tests organized in logical directories (unit/, integration/, e2e/)
  • Shared fixtures in conftest.py at appropriate levels
  • One test file per module/class being tested

Test Function/Class Naming

  • Test functions start with test_
  • Test classes start with Test
  • Names describe the scenario: test_<what>_<when>_<expected>
  • Names are clear and self-documenting

Test Structure

  • Tests follow AAA or Given-When-Then pattern
  • Clear separation between Arrange, Act, Assert
  • Each test verifies one logical behavior
  • Tests are independent and can run in any order

Fixtures

  • Appropriate fixture scope selected (function/class/module/session)
  • Fixtures have descriptive names
  • Fixtures clean up resources (using yield)
  • Fixture dependencies are logical and clear
  • autouse only used when necessary

Mocking and Patching

  • Only external dependencies are mocked
  • Internal business logic is tested directly
  • Patches target where object is used, not where it's defined
  • Mock assertions verify expected behavior
  • Mocks are reset between tests (automatic with mocker fixture)

Test Coverage

  • Critical paths have 100% coverage
  • Overall coverage meets project threshold (80%+)
  • Coverage report excludes irrelevant files (migrations, etc.)
  • Business logic is thoroughly tested
  • Edge cases are covered

Assertions

  • Tests have at least one assertion
  • Assertions are specific and meaningful
  • Exception tests use pytest.raises()
  • Assertion messages provided for complex checks
  • No bare assert without verification

Test Quality

  • No testing of implementation details
  • No overly complex test setup
  • Tests are not flaky (deterministic)
  • Edge cases are tested
  • Negative test cases are included
  • Tests are fast (unit tests < 100ms)

Parametrization

  • @pytest.mark.parametrize used for multiple similar test cases
  • Test IDs provided for clarity (using pytest.param)
  • Parametrized tests cover full range of inputs

Code Quality

  • Tests follow PEP 8 style guide
  • Tests have docstrings describing what they test
  • No code duplication (use fixtures/helpers)
  • Imports are organized and minimal
  • No commented-out code

Documentation

  • README explains how to run tests
  • Complex test scenarios are documented
  • Custom markers are registered and documented
  • Fixture purposes are clear from docstrings

Tools and Commands

Running Tests

# Run all tests
pytest

# Run specific test file
pytest tests/unit/test_user_service.py

# Run specific test function
pytest tests/unit/test_user_service.py::test_register_with_valid_data

# Run specific test class
pytest tests/unit/test_user_service.py::TestUserRegistration

# Run tests matching pattern
pytest -k "registration"

# Run tests with specific marker
pytest -m "slow"

# Run tests excluding marker
pytest -m "not slow"

# Verbose output
pytest -v

# Show print statements
pytest -s

# Stop after first failure
pytest -x

# Run last failed tests only
pytest --lf

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

# Show slowest tests
pytest --durations=10

Coverage Commands

# Run with coverage
pytest --cov=myapp

# HTML report
pytest --cov=myapp --cov-report=html

# Terminal report with missing lines
pytest --cov=myapp --cov-report=term-missing

# XML report (for CI)
pytest --cov=myapp --cov-report=xml

# Fail if coverage below threshold
pytest --cov=myapp --cov-fail-under=80

# Multiple reports
pytest --cov=myapp --cov-report=html --cov-report=term --cov-report=xml

Pytest Configuration

pytest.ini:

[pytest]
# Test discovery patterns
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*

# Default options
addopts =
    -v
    --strict-markers
    --tb=short
    --cov=myapp
    --cov-report=term-missing
    --cov-fail-under=80

# Test paths
testpaths = tests

# Markers
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
    integration: marks tests as integration tests
    unit: marks tests as unit tests
    e2e: marks tests as end-to-end tests

# Coverage options
[coverage:run]
source = myapp
omit =
    */tests/*
    */migrations/*
    */venv/*
    */__pycache__/*

[coverage:report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:
    if TYPE_CHECKING:
    @abstractmethod

Useful Pytest Plugins

# Install common plugins
pip install pytest-cov          # Coverage integration
pip install pytest-mock         # Mocking integration
pip install pytest-xdist        # Parallel test execution
pip install pytest-django       # Django integration
pip install pytest-asyncio      # Async test support
pip install pytest-timeout      # Test timeouts
pip install pytest-benchmark    # Performance benchmarking

# Run tests in parallel
pytest -n auto  # Uses all CPU cores
pytest -n 4     # Uses 4 workers

# Set test timeout
pytest --timeout=10  # 10 seconds per test

Related Skills

  • uncle-duke-python: Python code review agent that uses this skill as reference
  • agent-skill-templates: Templates for creating new skills

References

Official Documentation

Best Practices Guides

PEPs Referenced


Version: 1.0 Last Updated: 2025-12-24 Maintainer: Development Team