pytest-patterns
Python testing with pytest covering fixtures, parametrization, mocking, and test organization for reliable test suites
- Category: discovered
- Tags: github, community
- Version: 1.0.0
- Author: walid
- Downloads: 0
- Updated: 2025-11-22
Download the latest version (v1.0.0) from this page.
SKILL.md
---
name: pytest-patterns
description: Python testing with pytest covering fixtures, parametrization, mocking, and test organization for reliable test suites
---
# Pytest Patterns - Comprehensive Testing Guide
A comprehensive skill for mastering Python testing with pytest. This skill covers everything from basic test structure to advanced patterns including fixtures, parametrization, mocking, test organization, coverage analysis, and CI/CD integration.
## When to Use This Skill
Use this skill when:
- Writing tests for Python applications (web apps, APIs, CLI tools, libraries)
- Setting up test infrastructure for a new Python project
- Refactoring existing tests to be more maintainable and efficient
- Implementing test-driven development (TDD) workflows
- Creating fixture patterns for database, API, or external service testing
- Organizing large test suites with hundreds or thousands of tests
- Debugging failing tests or improving test reliability
- Setting up continuous integration testing pipelines
- Measuring and improving code coverage
- Writing integration, unit, or end-to-end tests
- Testing async Python code
- Mocking external dependencies and services
## Core Concepts
### What is pytest?
pytest is a mature, full-featured Python testing framework that makes it easy to write simple tests, yet scales to support complex functional testing. It provides:
- **Simple syntax**: Use plain `assert` statements instead of special assertion methods
- **Powerful fixtures**: Modular, composable test setup and teardown
- **Parametrization**: Run the same test with different inputs
- **Plugin ecosystem**: Hundreds of plugins for extended functionality
- **Detailed reporting**: Clear failure messages and debugging information
- **Test discovery**: Automatic test collection following naming conventions
### pytest vs unittest
```python
# unittest (traditional)
import unittest
class TestMath(unittest.TestCase):
def test_addition(self):
self.assertEqual(2 + 2, 4)
# pytest (simpler)
def test_addition():
assert 2 + 2 == 4
```
### Test Discovery Rules
pytest automatically discovers tests by following these conventions:
1. **Test files**: `test_*.py` or `*_test.py`
2. **Test functions**: Functions prefixed with `test_`
3. **Test classes**: Classes prefixed with `Test` (no `__init__` method)
4. **Test methods**: Methods prefixed with `test_` inside Test classes
## Fixtures - The Heart of pytest
### What are Fixtures?
Fixtures provide a fixed baseline for tests to run reliably and repeatably. They handle setup, provide test data, and perform cleanup.
### Basic Fixture Pattern
```python
import pytest
@pytest.fixture
def sample_data():
"""Provides sample data for testing."""
return {"name": "Alice", "age": 30}
def test_data_access(sample_data):
assert sample_data["name"] == "Alice"
assert sample_data["age"] == 30
```
### Fixture Scopes
Fixtures can have different scopes controlling how often they're created:
- **function** (default): Created for each test function
- **class**: Created once per test class
- **module**: Created once per test module
- **package**: Created once per test package
- **session**: Created once per test session
```python
@pytest.fixture(scope="session")
def database_connection():
"""Database connection created once for entire test session."""
conn = create_db_connection()
yield conn
conn.close() # Cleanup after all tests
@pytest.fixture(scope="module")
def api_client():
"""API client created once per test module."""
client = APIClient()
client.authenticate()
yield client
client.logout()
@pytest.fixture # scope="function" is default
def temp_file():
"""Temporary file created for each test."""
import tempfile
f = tempfile.NamedTemporaryFile(mode='w', delete=False)
yield f.name
os.unlink(f.name)
```
### Fixture Dependencies
Fixtures can depend on other fixtures, creating a dependency graph:
```python
@pytest.fixture
def database():
db = Database()
db.connect()
yield db
db.disconnect()
@pytest.fixture
def user_repository(database):
"""Depends on database fixture."""
return UserRepository(database)
@pytest.fixture
def sample_user(user_repository):
"""Depends on user_repository, which depends on database."""
user = user_repository.create(name="Test User")
yield user
user_repository.delete(user.id)
def test_user_operations(sample_user):
"""Uses sample_user fixture (which uses user_repository and database)."""
assert sample_user.name == "Test User"
```
### Autouse Fixtures
Fixtures that run automatically without being explicitly requested:
```python
@pytest.fixture(autouse=True)
def reset_database():
"""Runs before every test automatically."""
clear_database()
seed_test_data()
@pytest.fixture(autouse=True, scope="session")
def configure_logging():
"""Configure logging once for entire test session."""
import logging
logging.basicConfig(level=logging.DEBUG)
```
### Fixture Factories
Fixtures that return functions for creating test data:
```python
@pytest.fixture
def make_user():
"""Factory fixture for creating users."""
users = []
def _make_user(name, email=None):
user = User(name=name, email=email or f"{name}@example.com")
users.append(user)
return user
yield _make_user
# Cleanup all created users
for user in users:
user.delete()
def test_multiple_users(make_user):
user1 = make_user("Alice")
user2 = make_user("Bob", email="bob@test.com")
assert user1.name == "Alice"
assert user2.email == "bob@test.com"
```
## Parametrization - Testing Multiple Cases
### Basic Parametrization
Run the same test with different inputs:
```python
import pytest
@pytest.mark.parametrize("input_value,expected", [
(2, 4),
(3, 9),
(4, 16),
(5, 25),
])
def test_square(input_value, expected):
assert input_value ** 2 == expected
```
### Multiple Parameters
```python
@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
def test_combinations(x, y):
"""Runs 4 times: (0,2), (0,3), (1,2), (1,3)."""
assert x < y
```
### Parametrizing with IDs
Make test output more readable:
```python
@pytest.mark.parametrize("test_input,expected", [
pytest.param("3+5", 8, id="addition"),
pytest.param("2*4", 8, id="multiplication"),
pytest.param("10-2", 8, id="subtraction"),
])
def test_eval(test_input, expected):
assert eval(test_input) == expected
# Output:
# test_eval[addition] PASSED
# test_eval[multiplication] PASSED
# test_eval[subtraction] PASSED
```
### Parametrizing Fixtures
Create fixture instances with different values:
```python
@pytest.fixture(params=["mysql", "postgresql", "sqlite"])
def database_type(request):
"""Test runs three times, once for each database."""
return request.param
def test_database_connection(database_type):
conn = connect_to_database(database_type)
assert conn.is_connected()
```
### Combining Parametrization and Marks
```python
@pytest.mark.parametrize("test_input,expected", [
("valid@email.com", True),
("invalid-email", False),
pytest.param("edge@case", True, marks=pytest.mark.xfail),
pytest.param("slow@test.com", True, marks=pytest.mark.slow),
])
def test_email_validation(test_input, expected):
assert is_valid_email(test_input) == expected
```
### Indirect Parametrization
Pass parameters through fixtures:
```python
@pytest.fixture
def database(request):
"""Create database based on parameter."""
db_type = request.param
db = Database(db_type)
db.connect()
yield db
db.close()
@pytest.mark.parametrize("database", ["mysql", "postgres"], indirect=True)
def test_database_operations(database):
"""database fixture receives the parameter value."""
assert database.is_connected()
database.execute("SELECT 1")
```
## Mocking and Monkeypatching
### Using pytest's monkeypatch
The `monkeypatch` fixture provides safe patching that's automatically undone:
```python
def test_get_user_env(monkeypatch):
"""Test environment variable access."""
monkeypatch.setenv("USER", "testuser")
assert os.getenv("USER") == "testuser"
def test_remove_env(monkeypatch):
"""Test with missing environment variable."""
monkeypatch.delenv("PATH", raising=False)
assert os.getenv("PATH") is None
def test_modify_path(monkeypatch):
"""Test sys.path modification."""
monkeypatch.syspath_prepend("/custom/path")
assert "/custom/path" in sys.path
```
### Mocking Functions and Methods
```python
import requests
def get_user_data(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
return response.json()
def test_get_user_data(monkeypatch):
"""Mock external API call."""
class MockResponse:
@staticmethod
def json():
return {"id": 1, "name": "Test User"}
def mock_get(*args, **kwargs):
return MockResponse()
monkeypatch.setattr(requests, "get", mock_get)
result = get_user_data(1)
assert result["name"] == "Test User"
```
### Using unittest.mock
```python
from unittest.mock import Mock, MagicMock, patch, call
def test_with_mock():
"""Basic mock usage."""
mock_db = Mock()
mock_db.get_user.return_value = {"id": 1, "name": "Alice"}
user = mock_db.get_user(1)
assert user["name"] == "Alice"
mock_db.get_user.assert_called_once_with(1)
def test_with_patch():
"""Patch during test execution."""
with patch('mymodule.database.get_connection') as mock_conn:
mock_conn.return_value = Mock()
# Test code that uses database.get_connection()
assert mock_conn.called
@patch('mymodule.send_email')
def test_notification(mock_email):
"""Patch as decorator."""
send_notification("test@example.com", "Hello")
mock_email.assert_called_once()
```
### Mock Return Values and Side Effects
```python
def test_mock_return_values():
"""Different return values for sequential calls."""
mock_api = Mock()
mock_api.fetch.side_effect = [
{"status": "pending"},
{"status": "processing"},
{"status": "complete"}
]
assert mock_api.fetch()["status"] == "pending"
assert mock_api.fetch()["status"] == "processing"
assert mock_api.fetch()["status"] == "complete"
def test_mock_exception():
"""Mock raising exceptions."""
mock_service = Mock()
mock_service.connect.side_effect = ConnectionError("Failed to connect")
with pytest.raises(ConnectionError):
mock_service.connect()
```
### Spy Pattern - Partial Mocking
```python
def test_spy_pattern(monkeypatch):
"""Spy on a function while preserving original behavior."""
original_function = mymodule.process_data
call_count = 0
def spy_function(*args, **kwargs):
nonlocal call_count
call_count += 1
return original_function(*args, **kwargs)
monkeypatch.setattr(mymodule, "process_data", spy_function)
result = mymodule.process_data([1, 2, 3])
assert call_count == 1
assert result is not None # Original function executed
```
## Test Organization
### Directory Structure
```
project/
├── src/
│ └── mypackage/
│ ├── __init__.py
│ ├── models.py
│ ├── services.py
│ └── utils.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Shared fixtures
│ ├── unit/
│ │ ├── __init__.py
│ │ ├── test_models.py
│ │ └── test_utils.py
│ ├── integration/
│ │ ├── __init__.py
│ │ ├── conftest.py # Integration-specific fixtures
│ │ └── test_services.py
│ └── e2e/
│ └── test_workflows.py
├── pytest.ini # pytest configuration
└── setup.py
```
### conftest.py - Sharing Fixtures
The `conftest.py` file makes fixtures available to all tests in its directory and subdirectories:
```python
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def database():
"""Database connection available to all tests."""
db = Database()
db.connect()
yield db
db.disconnect()
@pytest.fixture
def clean_database(database):
"""Reset database before each test."""
database.clear_all_tables()
return database
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"
)
```
### Using Markers
Markers allow categorizing and selecting tests:
```python
import pytest
@pytest.mark.slow
def test_slow_operation():
"""Marked as slow test."""
time.sleep(5)
assert True
@pytest.mark.integration
def test_api_integration():
"""Marked as integration test."""
response = requests.get("https://api.example.com")
assert response.status_code == 200
@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
"""Skipped test."""
pass
@pytest.mark.skipif(sys.version_info < (3, 8), reason="Requires Python 3.8+")
def test_python38_feature():
"""Conditionally skipped."""
pass
@pytest.mark.xfail(reason="Known bug in dependency")
def test_known_failure():
"""Expected to fail."""
assert False
@pytest.mark.parametrize("env", ["dev", "staging", "prod"])
@pytest.mark.integration
def test_environments(env):
"""Multiple markers on one test."""
assert environment_exists(env)
```
Running tests with markers:
```bash
pytest -m slow # Run only slow tests
pytest -m "not slow" # Skip slow tests
pytest -m "integration and not slow" # Integration tests that aren't slow
pytest --markers # List all available markers
```
### Test Classes for Organization
```python
class TestUserAuthentication:
"""Group related authentication tests."""
@pytest.fixture(autouse=True)
def setup(self):
"""Setup for all tests in this class."""
self.user_service = UserService()
def test_login_success(self):
result = self.user_service.login("user", "password")
assert result.success
def test_login_failure(self):
result = self.user_service.login("user", "wrong")
assert not result.success
def test_logout(self):
self.user_service.login("user", "password")
assert self.user_service.logout()
class TestUserRegistration:
"""Group related registration tests."""
def test_register_new_user(self):
pass
def test_register_duplicate_email(self):
pass
```
## Coverage Analysis
### Installing Coverage Tools
```bash
pip install pytest-cov
```
### Running Coverage
```bash
# Basic coverage report
pytest --cov=mypackage tests/
# Coverage with HTML report
pytest --cov=mypackage --cov-report=html tests/
# Opens htmlcov/index.html
# Coverage with terminal report
pytest --cov=mypackage --cov-report=term-missing tests/
# Coverage with multiple formats
pytest --cov=mypackage --cov-report=html --cov-report=term tests/
# Fail if coverage below threshold
pytest --cov=mypackage --cov-fail-under=80 tests/
```
### Coverage Configuration
```ini
# pytest.ini or setup.cfg
[tool:pytest]
addopts =
--cov=mypackage
--cov-report=html
--cov-report=term-missing
--cov-fail-under=80
[coverage:run]
source = mypackage
omit =
*/tests/*
*/venv/*
*/__pycache__/*
[coverage:report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
if TYPE_CHECKING:
```
### Coverage in Code
```python
def critical_function(): # pragma: no cover
"""Excluded from coverage."""
pass
if sys.platform == 'win32': # pragma: no cover
# Platform-specific code excluded
pass
```
## pytest Configuration
### pytest.ini
```ini
[pytest]
# Test discovery
testpaths = tests
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*
# Output options
addopts =
-ra
--strict-markers
--strict-config
--showlocals
--tb=short
--cov=mypackage
--cov-report=html
--cov-report=term-missing
# Markers
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
unit: marks tests as unit tests
smoke: marks tests as smoke tests
regression: marks tests as regression tests
# Timeout for tests
timeout = 300
# Minimum Python version
minversion = 7.0
# Directories to ignore
norecursedirs = .git .tox dist build *.egg venv
# Warning filters
filterwarnings =
error
ignore::DeprecationWarning
```
### pyproject.toml Configuration
```toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
addopts = [
"-ra",
"--strict-markers",
"--cov=mypackage",
"--cov-report=html",
"--cov-report=term-missing",
]
markers = [
"slow: marks tests as slow",
"integration: marks tests as integration tests",
]
[tool.coverage.run]
source = ["mypackage"]
omit = ["*/tests/*", "*/venv/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError",
]
```
## CI/CD Integration
### GitHub Actions
```yaml
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[dev]
pip install pytest pytest-cov pytest-xdist
- name: Run tests
run: |
pytest --cov=mypackage --cov-report=xml --cov-report=term-missing -n auto
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
fail_ci_if_error: true
```
### GitLab CI
```yaml
# .gitlab-ci.yml
image: python:3.11
stages:
- test
- coverage
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
paths:
- .cache/pip
- venv/
before_script:
- python -m venv venv
- source venv/bin/activate
- pip install -e .[dev]
- pip install pytest pytest-cov
test:
stage: test
script:
- pytest --junitxml=report.xml --cov=mypackage --cov-report=xml
artifacts:
when: always
reports:
junit: report.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
coverage:
stage: coverage
script:
- pytest --cov=mypackage --cov-report=html --cov-fail-under=80
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts:
paths:
- htmlcov/
```
### Jenkins Pipeline
```groovy
// Jenkinsfile
pipeline {
agent any
stages {
stage('Setup') {
steps {
sh 'python -m venv venv'
sh '. venv/bin/activate && pip install -e .[dev]'
sh '. venv/bin/activate && pip install pytest pytest-cov pytest-html'
}
}
stage('Test') {
steps {
sh '. venv/bin/activate && pytest --junitxml=results.xml --html=report.html --cov=mypackage'
}
post {
always {
junit 'results.xml'
publishHTML([
allowMissing: false,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: 'htmlcov',
reportFiles: 'index.html',
reportName: 'Coverage Repor