359 lines
11 KiB
Markdown
359 lines
11 KiB
Markdown
# pytest — Advanced Implementation Playbook
|
|
|
|
## §1 — Production Configuration
|
|
|
|
```ini
|
|
# pytest.ini
|
|
[pytest]
|
|
testpaths = tests
|
|
python_files = test_*.py
|
|
python_classes = Test*
|
|
python_functions = test_*
|
|
addopts = -v --strict-markers --tb=short -q
|
|
markers =
|
|
slow: marks tests as slow (deselect with '-m "not slow"')
|
|
integration: marks integration tests
|
|
smoke: marks smoke tests
|
|
api: marks API tests
|
|
filterwarnings =
|
|
error
|
|
ignore::DeprecationWarning
|
|
```
|
|
|
|
```toml
|
|
# pyproject.toml (alternative)
|
|
[tool.pytest.ini_options]
|
|
testpaths = ["tests"]
|
|
addopts = "-v --strict-markers --tb=short"
|
|
markers = [
|
|
"slow: marks tests as slow",
|
|
"integration: integration tests",
|
|
"smoke: smoke tests",
|
|
]
|
|
|
|
[tool.coverage.run]
|
|
source = ["src"]
|
|
omit = ["tests/*", "*/migrations/*"]
|
|
|
|
[tool.coverage.report]
|
|
fail_under = 80
|
|
show_missing = true
|
|
```
|
|
|
|
## §2 — Fixtures (Scoping, Factories, Teardown)
|
|
|
|
```python
|
|
# conftest.py — shared fixtures
|
|
import pytest
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session
|
|
|
|
# Session-scoped: created once per test session
|
|
@pytest.fixture(scope="session")
|
|
def engine():
|
|
engine = create_engine("sqlite:///test.db")
|
|
Base.metadata.create_all(engine)
|
|
yield engine
|
|
Base.metadata.drop_all(engine)
|
|
|
|
# Function-scoped: created per test (default), auto-cleanup
|
|
@pytest.fixture
|
|
def db_session(engine):
|
|
connection = engine.connect()
|
|
transaction = connection.begin()
|
|
session = Session(bind=connection)
|
|
yield session
|
|
session.close()
|
|
transaction.rollback()
|
|
connection.close()
|
|
|
|
# Factory fixture — create multiple instances
|
|
@pytest.fixture
|
|
def user_factory(db_session):
|
|
created = []
|
|
def _create_user(name="Test User", email=None, role="viewer"):
|
|
email = email or f"{name.lower().replace(' ', '.')}@test.com"
|
|
user = User(name=name, email=email, role=role)
|
|
db_session.add(user)
|
|
db_session.commit()
|
|
created.append(user)
|
|
return user
|
|
yield _create_user
|
|
for user in created:
|
|
db_session.delete(user)
|
|
db_session.commit()
|
|
|
|
# Autouse fixture — runs for every test in module
|
|
@pytest.fixture(autouse=True)
|
|
def reset_cache():
|
|
cache.clear()
|
|
yield
|
|
cache.clear()
|
|
|
|
# tmp_path for file operations (built-in)
|
|
def test_writes_output(tmp_path):
|
|
output_file = tmp_path / "result.json"
|
|
generate_report(output_file)
|
|
assert output_file.exists()
|
|
data = json.loads(output_file.read_text())
|
|
assert data["status"] == "complete"
|
|
```
|
|
|
|
## §3 — Parameterized Tests
|
|
|
|
```python
|
|
import pytest
|
|
|
|
# Basic parametrize
|
|
@pytest.mark.parametrize("input,expected", [
|
|
("hello", "HELLO"),
|
|
("world", "WORLD"),
|
|
("", ""),
|
|
("123", "123"),
|
|
])
|
|
def test_uppercase(input, expected):
|
|
assert input.upper() == expected
|
|
|
|
# Multiple parameters with IDs
|
|
@pytest.mark.parametrize("email,valid", [
|
|
("user@test.com", True),
|
|
("invalid", False),
|
|
("", False),
|
|
("user@.com", False),
|
|
], ids=["valid_email", "no_at_sign", "empty", "missing_domain"])
|
|
def test_validate_email(email, valid):
|
|
assert validate_email(email) == valid
|
|
|
|
# Combine parametrize (cartesian product)
|
|
@pytest.mark.parametrize("browser", ["chrome", "firefox", "edge"])
|
|
@pytest.mark.parametrize("resolution", ["1920x1080", "1366x768", "375x667"])
|
|
def test_responsive_layout(browser, resolution):
|
|
assert render_page(browser, resolution).is_valid()
|
|
|
|
# Indirect parametrize (pass to fixture)
|
|
@pytest.fixture
|
|
def user(request):
|
|
return create_user(role=request.param)
|
|
|
|
@pytest.mark.parametrize("user", ["admin", "editor", "viewer"], indirect=True)
|
|
def test_permissions(user):
|
|
assert user.can_view()
|
|
```
|
|
|
|
## §4 — Mocking with pytest-mock
|
|
|
|
```python
|
|
# pip install pytest-mock
|
|
|
|
def test_send_email(mocker):
|
|
mock_smtp = mocker.patch("myapp.email.smtplib.SMTP")
|
|
send_email("test@example.com", "Hello", "Body")
|
|
mock_smtp.return_value.sendmail.assert_called_once()
|
|
|
|
def test_api_call(mocker):
|
|
mock_get = mocker.patch("myapp.api.requests.get")
|
|
mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}
|
|
mock_get.return_value.status_code = 200
|
|
user = get_user(1)
|
|
assert user["name"] == "Alice"
|
|
mock_get.assert_called_once_with("https://api.example.com/users/1")
|
|
|
|
def test_database_error(mocker):
|
|
mocker.patch("myapp.db.session.commit", side_effect=IntegrityError("duplicate"))
|
|
with pytest.raises(DuplicateError):
|
|
create_user("Alice", "alice@test.com")
|
|
|
|
# Spy — track calls without replacing
|
|
def test_logging(mocker):
|
|
spy = mocker.spy(logger, "info")
|
|
process_order(order)
|
|
spy.assert_called_with("Order processed: %s", order.id)
|
|
|
|
# Mock environment variables
|
|
def test_config(monkeypatch):
|
|
monkeypatch.setenv("API_KEY", "test-key-123")
|
|
monkeypatch.setenv("DEBUG", "true")
|
|
config = load_config()
|
|
assert config.api_key == "test-key-123"
|
|
assert config.debug is True
|
|
```
|
|
|
|
## §5 — Async Testing
|
|
|
|
```python
|
|
# pip install pytest-asyncio
|
|
|
|
import pytest
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_fetch():
|
|
result = await fetch_data("https://api.example.com/data")
|
|
assert result["status"] == "ok"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_exception():
|
|
with pytest.raises(ConnectionError):
|
|
await fetch_data("https://invalid.example.com")
|
|
|
|
# Async fixtures
|
|
@pytest.fixture
|
|
async def async_client():
|
|
async with AsyncClient(app=app, base_url="http://test") as client:
|
|
yield client
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_endpoint(async_client):
|
|
response = await async_client.get("/api/users")
|
|
assert response.status_code == 200
|
|
assert len(response.json()) > 0
|
|
```
|
|
|
|
## §6 — Testing Exceptions & Warnings
|
|
|
|
```python
|
|
# Exception testing
|
|
def test_division_by_zero():
|
|
with pytest.raises(ZeroDivisionError):
|
|
1 / 0
|
|
|
|
def test_error_message():
|
|
with pytest.raises(ValueError, match=r".*invalid email.*"):
|
|
validate_email("not-an-email")
|
|
|
|
def test_raises_with_info():
|
|
with pytest.raises(PermissionError) as exc_info:
|
|
delete_file("/protected/file.txt")
|
|
assert "permission denied" in str(exc_info.value).lower()
|
|
assert exc_info.value.errno == 13
|
|
|
|
# Warning testing
|
|
def test_deprecation_warning():
|
|
with pytest.warns(DeprecationWarning, match="use new_func"):
|
|
old_func()
|
|
```
|
|
|
|
## §7 — Markers & Custom Plugins
|
|
|
|
```python
|
|
# Custom marker usage
|
|
@pytest.mark.slow
|
|
def test_full_data_processing():
|
|
result = process_large_dataset()
|
|
assert result.row_count > 1_000_000
|
|
|
|
@pytest.mark.integration
|
|
def test_database_connection():
|
|
assert db.is_connected()
|
|
|
|
# Run by marker: pytest -m "not slow"
|
|
# Run by marker: pytest -m "smoke and not integration"
|
|
|
|
# Custom plugin — conftest.py
|
|
def pytest_collection_modifyitems(config, items):
|
|
"""Auto-mark tests in integration/ directory"""
|
|
for item in items:
|
|
if "integration" in str(item.fspath):
|
|
item.add_marker(pytest.mark.integration)
|
|
|
|
# Custom report header
|
|
def pytest_report_header(config):
|
|
return f"Environment: {os.getenv('ENV', 'local')}"
|
|
```
|
|
|
|
## §8 — Class-Based Test Organization
|
|
|
|
```python
|
|
class TestUserService:
|
|
@pytest.fixture(autouse=True)
|
|
def setup(self, db_session, user_factory):
|
|
self.db = db_session
|
|
self.create_user = user_factory
|
|
self.service = UserService(db_session)
|
|
|
|
def test_create_user(self):
|
|
user = self.service.create("Alice", "alice@test.com")
|
|
assert user.id is not None
|
|
assert user.name == "Alice"
|
|
|
|
def test_find_by_email(self):
|
|
self.create_user(name="Bob", email="bob@test.com")
|
|
user = self.service.find_by_email("bob@test.com")
|
|
assert user.name == "Bob"
|
|
|
|
def test_delete_nonexistent(self):
|
|
with pytest.raises(NotFoundError):
|
|
self.service.delete(999)
|
|
|
|
class TestPermissions:
|
|
"""Nested class for permission-related tests"""
|
|
def test_admin_can_delete(self, user_factory):
|
|
admin = user_factory(role="admin")
|
|
assert admin.can_delete()
|
|
|
|
def test_viewer_cannot_delete(self, user_factory):
|
|
viewer = user_factory(role="viewer")
|
|
assert not viewer.can_delete()
|
|
```
|
|
|
|
## §9 — CI/CD Integration
|
|
|
|
```yaml
|
|
# GitHub Actions
|
|
name: Python Tests
|
|
on: [push, pull_request]
|
|
jobs:
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
strategy:
|
|
matrix:
|
|
python-version: ['3.10', '3.11', '3.12']
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/setup-python@v5
|
|
with: { python-version: '${{ matrix.python-version }}' }
|
|
- name: Install deps
|
|
run: pip install -r requirements-test.txt
|
|
- name: Run tests
|
|
run: pytest --cov=src --cov-report=xml --junitxml=results.xml -v
|
|
- name: Upload coverage
|
|
uses: codecov/codecov-action@v4
|
|
with: { files: coverage.xml }
|
|
- name: Upload results
|
|
uses: actions/upload-artifact@v4
|
|
if: always()
|
|
with: { name: test-results-${{ matrix.python-version }}, path: results.xml }
|
|
```
|
|
|
|
## §10 — Debugging Quick-Reference
|
|
|
|
| Problem | Cause | Fix |
|
|
|---------|-------|-----|
|
|
| Fixture not found | Wrong scope or missing conftest.py | Check conftest.py location, fixture name |
|
|
| `ScopeMismatch` | Function fixture depends on session fixture | Match scope: session → module → function |
|
|
| Tests interfere | Shared mutable state | Use function-scoped fixtures, `autouse` cleanup |
|
|
| Parametrize fails | Wrong number of params | Ensure tuple count matches parameter names |
|
|
| Slow collection | Too many test paths | Set `testpaths` in pytest.ini |
|
|
| Async test hangs | Missing `@pytest.mark.asyncio` | Add marker or set `asyncio_mode = "auto"` |
|
|
| Coverage wrong | Source path mismatch | Set `source` in `[tool.coverage.run]` |
|
|
| Import errors | Missing `__init__.py` or bad path | Add `__init__.py` or use `src` layout with `--import-mode=importlib` |
|
|
| Monkeypatch not reverting | Using at module scope | Only use in function-scoped fixtures |
|
|
| Marker warnings | Marker not registered | Add to `markers` in pytest.ini |
|
|
|
|
## §11 — Best Practices Checklist
|
|
|
|
- ✅ Use fixtures over setup/teardown methods
|
|
- ✅ Use `conftest.py` for shared fixtures (auto-discovered)
|
|
- ✅ Use `tmp_path` for file operations (built-in, auto-cleanup)
|
|
- ✅ Use `monkeypatch` for env vars and attribute patching
|
|
- ✅ Use `pytest-mock` (mocker fixture) over `unittest.mock`
|
|
- ✅ Use `@pytest.mark.parametrize` for data-driven tests
|
|
- ✅ Register all custom markers in `pytest.ini`
|
|
- ✅ Use `--strict-markers` to catch typos in marker names
|
|
- ✅ Use `pytest-cov` for coverage with `--cov-fail-under=80`
|
|
- ✅ Use `pytest-xdist` for parallel execution: `pytest -n auto`
|
|
- ✅ Use `--tb=short` for concise tracebacks in CI
|
|
- ✅ Structure: `tests/unit/`, `tests/integration/`, `conftest.py`
|
|
- ✅ Name files `test_*.py` and functions `test_*`
|
|
- ✅ Use factory fixtures for creating test objects
|
|
- ✅ Use `pytest.raises(match=...)` for precise error checking
|