mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
498 lines
11 KiB
Markdown
498 lines
11 KiB
Markdown
---
|
|
name: python-testing
|
|
description: >
|
|
Python testing best practices using pytest including fixtures, parametrization,
|
|
mocking, coverage analysis, async testing, and test organization. Use when
|
|
writing or improving Python tests.
|
|
metadata:
|
|
origin: ECC
|
|
globs: ["**/*.py", "**/*.pyi"]
|
|
---
|
|
|
|
# Python Testing
|
|
|
|
> This skill provides comprehensive Python testing patterns using pytest as the primary testing framework.
|
|
|
|
## Testing Framework
|
|
|
|
Use **pytest** as the testing framework for its powerful features and clean syntax.
|
|
|
|
### Basic Test Structure
|
|
|
|
```python
|
|
def test_user_creation():
|
|
"""Test that a user can be created with valid data"""
|
|
user = User(name="Alice", email="alice@example.com")
|
|
|
|
assert user.name == "Alice"
|
|
assert user.email == "alice@example.com"
|
|
assert user.is_active is True
|
|
```
|
|
|
|
### Test Discovery
|
|
|
|
pytest automatically discovers tests following these conventions:
|
|
- Files: `test_*.py` or `*_test.py`
|
|
- Functions: `test_*`
|
|
- Classes: `Test*` (without `__init__`)
|
|
- Methods: `test_*`
|
|
|
|
## Fixtures
|
|
|
|
Fixtures provide reusable test setup and teardown:
|
|
|
|
```python
|
|
import pytest
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
@pytest.fixture
|
|
def db_session():
|
|
"""Provide a database session for tests"""
|
|
engine = create_engine("sqlite:///:memory:")
|
|
Session = sessionmaker(bind=engine)
|
|
session = Session()
|
|
|
|
# Setup
|
|
Base.metadata.create_all(engine)
|
|
|
|
yield session
|
|
|
|
# Teardown
|
|
session.close()
|
|
|
|
def test_user_repository(db_session):
|
|
"""Test using the db_session fixture"""
|
|
repo = UserRepository(db_session)
|
|
user = repo.create(name="Alice", email="alice@example.com")
|
|
|
|
assert user.id is not None
|
|
```
|
|
|
|
### Fixture Scopes
|
|
|
|
```python
|
|
@pytest.fixture(scope="function") # Default: per test
|
|
def user():
|
|
return User(name="Alice")
|
|
|
|
@pytest.fixture(scope="class") # Per test class
|
|
def database():
|
|
db = Database()
|
|
db.connect()
|
|
yield db
|
|
db.disconnect()
|
|
|
|
@pytest.fixture(scope="module") # Per module
|
|
def app():
|
|
return create_app()
|
|
|
|
@pytest.fixture(scope="session") # Once per test session
|
|
def config():
|
|
return load_config()
|
|
```
|
|
|
|
### Fixture Dependencies
|
|
|
|
```python
|
|
@pytest.fixture
|
|
def database():
|
|
db = Database()
|
|
db.connect()
|
|
yield db
|
|
db.disconnect()
|
|
|
|
@pytest.fixture
|
|
def user_repository(database):
|
|
"""Fixture that depends on database fixture"""
|
|
return UserRepository(database)
|
|
|
|
def test_create_user(user_repository):
|
|
user = user_repository.create(name="Alice")
|
|
assert user.id is not None
|
|
```
|
|
|
|
## Parametrization
|
|
|
|
Test multiple inputs with `@pytest.mark.parametrize`:
|
|
|
|
```python
|
|
import pytest
|
|
|
|
@pytest.mark.parametrize("email,expected", [
|
|
("user@example.com", True),
|
|
("invalid-email", False),
|
|
("", False),
|
|
("user@", False),
|
|
("@example.com", False),
|
|
])
|
|
def test_email_validation(email, expected):
|
|
result = validate_email(email)
|
|
assert result == expected
|
|
```
|
|
|
|
### Multiple Parameters
|
|
|
|
```python
|
|
@pytest.mark.parametrize("name,age,valid", [
|
|
("Alice", 25, True),
|
|
("Bob", 17, False),
|
|
("", 25, False),
|
|
("Charlie", -1, False),
|
|
])
|
|
def test_user_validation(name, age, valid):
|
|
result = validate_user(name, age)
|
|
assert result == valid
|
|
```
|
|
|
|
### Parametrize with IDs
|
|
|
|
```python
|
|
@pytest.mark.parametrize("input,expected", [
|
|
("hello", "HELLO"),
|
|
("world", "WORLD"),
|
|
], ids=["lowercase", "another_lowercase"])
|
|
def test_uppercase(input, expected):
|
|
assert input.upper() == expected
|
|
```
|
|
|
|
## Test Markers
|
|
|
|
Use markers for test categorization and selective execution:
|
|
|
|
```python
|
|
import pytest
|
|
|
|
@pytest.mark.unit
|
|
def test_calculate_total():
|
|
"""Fast unit test"""
|
|
assert calculate_total([1, 2, 3]) == 6
|
|
|
|
@pytest.mark.integration
|
|
def test_database_connection():
|
|
"""Slower integration test"""
|
|
db = Database()
|
|
assert db.connect() is True
|
|
|
|
@pytest.mark.slow
|
|
def test_large_dataset():
|
|
"""Very slow test"""
|
|
process_million_records()
|
|
|
|
@pytest.mark.skip(reason="Not implemented yet")
|
|
def test_future_feature():
|
|
pass
|
|
|
|
@pytest.mark.skipif(sys.version_info < (3, 10), reason="Requires Python 3.10+")
|
|
def test_new_syntax():
|
|
pass
|
|
```
|
|
|
|
**Run specific markers:**
|
|
```bash
|
|
pytest -m unit # Run only unit tests
|
|
pytest -m "not slow" # Skip slow tests
|
|
pytest -m "unit or integration" # Run unit OR integration
|
|
```
|
|
|
|
## Mocking
|
|
|
|
### Using unittest.mock
|
|
|
|
```python
|
|
from unittest.mock import Mock, patch, MagicMock
|
|
|
|
def test_user_service_with_mock():
|
|
"""Test with mock repository"""
|
|
mock_repo = Mock()
|
|
mock_repo.find_by_id.return_value = User(id="1", name="Alice")
|
|
|
|
service = UserService(mock_repo)
|
|
user = service.get_user("1")
|
|
|
|
assert user.name == "Alice"
|
|
mock_repo.find_by_id.assert_called_once_with("1")
|
|
|
|
@patch('myapp.services.EmailService')
|
|
def test_send_notification(mock_email_service):
|
|
"""Test with patched dependency"""
|
|
service = NotificationService()
|
|
service.send("user@example.com", "Hello")
|
|
|
|
mock_email_service.send.assert_called_once()
|
|
```
|
|
|
|
### pytest-mock Plugin
|
|
|
|
```python
|
|
def test_with_mocker(mocker):
|
|
"""Using pytest-mock plugin"""
|
|
mock_repo = mocker.Mock()
|
|
mock_repo.find_by_id.return_value = User(id="1", name="Alice")
|
|
|
|
service = UserService(mock_repo)
|
|
user = service.get_user("1")
|
|
|
|
assert user.name == "Alice"
|
|
```
|
|
|
|
## Coverage Analysis
|
|
|
|
### Basic Coverage
|
|
|
|
```bash
|
|
pytest --cov=src --cov-report=term-missing
|
|
```
|
|
|
|
### HTML Coverage Report
|
|
|
|
```bash
|
|
pytest --cov=src --cov-report=html
|
|
open htmlcov/index.html
|
|
```
|
|
|
|
### Coverage Configuration
|
|
|
|
```ini
|
|
# pytest.ini or pyproject.toml
|
|
[tool.pytest.ini_options]
|
|
addopts = """
|
|
--cov=src
|
|
--cov-report=term-missing
|
|
--cov-report=html
|
|
--cov-fail-under=80
|
|
"""
|
|
```
|
|
|
|
### Branch Coverage
|
|
|
|
```bash
|
|
pytest --cov=src --cov-branch
|
|
```
|
|
|
|
## Async Testing
|
|
|
|
### Testing Async Functions
|
|
|
|
```python
|
|
import pytest
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_fetch_user():
|
|
"""Test async function"""
|
|
user = await fetch_user("1")
|
|
assert user.name == "Alice"
|
|
|
|
@pytest.fixture
|
|
async def async_client():
|
|
"""Async fixture"""
|
|
client = AsyncClient()
|
|
await client.connect()
|
|
yield client
|
|
await client.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_with_async_fixture(async_client):
|
|
result = await async_client.get("/users/1")
|
|
assert result.status == 200
|
|
```
|
|
|
|
## Test Organization
|
|
|
|
### Directory Structure
|
|
|
|
```
|
|
tests/
|
|
├── unit/
|
|
│ ├── test_models.py
|
|
│ ├── test_services.py
|
|
│ └── test_utils.py
|
|
├── integration/
|
|
│ ├── test_database.py
|
|
│ └── test_api.py
|
|
├── conftest.py # Shared fixtures
|
|
└── pytest.ini # Configuration
|
|
```
|
|
|
|
### conftest.py
|
|
|
|
```python
|
|
# tests/conftest.py
|
|
import pytest
|
|
|
|
@pytest.fixture(scope="session")
|
|
def app():
|
|
"""Application fixture available to all tests"""
|
|
return create_app()
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
"""Test client fixture"""
|
|
return app.test_client()
|
|
|
|
def pytest_configure(config):
|
|
"""Register custom markers"""
|
|
config.addinivalue_line("markers", "unit: Unit tests")
|
|
config.addinivalue_line("markers", "integration: Integration tests")
|
|
config.addinivalue_line("markers", "slow: Slow tests")
|
|
```
|
|
|
|
## Assertions
|
|
|
|
### Basic Assertions
|
|
|
|
```python
|
|
def test_assertions():
|
|
assert value == expected
|
|
assert value != other
|
|
assert value > 0
|
|
assert value in collection
|
|
assert isinstance(value, str)
|
|
```
|
|
|
|
### pytest Assertions with Better Error Messages
|
|
|
|
```python
|
|
def test_with_context():
|
|
"""pytest provides detailed assertion introspection"""
|
|
result = calculate_total([1, 2, 3])
|
|
expected = 6
|
|
|
|
# pytest shows: assert 5 == 6
|
|
assert result == expected
|
|
```
|
|
|
|
### Custom Assertion Messages
|
|
|
|
```python
|
|
def test_with_message():
|
|
result = process_data(input_data)
|
|
assert result.is_valid, f"Expected valid result, got errors: {result.errors}"
|
|
```
|
|
|
|
### Approximate Comparisons
|
|
|
|
```python
|
|
import pytest
|
|
|
|
def test_float_comparison():
|
|
result = 0.1 + 0.2
|
|
assert result == pytest.approx(0.3)
|
|
|
|
# With tolerance
|
|
assert result == pytest.approx(0.3, abs=1e-9)
|
|
```
|
|
|
|
## Exception Testing
|
|
|
|
```python
|
|
import pytest
|
|
|
|
def test_raises_exception():
|
|
"""Test that function raises expected exception"""
|
|
with pytest.raises(ValueError):
|
|
validate_age(-1)
|
|
|
|
def test_exception_message():
|
|
"""Test exception message"""
|
|
with pytest.raises(ValueError, match="Age must be positive"):
|
|
validate_age(-1)
|
|
|
|
def test_exception_details():
|
|
"""Capture and inspect exception"""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
validate_user(name="", age=-1)
|
|
|
|
assert "name" in exc_info.value.errors
|
|
assert "age" in exc_info.value.errors
|
|
```
|
|
|
|
## Test Helpers
|
|
|
|
```python
|
|
# tests/helpers.py
|
|
def assert_user_equal(actual, expected):
|
|
"""Custom assertion helper"""
|
|
assert actual.id == expected.id
|
|
assert actual.name == expected.name
|
|
assert actual.email == expected.email
|
|
|
|
def create_test_user(**kwargs):
|
|
"""Test data factory"""
|
|
defaults = {
|
|
"name": "Test User",
|
|
"email": "test@example.com",
|
|
"age": 25,
|
|
}
|
|
defaults.update(kwargs)
|
|
return User(**defaults)
|
|
```
|
|
|
|
## Property-Based Testing
|
|
|
|
Using `hypothesis` for property-based testing:
|
|
|
|
```python
|
|
from hypothesis import given, strategies as st
|
|
|
|
@given(st.integers(), st.integers())
|
|
def test_addition_commutative(a, b):
|
|
"""Test that addition is commutative"""
|
|
assert a + b == b + a
|
|
|
|
@given(st.lists(st.integers()))
|
|
def test_sort_idempotent(lst):
|
|
"""Test that sorting twice gives same result"""
|
|
sorted_once = sorted(lst)
|
|
sorted_twice = sorted(sorted_once)
|
|
assert sorted_once == sorted_twice
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **One assertion per test** (when possible)
|
|
2. **Use descriptive test names** - describe what's being tested
|
|
3. **Arrange-Act-Assert pattern** - clear test structure
|
|
4. **Use fixtures for setup** - avoid duplication
|
|
5. **Mock external dependencies** - keep tests fast and isolated
|
|
6. **Test edge cases** - empty inputs, None, boundaries
|
|
7. **Use parametrize** - test multiple scenarios efficiently
|
|
8. **Keep tests independent** - no shared state between tests
|
|
|
|
## Running Tests
|
|
|
|
```bash
|
|
# Run all tests
|
|
pytest
|
|
|
|
# Run specific file
|
|
pytest tests/test_user.py
|
|
|
|
# Run specific test
|
|
pytest tests/test_user.py::test_create_user
|
|
|
|
# Run with verbose output
|
|
pytest -v
|
|
|
|
# Run with output capture disabled
|
|
pytest -s
|
|
|
|
# Run in parallel (requires pytest-xdist)
|
|
pytest -n auto
|
|
|
|
# Run only failed tests from last run
|
|
pytest --lf
|
|
|
|
# Run failed tests first
|
|
pytest --ff
|
|
```
|
|
|
|
## When to Use This Skill
|
|
|
|
- Writing new Python tests
|
|
- Improving test coverage
|
|
- Setting up pytest infrastructure
|
|
- Debugging flaky tests
|
|
- Implementing integration tests
|
|
- Testing async Python code
|