Files
2025-11-30 08:51:46 +08:00

13 KiB

name, description
name description
pytest-patterns 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

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

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

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

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

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

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

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)

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

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

# 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

# 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

# ❌ 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
  • async-await-checker - For async test patterns
  • pydantic-models - For testing models
  • structured-errors - For testing error responses
  • pii-redaction - For testing PII handling