AgentSkillsCN

python-testing-guidelines

pytest模式和最佳实践

SKILL.md
--- frontmatter
name: python-testing-guidelines
description: pytest patterns and best practices
version: 1.0.0
triggers:
  - pytest
  - python test
  - test fixture
  - mock
  - unittest
  - coverage

Python Testing Guidelines

Overview

This skill provides patterns for testing Python applications with pytest, including fixtures, mocking, async testing, and test organization.

Quick Reference

PatternWhen to UseExample
FixtureReusable test setup@pytest.fixture
ParametrizeTest multiple inputs@pytest.mark.parametrize
MockReplace dependenciesmocker.patch()
Async TestTest async code@pytest.mark.asyncio

Project Configuration

toml
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "-v --tb=short"
filterwarnings = ["ignore::DeprecationWarning"]

[tool.coverage.run]
source = ["app"]
omit = ["*/tests/*", "*/__init__.py"]

Core Patterns

Pattern 1: Test Structure (AAA)

python
# ✅ CORRECT: Arrange-Act-Assert pattern
import pytest
from app.services.user_service import UserService

class TestUserService:
    """Tests for UserService."""

    async def test_create_user_with_valid_data(self, user_service, mock_repo):
        """Should create user when data is valid."""
        # Arrange
        user_data = {"email": "test@example.com", "name": "Test User"}
        mock_repo.get_by_email.return_value = None
        mock_repo.create.return_value = User(id=1, **user_data)

        # Act
        result = await user_service.create(user_data)

        # Assert
        assert result.email == "test@example.com"
        assert result.name == "Test User"
        mock_repo.create.assert_called_once()

    async def test_create_user_raises_on_duplicate_email(self, user_service, mock_repo):
        """Should raise ConflictError when email exists."""
        # Arrange
        mock_repo.get_by_email.return_value = User(id=1, email="test@example.com")

        # Act & Assert
        with pytest.raises(ConflictError, match="Email already registered"):
            await user_service.create({"email": "test@example.com", "name": "Test"})

Pattern 2: Fixtures

python
# conftest.py - Shared fixtures
import pytest
from unittest.mock import AsyncMock, MagicMock
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from app.models import Base
from app.services.user_service import UserService
from app.repositories.user_repository import UserRepository

# Database fixtures
@pytest.fixture
async def db_engine():
    """Create test database engine."""
    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()

@pytest.fixture
async def db_session(db_engine):
    """Create test database session."""
    async with AsyncSession(db_engine) as session:
        yield session

# Repository fixtures
@pytest.fixture
def mock_user_repo():
    """Mock user repository."""
    repo = AsyncMock(spec=UserRepository)
    repo.get_by_id.return_value = None
    repo.get_by_email.return_value = None
    return repo

# Service fixtures
@pytest.fixture
def user_service(mock_user_repo):
    """User service with mocked repository."""
    return UserService(mock_user_repo)

# Test data fixtures
@pytest.fixture
def sample_user():
    """Sample user data."""
    return {
        "email": "test@example.com",
        "name": "Test User",
        "password": "securepassword123"
    }

Pattern 3: Parametrized Tests

python
# ✅ CORRECT: Parametrized tests for multiple scenarios
import pytest

@pytest.mark.parametrize("email,expected_valid", [
    ("user@example.com", True),
    ("user@subdomain.example.com", True),
    ("user+tag@example.com", True),
    ("invalid-email", False),
    ("@example.com", False),
    ("user@", False),
    ("", False),
])
def test_email_validation(email: str, expected_valid: bool):
    """Should validate email format correctly."""
    result = is_valid_email(email)
    assert result == expected_valid

@pytest.mark.parametrize("password,expected_errors", [
    ("short", ["Password must be at least 8 characters"]),
    ("nodigits!", ["Password must contain a digit"]),
    ("NOLOWER1", ["Password must contain lowercase"]),
    ("ValidPass1!", []),
])
def test_password_validation(password: str, expected_errors: list[str]):
    """Should validate password requirements."""
    errors = validate_password(password)
    assert errors == expected_errors

Pattern 4: Mocking

python
# ✅ CORRECT: Mocking external dependencies
import pytest
from unittest.mock import AsyncMock, patch, MagicMock

class TestEmailService:
    async def test_send_welcome_email(self, mocker):
        """Should send welcome email with correct template."""
        # Mock the email client
        mock_client = AsyncMock()
        mocker.patch("app.services.email_service.email_client", mock_client)

        service = EmailService()
        await service.send_welcome("user@example.com", "John")

        mock_client.send.assert_called_once()
        call_args = mock_client.send.call_args
        assert call_args.kwargs["to"] == "user@example.com"
        assert "Welcome" in call_args.kwargs["subject"]

    async def test_handles_email_failure(self, mocker):
        """Should handle email sending failure gracefully."""
        mock_client = AsyncMock()
        mock_client.send.side_effect = EmailError("Connection failed")
        mocker.patch("app.services.email_service.email_client", mock_client)

        service = EmailService()
        # Should not raise, just log
        await service.send_welcome("user@example.com", "John")

# Context manager for patching
class TestExternalAPI:
    async def test_fetch_data(self):
        """Should fetch and transform external data."""
        mock_response = {"data": [{"id": 1, "name": "Item"}]}

        with patch("httpx.AsyncClient.get") as mock_get:
            mock_get.return_value = MagicMock(
                status_code=200,
                json=lambda: mock_response
            )
            result = await fetch_external_data()

        assert len(result) == 1
        assert result[0]["name"] == "Item"

Pattern 5: Async Testing

python
# ✅ CORRECT: Async test patterns
import pytest
from httpx import AsyncClient
from app.main import app

# Using pytest-asyncio
@pytest.mark.asyncio
async def test_async_function():
    """Test async function directly."""
    result = await async_operation()
    assert result == expected_value

# Integration test with async client
@pytest.fixture
async def async_client():
    """Async HTTP client for testing."""
    async with AsyncClient(app=app, base_url="http://test") as client:
        yield client

class TestAPI:
    async def test_create_user_endpoint(self, async_client, db_session):
        """Should create user via API."""
        response = await async_client.post(
            "/api/users",
            json={"email": "test@example.com", "name": "Test", "password": "pass1234"}
        )

        assert response.status_code == 201
        data = response.json()
        assert data["email"] == "test@example.com"

    async def test_get_user_not_found(self, async_client):
        """Should return 404 for non-existent user."""
        response = await async_client.get("/api/users/999")
        assert response.status_code == 404

Pattern 6: Test Organization

code
tests/
├── conftest.py              # Shared fixtures
├── unit/                    # Unit tests
│   ├── services/
│   │   ├── test_user_service.py
│   │   └── test_email_service.py
│   └── utils/
│       └── test_validators.py
├── integration/             # Integration tests
│   ├── test_api_users.py
│   └── test_api_posts.py
└── e2e/                     # End-to-end tests
    └── test_user_flow.py

Anti-Patterns

Don't: Test Implementation Details

python
# ❌ BAD: Testing internal implementation
def test_user_service_calls_repo_twice():
    await service.create(data)
    assert mock_repo.get_by_email.call_count == 1
    assert mock_repo.create.call_count == 1  # Brittle!

# ✅ GOOD: Test behavior/outcome
def test_user_service_creates_user():
    result = await service.create(data)
    assert result.email == data["email"]

Don't: Share Mutable State

python
# ❌ BAD: Mutable fixture state
@pytest.fixture
def users():
    return []  # Same list shared across tests!

# ✅ GOOD: Factory fixture
@pytest.fixture
def users():
    return []  # Fresh list for each test (if scope="function")

Don't: Ignore Test Isolation

python
# ❌ BAD: Tests depend on order
class TestUser:
    user_id = None

    def test_create(self):
        TestUser.user_id = create_user().id

    def test_get(self):
        get_user(TestUser.user_id)  # Fails if test_create didn't run!

Useful pytest Markers

python
@pytest.mark.skip(reason="Not implemented yet")
@pytest.mark.skipif(sys.platform == "win32", reason="Unix only")
@pytest.mark.xfail(reason="Known bug #123")
@pytest.mark.slow  # Custom marker for slow tests
@pytest.mark.integration  # Custom marker

Resources

TopicLink
Fixtures[mdc:resources/fixtures.md]
Mocking[mdc:resources/mocking.md]
Async Testing[mdc:resources/async-testing.md]
Coverage[mdc:resources/coverage.md]