Python Testing Patterns
Comprehensive guide to implementing robust testing strategies in Python using pytest, fixtures, mocking, parameterization, and test-driven development practices.
When to Use This Skill
- Writing unit tests for Python code
- Setting up test suites and test infrastructure
- Implementing test-driven development (TDD)
- Creating integration tests for APIs and services
- Mocking external dependencies and services
- Testing async code and concurrent operations
- Setting up continuous testing in CI/CD
- Implementing property-based testing
- Testing database operations
- Debugging failing tests
Core Concepts
1. Test Types
- Unit Tests: Test individual functions/classes in isolation
- Integration Tests: Test interaction between components
- Functional Tests: Test complete features end-to-end
- Performance Tests: Measure speed and resource usage
2. Test Structure (AAA Pattern)
- Arrange: Set up test data and preconditions
- Act: Execute the code under test
- Assert: Verify the results
3. Test Coverage
- Measure what code is exercised by tests
- Identify untested code paths
- Aim for meaningful coverage, not just high percentages
4. Test Isolation
- Tests should be independent
- No shared state between tests
- Each test should clean up after itself
Quick Start
python1# test_example.py 2def add(a, b): 3 return a + b 4 5def test_add(): 6 """Basic test example.""" 7 result = add(2, 3) 8 assert result == 5 9 10def test_add_negative(): 11 """Test with negative numbers.""" 12 assert add(-1, 1) == 0 13 14# Run with: pytest test_example.py
Fundamental Patterns
Pattern 1: Basic pytest Tests
python1# test_calculator.py 2import pytest 3 4class Calculator: 5 """Simple calculator for testing.""" 6 7 def add(self, a: float, b: float) -> float: 8 return a + b 9 10 def subtract(self, a: float, b: float) -> float: 11 return a - b 12 13 def multiply(self, a: float, b: float) -> float: 14 return a * b 15 16 def divide(self, a: float, b: float) -> float: 17 if b == 0: 18 raise ValueError("Cannot divide by zero") 19 return a / b 20 21 22def test_addition(): 23 """Test addition.""" 24 calc = Calculator() 25 assert calc.add(2, 3) == 5 26 assert calc.add(-1, 1) == 0 27 assert calc.add(0, 0) == 0 28 29 30def test_subtraction(): 31 """Test subtraction.""" 32 calc = Calculator() 33 assert calc.subtract(5, 3) == 2 34 assert calc.subtract(0, 5) == -5 35 36 37def test_multiplication(): 38 """Test multiplication.""" 39 calc = Calculator() 40 assert calc.multiply(3, 4) == 12 41 assert calc.multiply(0, 5) == 0 42 43 44def test_division(): 45 """Test division.""" 46 calc = Calculator() 47 assert calc.divide(6, 3) == 2 48 assert calc.divide(5, 2) == 2.5 49 50 51def test_division_by_zero(): 52 """Test division by zero raises error.""" 53 calc = Calculator() 54 with pytest.raises(ValueError, match="Cannot divide by zero"): 55 calc.divide(5, 0)
Pattern 2: Fixtures for Setup and Teardown
python1# test_database.py 2import pytest 3from typing import Generator 4 5class Database: 6 """Simple database class.""" 7 8 def __init__(self, connection_string: str): 9 self.connection_string = connection_string 10 self.connected = False 11 12 def connect(self): 13 """Connect to database.""" 14 self.connected = True 15 16 def disconnect(self): 17 """Disconnect from database.""" 18 self.connected = False 19 20 def query(self, sql: str) -> list: 21 """Execute query.""" 22 if not self.connected: 23 raise RuntimeError("Not connected") 24 return [{"id": 1, "name": "Test"}] 25 26 27@pytest.fixture 28def db() -> Generator[Database, None, None]: 29 """Fixture that provides connected database.""" 30 # Setup 31 database = Database("sqlite:///:memory:") 32 database.connect() 33 34 # Provide to test 35 yield database 36 37 # Teardown 38 database.disconnect() 39 40 41def test_database_query(db): 42 """Test database query with fixture.""" 43 results = db.query("SELECT * FROM users") 44 assert len(results) == 1 45 assert results[0]["name"] == "Test" 46 47 48@pytest.fixture(scope="session") 49def app_config(): 50 """Session-scoped fixture - created once per test session.""" 51 return { 52 "database_url": "postgresql://localhost/test", 53 "api_key": "test-key", 54 "debug": True 55 } 56 57 58@pytest.fixture(scope="module") 59def api_client(app_config): 60 """Module-scoped fixture - created once per test module.""" 61 # Setup expensive resource 62 client = {"config": app_config, "session": "active"} 63 yield client 64 # Cleanup 65 client["session"] = "closed" 66 67 68def test_api_client(api_client): 69 """Test using api client fixture.""" 70 assert api_client["session"] == "active" 71 assert api_client["config"]["debug"] is True
Pattern 3: Parameterized Tests
python1# test_validation.py 2import pytest 3 4def is_valid_email(email: str) -> bool: 5 """Check if email is valid.""" 6 return "@" in email and "." in email.split("@")[1] 7 8 9@pytest.mark.parametrize("email,expected", [ 10 ("user@example.com", True), 11 ("test.user@domain.co.uk", True), 12 ("invalid.email", False), 13 ("@example.com", False), 14 ("user@domain", False), 15 ("", False), 16]) 17def test_email_validation(email, expected): 18 """Test email validation with various inputs.""" 19 assert is_valid_email(email) == expected 20 21 22@pytest.mark.parametrize("a,b,expected", [ 23 (2, 3, 5), 24 (0, 0, 0), 25 (-1, 1, 0), 26 (100, 200, 300), 27 (-5, -5, -10), 28]) 29def test_addition_parameterized(a, b, expected): 30 """Test addition with multiple parameter sets.""" 31 from test_calculator import Calculator 32 calc = Calculator() 33 assert calc.add(a, b) == expected 34 35 36# Using pytest.param for special cases 37@pytest.mark.parametrize("value,expected", [ 38 pytest.param(1, True, id="positive"), 39 pytest.param(0, False, id="zero"), 40 pytest.param(-1, False, id="negative"), 41]) 42def test_is_positive(value, expected): 43 """Test with custom test IDs.""" 44 assert (value > 0) == expected
Pattern 4: Mocking with unittest.mock
python1# test_api_client.py 2import pytest 3from unittest.mock import Mock, patch, MagicMock 4import requests 5 6class APIClient: 7 """Simple API client.""" 8 9 def __init__(self, base_url: str): 10 self.base_url = base_url 11 12 def get_user(self, user_id: int) -> dict: 13 """Fetch user from API.""" 14 response = requests.get(f"{self.base_url}/users/{user_id}") 15 response.raise_for_status() 16 return response.json() 17 18 def create_user(self, data: dict) -> dict: 19 """Create new user.""" 20 response = requests.post(f"{self.base_url}/users", json=data) 21 response.raise_for_status() 22 return response.json() 23 24 25def test_get_user_success(): 26 """Test successful API call with mock.""" 27 client = APIClient("https://api.example.com") 28 29 mock_response = Mock() 30 mock_response.json.return_value = {"id": 1, "name": "John Doe"} 31 mock_response.raise_for_status.return_value = None 32 33 with patch("requests.get", return_value=mock_response) as mock_get: 34 user = client.get_user(1) 35 36 assert user["id"] == 1 37 assert user["name"] == "John Doe" 38 mock_get.assert_called_once_with("https://api.example.com/users/1") 39 40 41def test_get_user_not_found(): 42 """Test API call with 404 error.""" 43 client = APIClient("https://api.example.com") 44 45 mock_response = Mock() 46 mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found") 47 48 with patch("requests.get", return_value=mock_response): 49 with pytest.raises(requests.HTTPError): 50 client.get_user(999) 51 52 53@patch("requests.post") 54def test_create_user(mock_post): 55 """Test user creation with decorator syntax.""" 56 client = APIClient("https://api.example.com") 57 58 mock_post.return_value.json.return_value = {"id": 2, "name": "Jane Doe"} 59 mock_post.return_value.raise_for_status.return_value = None 60 61 user_data = {"name": "Jane Doe", "email": "jane@example.com"} 62 result = client.create_user(user_data) 63 64 assert result["id"] == 2 65 mock_post.assert_called_once() 66 call_args = mock_post.call_args 67 assert call_args.kwargs["json"] == user_data
Pattern 5: Testing Exceptions
python1# test_exceptions.py 2import pytest 3 4def divide(a: float, b: float) -> float: 5 """Divide a by b.""" 6 if b == 0: 7 raise ZeroDivisionError("Division by zero") 8 if not isinstance(a, (int, float)) or not isinstance(b, (int, float)): 9 raise TypeError("Arguments must be numbers") 10 return a / b 11 12 13def test_zero_division(): 14 """Test exception is raised for division by zero.""" 15 with pytest.raises(ZeroDivisionError): 16 divide(10, 0) 17 18 19def test_zero_division_with_message(): 20 """Test exception message.""" 21 with pytest.raises(ZeroDivisionError, match="Division by zero"): 22 divide(5, 0) 23 24 25def test_type_error(): 26 """Test type error exception.""" 27 with pytest.raises(TypeError, match="must be numbers"): 28 divide("10", 5) 29 30 31def test_exception_info(): 32 """Test accessing exception info.""" 33 with pytest.raises(ValueError) as exc_info: 34 int("not a number") 35 36 assert "invalid literal" in str(exc_info.value)
Advanced Patterns
Pattern 6: Testing Async Code
python1# test_async.py 2import pytest 3import asyncio 4 5async def fetch_data(url: str) -> dict: 6 """Fetch data asynchronously.""" 7 await asyncio.sleep(0.1) 8 return {"url": url, "data": "result"} 9 10 11@pytest.mark.asyncio 12async def test_fetch_data(): 13 """Test async function.""" 14 result = await fetch_data("https://api.example.com") 15 assert result["url"] == "https://api.example.com" 16 assert "data" in result 17 18 19@pytest.mark.asyncio 20async def test_concurrent_fetches(): 21 """Test concurrent async operations.""" 22 urls = ["url1", "url2", "url3"] 23 tasks = [fetch_data(url) for url in urls] 24 results = await asyncio.gather(*tasks) 25 26 assert len(results) == 3 27 assert all("data" in r for r in results) 28 29 30@pytest.fixture 31async def async_client(): 32 """Async fixture.""" 33 client = {"connected": True} 34 yield client 35 client["connected"] = False 36 37 38@pytest.mark.asyncio 39async def test_with_async_fixture(async_client): 40 """Test using async fixture.""" 41 assert async_client["connected"] is True
Pattern 7: Monkeypatch for Testing
python1# test_environment.py 2import os 3import pytest 4 5def get_database_url() -> str: 6 """Get database URL from environment.""" 7 return os.environ.get("DATABASE_URL", "sqlite:///:memory:") 8 9 10def test_database_url_default(): 11 """Test default database URL.""" 12 # Will use actual environment variable if set 13 url = get_database_url() 14 assert url 15 16 17def test_database_url_custom(monkeypatch): 18 """Test custom database URL with monkeypatch.""" 19 monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/test") 20 assert get_database_url() == "postgresql://localhost/test" 21 22 23def test_database_url_not_set(monkeypatch): 24 """Test when env var is not set.""" 25 monkeypatch.delenv("DATABASE_URL", raising=False) 26 assert get_database_url() == "sqlite:///:memory:" 27 28 29class Config: 30 """Configuration class.""" 31 32 def __init__(self): 33 self.api_key = "production-key" 34 35 def get_api_key(self): 36 return self.api_key 37 38 39def test_monkeypatch_attribute(monkeypatch): 40 """Test monkeypatching object attributes.""" 41 config = Config() 42 monkeypatch.setattr(config, "api_key", "test-key") 43 assert config.get_api_key() == "test-key"
Pattern 8: Temporary Files and Directories
python1# test_file_operations.py 2import pytest 3from pathlib import Path 4 5def save_data(filepath: Path, data: str): 6 """Save data to file.""" 7 filepath.write_text(data) 8 9 10def load_data(filepath: Path) -> str: 11 """Load data from file.""" 12 return filepath.read_text() 13 14 15def test_file_operations(tmp_path): 16 """Test file operations with temporary directory.""" 17 # tmp_path is a pathlib.Path object 18 test_file = tmp_path / "test_data.txt" 19 20 # Save data 21 save_data(test_file, "Hello, World!") 22 23 # Verify file exists 24 assert test_file.exists() 25 26 # Load and verify data 27 data = load_data(test_file) 28 assert data == "Hello, World!" 29 30 31def test_multiple_files(tmp_path): 32 """Test with multiple temporary files.""" 33 files = { 34 "file1.txt": "Content 1", 35 "file2.txt": "Content 2", 36 "file3.txt": "Content 3" 37 } 38 39 for filename, content in files.items(): 40 filepath = tmp_path / filename 41 save_data(filepath, content) 42 43 # Verify all files created 44 assert len(list(tmp_path.iterdir())) == 3 45 46 # Verify contents 47 for filename, expected_content in files.items(): 48 filepath = tmp_path / filename 49 assert load_data(filepath) == expected_content
Pattern 9: Custom Fixtures and Conftest
python1# conftest.py 2"""Shared fixtures for all tests.""" 3import pytest 4 5@pytest.fixture(scope="session") 6def database_url(): 7 """Provide database URL for all tests.""" 8 return "postgresql://localhost/test_db" 9 10 11@pytest.fixture(autouse=True) 12def reset_database(database_url): 13 """Auto-use fixture that runs before each test.""" 14 # Setup: Clear database 15 print(f"Clearing database: {database_url}") 16 yield 17 # Teardown: Clean up 18 print("Test completed") 19 20 21@pytest.fixture 22def sample_user(): 23 """Provide sample user data.""" 24 return { 25 "id": 1, 26 "name": "Test User", 27 "email": "test@example.com" 28 } 29 30 31@pytest.fixture 32def sample_users(): 33 """Provide list of sample users.""" 34 return [ 35 {"id": 1, "name": "User 1"}, 36 {"id": 2, "name": "User 2"}, 37 {"id": 3, "name": "User 3"}, 38 ] 39 40 41# Parametrized fixture 42@pytest.fixture(params=["sqlite", "postgresql", "mysql"]) 43def db_backend(request): 44 """Fixture that runs tests with different database backends.""" 45 return request.param 46 47 48def test_with_db_backend(db_backend): 49 """This test will run 3 times with different backends.""" 50 print(f"Testing with {db_backend}") 51 assert db_backend in ["sqlite", "postgresql", "mysql"]
Pattern 10: Property-Based Testing
python1# test_properties.py 2from hypothesis import given, strategies as st 3import pytest 4 5def reverse_string(s: str) -> str: 6 """Reverse a string.""" 7 return s[::-1] 8 9 10@given(st.text()) 11def test_reverse_twice_is_original(s): 12 """Property: reversing twice returns original.""" 13 assert reverse_string(reverse_string(s)) == s 14 15 16@given(st.text()) 17def test_reverse_length(s): 18 """Property: reversed string has same length.""" 19 assert len(reverse_string(s)) == len(s) 20 21 22@given(st.integers(), st.integers()) 23def test_addition_commutative(a, b): 24 """Property: addition is commutative.""" 25 assert a + b == b + a 26 27 28@given(st.lists(st.integers())) 29def test_sorted_list_properties(lst): 30 """Property: sorted list is ordered.""" 31 sorted_lst = sorted(lst) 32 33 # Same length 34 assert len(sorted_lst) == len(lst) 35 36 # All elements present 37 assert set(sorted_lst) == set(lst) 38 39 # Is ordered 40 for i in range(len(sorted_lst) - 1): 41 assert sorted_lst[i] <= sorted_lst[i + 1]
Testing Best Practices
Test Organization
python1# tests/ 2# __init__.py 3# conftest.py # Shared fixtures 4# test_unit/ # Unit tests 5# test_models.py 6# test_utils.py 7# test_integration/ # Integration tests 8# test_api.py 9# test_database.py 10# test_e2e/ # End-to-end tests 11# test_workflows.py
Test Naming
python1# Good test names 2def test_user_creation_with_valid_data(): 3 """Clear name describes what is being tested.""" 4 pass 5 6 7def test_login_fails_with_invalid_password(): 8 """Name describes expected behavior.""" 9 pass 10 11 12def test_api_returns_404_for_missing_resource(): 13 """Specific about inputs and expected outcomes.""" 14 pass 15 16 17# Bad test names 18def test_1(): # Not descriptive 19 pass 20 21 22def test_user(): # Too vague 23 pass 24 25 26def test_function(): # Doesn't explain what's tested 27 pass
Test Markers
python1# test_markers.py 2import pytest 3 4@pytest.mark.slow 5def test_slow_operation(): 6 """Mark slow tests.""" 7 import time 8 time.sleep(2) 9 10 11@pytest.mark.integration 12def test_database_integration(): 13 """Mark integration tests.""" 14 pass 15 16 17@pytest.mark.skip(reason="Feature not implemented yet") 18def test_future_feature(): 19 """Skip tests temporarily.""" 20 pass 21 22 23@pytest.mark.skipif(os.name == "nt", reason="Unix only test") 24def test_unix_specific(): 25 """Conditional skip.""" 26 pass 27 28 29@pytest.mark.xfail(reason="Known bug #123") 30def test_known_bug(): 31 """Mark expected failures.""" 32 assert False 33 34 35# Run with: 36# pytest -m slow # Run only slow tests 37# pytest -m "not slow" # Skip slow tests 38# pytest -m integration # Run integration tests
Coverage Reporting
bash1# Install coverage 2pip install pytest-cov 3 4# Run tests with coverage 5pytest --cov=myapp tests/ 6 7# Generate HTML report 8pytest --cov=myapp --cov-report=html tests/ 9 10# Fail if coverage below threshold 11pytest --cov=myapp --cov-fail-under=80 tests/ 12 13# Show missing lines 14pytest --cov=myapp --cov-report=term-missing tests/
Testing Database Code
python1# test_database_models.py 2import pytest 3from sqlalchemy import create_engine, Column, Integer, String 4from sqlalchemy.ext.declarative import declarative_base 5from sqlalchemy.orm import sessionmaker, Session 6 7Base = declarative_base() 8 9 10class User(Base): 11 """User model.""" 12 __tablename__ = "users" 13 14 id = Column(Integer, primary_key=True) 15 name = Column(String(50)) 16 email = Column(String(100), unique=True) 17 18 19@pytest.fixture(scope="function") 20def db_session() -> Session: 21 """Create in-memory database for testing.""" 22 engine = create_engine("sqlite:///:memory:") 23 Base.metadata.create_all(engine) 24 25 SessionLocal = sessionmaker(bind=engine) 26 session = SessionLocal() 27 28 yield session 29 30 session.close() 31 32 33def test_create_user(db_session): 34 """Test creating a user.""" 35 user = User(name="Test User", email="test@example.com") 36 db_session.add(user) 37 db_session.commit() 38 39 assert user.id is not None 40 assert user.name == "Test User" 41 42 43def test_query_user(db_session): 44 """Test querying users.""" 45 user1 = User(name="User 1", email="user1@example.com") 46 user2 = User(name="User 2", email="user2@example.com") 47 48 db_session.add_all([user1, user2]) 49 db_session.commit() 50 51 users = db_session.query(User).all() 52 assert len(users) == 2 53 54 55def test_unique_email_constraint(db_session): 56 """Test unique email constraint.""" 57 from sqlalchemy.exc import IntegrityError 58 59 user1 = User(name="User 1", email="same@example.com") 60 user2 = User(name="User 2", email="same@example.com") 61 62 db_session.add(user1) 63 db_session.commit() 64 65 db_session.add(user2) 66 67 with pytest.raises(IntegrityError): 68 db_session.commit()
CI/CD Integration
yaml1# .github/workflows/test.yml 2name: Tests 3 4on: [push, pull_request] 5 6jobs: 7 test: 8 runs-on: ubuntu-latest 9 10 strategy: 11 matrix: 12 python-version: ["3.9", "3.10", "3.11", "3.12"] 13 14 steps: 15 - uses: actions/checkout@v3 16 17 - name: Set up Python 18 uses: actions/setup-python@v4 19 with: 20 python-version: ${{ matrix.python-version }} 21 22 - name: Install dependencies 23 run: | 24 pip install -e ".[dev]" 25 pip install pytest pytest-cov 26 27 - name: Run tests 28 run: | 29 pytest --cov=myapp --cov-report=xml 30 31 - name: Upload coverage 32 uses: codecov/codecov-action@v3 33 with: 34 file: ./coverage.xml
Configuration Files
ini1# pytest.ini 2[pytest] 3testpaths = tests 4python_files = test_*.py 5python_classes = Test* 6python_functions = test_* 7addopts = 8 -v 9 --strict-markers 10 --tb=short 11 --cov=myapp 12 --cov-report=term-missing 13markers = 14 slow: marks tests as slow 15 integration: marks integration tests 16 unit: marks unit tests 17 e2e: marks end-to-end tests
toml1# pyproject.toml 2[tool.pytest.ini_options] 3testpaths = ["tests"] 4python_files = ["test_*.py"] 5addopts = [ 6 "-v", 7 "--cov=myapp", 8 "--cov-report=term-missing", 9] 10 11[tool.coverage.run] 12source = ["myapp"] 13omit = ["*/tests/*", "*/migrations/*"] 14 15[tool.coverage.report] 16exclude_lines = [ 17 "pragma: no cover", 18 "def __repr__", 19 "raise AssertionError", 20 "raise NotImplementedError", 21]
Resources
- pytest documentation: https://docs.pytest.org/
- unittest.mock: https://docs.python.org/3/library/unittest.mock.html
- hypothesis: Property-based testing
- pytest-asyncio: Testing async code
- pytest-cov: Coverage reporting
- pytest-mock: pytest wrapper for mock
Best Practices Summary
- Write tests first (TDD) or alongside code
- One assertion per test when possible
- Use descriptive test names that explain behavior
- Keep tests independent and isolated
- Use fixtures for setup and teardown
- Mock external dependencies appropriately
- Parametrize tests to reduce duplication
- Test edge cases and error conditions
- Measure coverage but focus on quality
- Run tests in CI/CD on every commit