TradingAgents/.claude/skills/testing-guide/pytest-patterns.md

10 KiB

Pytest Patterns Guide

Purpose: Comprehensive guide to pytest patterns including fixtures, mocking, and parametrization.

When to use: When writing tests with pytest, creating reusable test components, or testing with multiple scenarios.


Fixtures

Pytest fixtures are functions that provide reusable test setup and teardown. They enable dependency injection for tests and promote DRY (Don't Repeat Yourself) principles.

Basic Fixture Pattern

import pytest

@pytest.fixture
def sample_data():
    """Provide sample data for tests."""
    return {"name": "Test User", "email": "test@example.com"}

def test_user_data(sample_data):
    """Test uses fixture via function parameter."""
    assert sample_data["name"] == "Test User"
    assert "email" in sample_data

Fixture Scopes

Fixtures can have different scopes to control when they're created and destroyed:

  • function (default): Created/destroyed for each test function
  • class: Created once per test class
  • module: Created once per test module
  • session: Created once per test session
@pytest.fixture(scope="function")
def temp_file():
    """Create temporary file for each test."""
    file = Path("temp.txt")
    file.write_text("test data")
    yield file
    file.unlink()  # Cleanup after test

@pytest.fixture(scope="module")
def database_connection():
    """Shared database connection for all tests in module."""
    conn = create_connection("test.db")
    yield conn
    conn.close()

@pytest.fixture(scope="session")
def test_config():
    """Global test configuration for entire test session."""
    config = load_config("test_config.yaml")
    return config

Autouse Fixtures

Fixtures can run automatically for all tests without explicit parameters:

@pytest.fixture(autouse=True)
def reset_state():
    """Automatically reset state before each test."""
    global_state.clear()
    yield
    global_state.clear()  # Cleanup after test

def test_operation():
    """This test automatically uses reset_state fixture."""
    assert len(global_state) == 0
    global_state.add("item")
    assert len(global_state) == 1

Fixture Composition

Fixtures can depend on other fixtures:

@pytest.fixture
def database():
    """Create test database."""
    db = Database(":memory:")
    db.create_tables()
    return db

@pytest.fixture
def user_repository(database):
    """Create user repository with database."""
    return UserRepository(database)

@pytest.fixture
def authenticated_user(user_repository):
    """Create and authenticate a test user."""
    user = user_repository.create("test@example.com", "password")
    token = user_repository.authenticate(user.id)
    return user, token

def test_user_operations(authenticated_user):
    """Test uses composed fixture."""
    user, token = authenticated_user
    assert user.email == "test@example.com"
    assert token is not None

Mocking

Mocking allows you to replace real objects with test doubles that simulate behavior. This is essential for isolating units under test and avoiding external dependencies.

Basic Mock Pattern

from unittest.mock import Mock, patch

def test_api_call_with_mock():
    """Test API call using mock object."""
    # Arrange
    mock_client = Mock()
    mock_client.get.return_value = {"status": "success"}

    # Act
    result = process_api_response(mock_client)

    # Assert
    mock_client.get.assert_called_once()
    assert result["status"] == "success"

Patching Functions

Use @patch decorator to replace functions during testing:

@patch('mymodule.external_api_call')
def test_function_with_patch(mock_api):
    """Test function that calls external API."""
    # Arrange
    mock_api.return_value = {"data": "test"}

    # Act
    result = my_function_that_calls_api()

    # Assert
    mock_api.assert_called_once_with(expected_param="value")
    assert result == "processed: test"

Mock Return Values and Side Effects

Control mock behavior with return_value and side_effect:

def test_mock_return_value():
    """Test mock with simple return value."""
    mock_obj = Mock()
    mock_obj.method.return_value = 42

    assert mock_obj.method() == 42
    assert mock_obj.method(any_arg="ignored") == 42

def test_mock_side_effect():
    """Test mock with side effects (multiple returns or exceptions)."""
    mock_obj = Mock()

    # Return different values on successive calls
    mock_obj.method.side_effect = [1, 2, 3]
    assert mock_obj.method() == 1
    assert mock_obj.method() == 2
    assert mock_obj.method() == 3

    # Raise exception
    mock_obj.error_method.side_effect = ValueError("Test error")
    with pytest.raises(ValueError, match="Test error"):
        mock_obj.error_method()

Mock File Operations

Use mock_open for file I/O testing:

from unittest.mock import mock_open

@patch('builtins.open', mock_open(read_data='file content'))
def test_read_file():
    """Test function that reads file."""
    content = read_config_file("config.txt")
    assert content == "file content"

@patch('builtins.open', mock_open())
def test_write_file(mock_file):
    """Test function that writes file."""
    write_log("test.log", "log message")

    mock_file.assert_called_once_with("test.log", "w")
    handle = mock_file()
    handle.write.assert_called_once_with("log message")

Asserting Mock Calls

Verify how mocks were called:

def test_mock_assertions():
    """Test various mock assertion patterns."""
    mock_obj = Mock()

    # Call mock multiple times
    mock_obj.method(1, 2)
    mock_obj.method(3, 4, key="value")
    mock_obj.other_method()

    # Assertions
    mock_obj.method.assert_called()  # Called at least once
    mock_obj.method.assert_called_with(3, 4, key="value")  # Last call
    assert mock_obj.method.call_count == 2

    # Check all calls
    assert mock_obj.method.call_args_list == [
        ((1, 2), {}),
        ((3, 4), {"key": "value"})
    ]

Parametrization

Parametrization allows running the same test with different input values, reducing code duplication and improving test coverage.

Basic Parametrization

@pytest.mark.parametrize("input,expected", [
    (1, 2),
    (2, 4),
    (3, 6),
    (5, 10),
])
def test_double(input, expected):
    """Test doubling function with multiple inputs."""
    assert double(input) == expected

Named Test Cases

Use ids parameter to give test cases descriptive names:

@pytest.mark.parametrize("email,valid", [
    ("user@example.com", True),
    ("invalid.email", False),
    ("@example.com", False),
    ("user@", False),
], ids=["valid_email", "missing_at", "missing_local", "missing_domain"])
def test_email_validation(email, valid):
    """Test email validation with named test cases."""
    assert is_valid_email(email) == valid

Multiple Parameters

Parametrize multiple arguments independently:

@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_addition(x, y):
    """Test runs 6 times (3 * 2 combinations)."""
    result = x + y
    assert result > x
    assert result > y

Complex Parametrization

Use dictionaries or objects for complex test cases:

@pytest.mark.parametrize("test_case", [
    {
        "input": {"username": "admin", "password": "secret"},
        "expected_status": 200,
        "expected_role": "admin"
    },
    {
        "input": {"username": "user", "password": "pass"},
        "expected_status": 200,
        "expected_role": "user"
    },
    {
        "input": {"username": "invalid", "password": "wrong"},
        "expected_status": 401,
        "expected_role": None
    },
], ids=["admin_login", "user_login", "invalid_login"])
def test_authentication(test_case):
    """Test authentication with complex scenarios."""
    response = authenticate(test_case["input"])
    assert response.status_code == test_case["expected_status"]
    if test_case["expected_role"]:
        assert response.user.role == test_case["expected_role"]

Parametrization with Fixtures

Combine parametrization with fixtures for powerful test scenarios:

@pytest.fixture
def api_client():
    """Create API client for tests."""
    client = APIClient("http://test.example.com")
    yield client
    client.close()

@pytest.mark.parametrize("endpoint,expected_fields", [
    ("/users", ["id", "name", "email"]),
    ("/posts", ["id", "title", "content"]),
    ("/comments", ["id", "text", "author"]),
])
def test_api_endpoints(api_client, endpoint, expected_fields):
    """Test multiple API endpoints with same client fixture."""
    response = api_client.get(endpoint)
    assert response.status_code == 200
    data = response.json()
    for field in expected_fields:
        assert field in data[0]

Exception Testing with Parametrization

Test multiple error conditions:

@pytest.mark.parametrize("invalid_input,error_type,error_message", [
    (None, TypeError, "Input cannot be None"),
    ("", ValueError, "Input cannot be empty"),
    (-1, ValueError, "Input must be positive"),
    (0, ValueError, "Input must be positive"),
], ids=["none_input", "empty_input", "negative_input", "zero_input"])
def test_validation_errors(invalid_input, error_type, error_message):
    """Test validation raises appropriate errors."""
    with pytest.raises(error_type, match=error_message):
        validate_input(invalid_input)

Best Practices

Combine Patterns Effectively

@pytest.fixture
def mock_database():
    """Mock database for testing."""
    db = Mock()
    db.query.return_value = [{"id": 1, "name": "Test"}]
    return db

@pytest.mark.parametrize("user_id,expected_name", [
    (1, "Test"),
    (2, None),
])
def test_user_lookup(mock_database, user_id, expected_name):
    """Combine fixture, mock, and parametrization."""
    # Arrange
    if user_id == 2:
        mock_database.query.return_value = []

    # Act
    user = find_user(mock_database, user_id)

    # Assert
    if expected_name:
        assert user.name == expected_name
    else:
        assert user is None

Keep Tests Focused

Each test should verify one specific behavior. Use descriptive names and clear assertions.

Use Fixtures for Setup

Extract common setup logic into fixtures rather than repeating code in each test.

Parametrize Similar Tests

If you find yourself copying a test with minor variations, use parametrization instead.


For more details: See SKILL.md for complete testing methodology and test-templates/ for working examples.