--- name: pytest-patterns description: Automatically applies when writing pytest tests. Ensures proper use of fixtures, parametrize, marks, mocking, async tests, and follows testing best practices. --- # Pytest Testing Pattern Enforcer When writing tests, follow these established pytest patterns and best practices. ## ✅ Basic Test Pattern ```python import pytest from unittest.mock import Mock, patch, MagicMock def test_function_name_success(): """Test successful operation.""" # Arrange input_data = "test input" # Act result = function_under_test(input_data) # Assert assert result == expected_output assert result.status == "success" def test_function_name_error_case(): """Test error handling.""" # Arrange invalid_input = "" # Act & Assert with pytest.raises(ValueError, match="Input cannot be empty"): function_under_test(invalid_input) ``` ## ✅ Async Test Pattern ```python import pytest from unittest.mock import AsyncMock, patch @pytest.mark.asyncio async def test_async_function(): """Test async function behavior.""" # Arrange mock_data = {"id": "123", "name": "Test"} # Act result = await async_function() # Assert assert result == expected assert result["id"] == "123" @pytest.mark.asyncio @patch('module.async_dependency') async def test_with_async_mock(mock_dependency): """Test with mocked async dependency.""" # Arrange mock_dependency.return_value = AsyncMock(return_value={"status": "ok"}) # Act result = await function_calling_dependency() # Assert assert result["status"] == "ok" mock_dependency.assert_called_once() ``` ## Fixtures ```python import pytest # Function-scoped fixture (default) @pytest.fixture def user_data(): """Provide test user data.""" return { "id": "user_123", "email": "test@example.com", "name": "Test User" } # Session-scoped fixture (created once per test session) @pytest.fixture(scope="session") def database_connection(): """Provide database connection for all tests.""" db = Database.connect("test_db") yield db db.close() # Module-scoped fixture @pytest.fixture(scope="module") def api_client(): """Provide API client for module tests.""" client = APIClient(base_url="http://test.local") yield client client.close() # Fixture with cleanup (teardown) @pytest.fixture def temp_file(tmp_path): """Create temporary file for testing.""" file_path = tmp_path / "test_file.txt" file_path.write_text("test content") yield file_path # Cleanup (runs after test) if file_path.exists(): file_path.unlink() # Usage in tests def test_user_creation(user_data): """Test using fixture.""" user = create_user(user_data) assert user.id == user_data["id"] ``` ## Parametrize for Multiple Test Cases ```python import pytest @pytest.mark.parametrize("input,expected", [ ("hello", "HELLO"), ("world", "WORLD"), ("", ""), ("123", "123"), ]) def test_uppercase(input, expected): """Test uppercase conversion with multiple inputs.""" assert uppercase(input) == expected @pytest.mark.parametrize("email", [ "user@example.com", "test.user@domain.co.uk", "user+tag@example.com", ]) def test_valid_emails(email): """Test valid email formats.""" assert is_valid_email(email) is True @pytest.mark.parametrize("email", [ "invalid", "@example.com", "user@", "user@.com", ]) def test_invalid_emails(email): """Test invalid email formats.""" assert is_valid_email(email) is False # Multiple parameters @pytest.mark.parametrize("a,b,expected", [ (1, 2, 3), (0, 0, 0), (-1, 1, 0), (100, 200, 300), ]) def test_addition(a, b, expected): """Test addition with various inputs.""" assert add(a, b) == expected # Named test cases @pytest.mark.parametrize("input,expected", [ pytest.param("valid@email.com", True, id="valid_email"), pytest.param("invalid", False, id="invalid_email"), pytest.param("", False, id="empty_string"), ]) def test_email_validation(input, expected): """Test email validation.""" assert is_valid_email(input) == expected ``` ## Mocking with unittest.mock ```python from unittest.mock import Mock, MagicMock, patch, call def test_with_mock(): """Test with Mock object.""" mock_service = Mock() mock_service.get_data.return_value = {"status": "success"} result = process_data(mock_service) assert result["status"] == "success" mock_service.get_data.assert_called_once() def test_mock_multiple_calls(): """Test multiple calls to mock.""" mock = Mock() mock.side_effect = [1, 2, 3] # Different return for each call assert mock() == 1 assert mock() == 2 assert mock() == 3 @patch('module.external_api_call') def test_with_patch(mock_api): """Test with patched external call.""" # Arrange mock_api.return_value = {"data": "test"} # Act result = function_that_calls_api() # Assert assert result["data"] == "test" mock_api.assert_called_once_with(expected_param) def test_mock_http_request(): """Test HTTP request with mock response.""" with patch('httpx.get') as mock_get: # Create mock response mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = {"key": "value"} mock_response.raise_for_status = Mock() mock_get.return_value = mock_response # Test result = fetch_data_from_api() assert result["key"] == "value" mock_get.assert_called_once() def test_verify_call_arguments(): """Test that mock was called with specific arguments.""" mock = Mock() function_with_mock(mock, param1="test", param2=123) # Verify call mock.method.assert_called_with("test", 123) # Verify any call in call history mock.method.assert_any_call("test", 123) # Verify call count assert mock.method.call_count == 1 # Verify all calls mock.method.assert_has_calls([ call("first"), call("second"), ]) ``` ## Pytest Marks ```python import pytest # Skip test @pytest.mark.skip(reason="Not implemented yet") def test_future_feature(): """Test to be implemented.""" pass # Skip conditionally @pytest.mark.skipif(sys.version_info < (3, 10), reason="Requires Python 3.10+") def test_python310_feature(): """Test Python 3.10+ feature.""" pass # Expected failure @pytest.mark.xfail(reason="Known bug in external library") def test_with_known_bug(): """Test that currently fails due to known bug.""" assert buggy_function() == expected # Custom marks @pytest.mark.slow def test_slow_operation(): """Test that takes a long time.""" pass @pytest.mark.integration def test_database_integration(): """Integration test with database.""" pass # Run with: pytest -m "not slow" to skip slow tests # Run with: pytest -m integration to run only integration tests ``` ## Testing Exceptions ```python import pytest def test_exception_raised(): """Test that exception is raised.""" with pytest.raises(ValueError): function_that_raises() def test_exception_message(): """Test exception message.""" with pytest.raises(ValueError, match="Invalid input"): function_that_raises("invalid") def test_exception_with_context(): """Test exception with context checking.""" with pytest.raises(APIError) as exc_info: call_failing_api() # Check exception details assert exc_info.value.status_code == 404 assert "not found" in str(exc_info.value) ``` ## Testing with Database (Fixtures) ```python import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @pytest.fixture(scope="session") def db_engine(): """Create test database engine.""" engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) yield engine engine.dispose() @pytest.fixture def db_session(db_engine): """Create database session for test.""" Session = sessionmaker(bind=db_engine) session = Session() yield session session.rollback() session.close() def test_create_user(db_session): """Test user creation in database.""" user = User(name="Test User", email="test@example.com") db_session.add(user) db_session.commit() # Verify found_user = db_session.query(User).filter_by(email="test@example.com").first() assert found_user is not None assert found_user.name == "Test User" ``` ## Testing File Operations ```python import pytest def test_file_read(tmp_path): """Test file reading with temporary file.""" # Create temporary file test_file = tmp_path / "test.txt" test_file.write_text("test content") # Test result = read_file(test_file) assert result == "test content" def test_file_write(tmp_path): """Test file writing.""" output_file = tmp_path / "output.txt" write_file(output_file, "new content") assert output_file.exists() assert output_file.read_text() == "new content" ``` ## Test Organization ```python # tests/test_user_service.py class TestUserService: """Tests for UserService.""" def test_create_user_success(self): """Test successful user creation.""" service = UserService() user = service.create_user("test@example.com") assert user.email == "test@example.com" def test_create_user_duplicate_email(self): """Test error on duplicate email.""" service = UserService() service.create_user("test@example.com") with pytest.raises(DuplicateEmailError): service.create_user("test@example.com") def test_get_user_found(self): """Test getting existing user.""" service = UserService() created = service.create_user("test@example.com") found = service.get_user(created.id) assert found.id == created.id def test_get_user_not_found(self): """Test getting non-existent user.""" service = UserService() with pytest.raises(UserNotFoundError): service.get_user("nonexistent_id") ``` ## Coverage ```python # Run tests with coverage # pytest --cov=src --cov-report=html # Add to pyproject.toml [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] python_functions = ["test_*"] addopts = "--cov=src --cov-report=term-missing" # Minimum coverage requirement [tool.coverage.report] fail_under = 80 exclude_lines = [ "pragma: no cover", "def __repr__", "raise AssertionError", "raise NotImplementedError", "if __name__ == .__main__.:", ] ``` ## ❌ Anti-Patterns ```python # ❌ No test docstring def test_something(): assert True # ❌ Testing multiple things in one test def test_user(): # Too much in one test - split into multiple tests user = create_user() update_user(user) delete_user(user) # ❌ No arrange/act/assert structure def test_messy(): result = function() x = 5 assert result > x y = calculate() # ❌ Mutable fixture default @pytest.fixture def config(): return {"key": "value"} # Shared dict - mutations affect other tests! # ✅ Better @pytest.fixture def config(): return {"key": "value"}.copy() # ❌ Not using parametrize def test_email1(): assert is_valid("test@example.com") def test_email2(): assert is_valid("user@domain.com") # ✅ Better: Use parametrize @pytest.mark.parametrize("email", ["test@example.com", "user@domain.com"]) def test_valid_email(email): assert is_valid(email) # ❌ Not cleaning up resources def test_file(): file = open("test.txt", "w") file.write("test") # Missing: file.close() # ✅ Better: Use context manager or fixture def test_file(): with open("test.txt", "w") as file: file.write("test") ``` ## Best Practices Checklist - ✅ Use descriptive test names: `test_function_scenario_expectation` - ✅ Add docstrings to all test functions - ✅ Follow Arrange/Act/Assert pattern - ✅ Use fixtures for setup and teardown - ✅ Use parametrize for multiple similar test cases - ✅ Use marks to categorize tests (slow, integration, etc.) - ✅ Mock external dependencies (APIs, databases) - ✅ Test both success and failure cases - ✅ Test edge cases (empty, null, boundary values) - ✅ One assertion focus per test (but multiple asserts OK) - ✅ Use `tmp_path` for file operations - ✅ Clean up resources (use fixtures with yield) - ✅ Aim for high coverage (80%+) - ✅ Keep tests independent (no shared state) ## Auto-Apply When writing tests: 1. Use `@pytest.mark.asyncio` for async functions 2. Use `@patch` decorator for mocking 3. Create fixtures for common test data 4. Follow naming conventions (`test_*`) 5. Test success + error + edge cases 6. Use parametrize for multiple inputs 7. Add descriptive docstrings ## Related Skills - async-await-checker - For async test patterns - pydantic-models - For testing models - structured-errors - For testing error responses - pii-redaction - For testing PII handling