# 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.