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

405 lines
10 KiB
Markdown

# 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
```python
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
```python
@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:
```python
@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:
```python
@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
```python
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:
```python
@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`:
```python
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:
```python
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:
```python
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
```python
@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:
```python
@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:
```python
@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:
```python
@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:
```python
@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:
```python
@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
```python
@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.