Skip to content
Back to skills

Pytest Patterns

ASecurity

Quality testing patterns with pytest for FastAPI projects. **TDD-first approach**.

  • 4 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added May 26, 2026
developmentpythongobashsqlfastapiawstestinggitapidatabase

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 5 files and shows the line behind each finding

Scanned May 27, 2026

npx -y skills add ruslan-korneev/claude-plugins --skill pytest-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Pytest Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Pytest Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/ruslan-korneev-pytest-patterns/badge)](https://www.skillsdirectory.com/skills/ruslan-korneev-pytest-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
# Pytest Patterns

Quality testing patterns with pytest for FastAPI projects. **TDD-first approach**.

## Triggers

Use this skill when the user:
- Wants to write tests
- Asks about pytest, fixtures, mocks
- Wants to set up testing for FastAPI
- Uses TDD approach

## Main Principle: TDD

1. **Red** — write a failing test
2. **Green** — write minimal code
3. **Refactor** — improve while keeping tests green

## What Makes a Good Test

### 1. Clear Name

```python
# ✅ Good — describes what, when, and expectation
async def test_create_user_with_valid_data_returns_user_with_id():
async def test_create_user_with_duplicate_email_raises_conflict_error():
async def test_get_user_by_id_when_not_exists_raises_not_found():

# ❌ Bad
async def test_create_user():
async def test_user():
async def test_1():
```

### 2. Single Responsibility

```python
# ✅ Good — one test = one scenario
async def test_create_user_returns_user_with_id(service):
    result = await service.create(user_data)
    assert result.id is not None

async def test_create_user_saves_email(service):
    result = await service.create(user_data)
    assert result.email == user_data["email"]

# ❌ Bad — tests multiple things
async def test_create_user(service):
    result = await service.create(user_data)
    assert result.id is not None
    assert result.email == user_data["email"]
    assert result.created_at is not None
    assert await service.get(result.id) == result
```

### 3. Arrange-Act-Assert

```python
async def test_create_order_calculates_total(service):
    # Arrange — prepare data
    items = [
        {"product_id": 1, "quantity": 2, "price": 100},
        {"product_id": 2, "quantity": 1, "price": 50},
    ]

    # Act — execute action
    order = await service.create(items=items)

    # Assert — verify result
    assert order.total == 250
```

### 4. Isolation

Tests should not depend on each other:

```python
# ✅ Good — each test is independent
@pytest.fixture
async def session(engine):
    async with engine.connect() as conn:
        await conn.begin()
        async with AsyncSession(bind=conn) as session:
            yield session
        await conn.rollback()  # Rollback after each test

# ❌ Bad — shared state
_created_user = None

async def test_create_user(service):
    global _created_user
    _created_user = await service.create(data)

async def test_get_user(service):
    user = await service.get(_created_user.id)  # Depends on previous test!
```

## When to Use parametrize

```python
# ✅ Use parametrize for similar checks
@pytest.mark.parametrize("email,is_valid", [
    ("user@example.com", True),
    ("user@subdomain.example.com", True),
    ("invalid", False),
    ("@example.com", False),
    ("user@", False),
])
def test_email_validation(email: str, is_valid: bool):
    assert validate_email(email) == is_valid

# ❌ Do NOT use parametrize for different scenarios
# Better to have separate tests with clear names
async def test_create_user_success(service):
    ...

async def test_create_user_duplicate_email(service):
    ...
```

## Async Testing

### pytest-asyncio Configuration

```toml
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
```

### Async Fixtures

```python
@pytest.fixture
async def session() -> AsyncGenerator[AsyncSession, None]:
    async with async_session_maker() as session:
        yield session

@pytest.fixture
async def client(session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
    async with AsyncClient(transport=ASGITransport(app=app)) as client:
        yield client
```

## FastAPI Testing

More details: `${CLAUDE_PLUGIN_ROOT}/skills/pytest-patterns/references/fastapi-testing.md`

### Testing Endpoints

```python
from httpx import ASGITransport, AsyncClient

async def test_create_user_endpoint(client: AsyncClient):
    response = await client.post(
        "/api/v1/users",
        json={"email": "test@example.com", "name": "Test"},
    )

    assert response.status_code == 201
    assert response.json()["id"] is not None
```

### Testing with DI Override

```python
@pytest.fixture
async def client(session: AsyncSession):
    def override_session():
        yield session

    app.dependency_overrides[get_session] = override_session
    async with AsyncClient(transport=ASGITransport(app=app)) as client:
        yield client
    app.dependency_overrides.clear()
```

## Fixtures for FastAPI

More details: `${CLAUDE_PLUGIN_ROOT}/skills/pytest-patterns/references/fixtures.md`

### Basic

```python
# tests/conftest.py
@pytest.fixture(scope="session")
def engine():
    return create_async_engine(TEST_DATABASE_URL)

@pytest.fixture
async def session(engine) -> AsyncGenerator[AsyncSession, None]:
    async with engine.connect() as conn:
        await conn.begin()
        session = AsyncSession(bind=conn)
        yield session
        await conn.rollback()

@pytest.fixture
async def client(session) -> AsyncGenerator[AsyncClient, None]:
    app.dependency_overrides[get_session] = lambda: session
    async with AsyncClient(transport=ASGITransport(app=app)) as c:
        yield c
    app.dependency_overrides.clear()
```

## Test Database (IMPORTANT!)

**Use real database connection, not mocks.** Test database is created specifically for tests.

### Why Real DB, Not Mocks

- ✅ Test real SQL behavior
- ✅ Find migration issues
- ✅ Check constraints, indexes, triggers
- ✅ Confidence in production

```python
# ✅ Good — real test database
@pytest.fixture(scope="session")
def engine():
    return create_async_engine("postgresql+asyncpg://test:test@localhost:5432/test_db")

@pytest.fixture
async def session(engine) -> AsyncGenerator[AsyncSession, None]:
    async with engine.connect() as conn:
        await conn.begin()
        async with AsyncSession(bind=conn) as session:
            yield session
        await conn.rollback()  # Isolation via transactions

# ❌ Bad — mock database
@pytest.fixture
def mock_session():
    return AsyncMock(spec=AsyncSession)  # Doesn't test real SQL!
```

### Parallel Tests (pytest-xdist)

Each worker gets its own DB: `gw{N}_test_{dbname}`

```python
def get_test_db_name(worker_id: str, base_name: str = "myapp") -> str:
    """gw0 → gw0_test_myapp, master → test_myapp"""
    if worker_id == "master":
        return f"test_{base_name}"
    return f"{worker_id}_test_{base_name}"

@pytest.fixture(scope="session")
def database_url(worker_id: str) -> str:
    db_name = get_test_db_name(worker_id)
    return f"postgresql+asyncpg://test:test@localhost:5432/{db_name}"
```

More details: `${CLAUDE_PLUGIN_ROOT}/skills/pytest-patterns/references/fixtures.md`

## Mocks — Only for External Services

More details: `${CLAUDE_PLUGIN_ROOT}/skills/pytest-patterns/references/mocking.md`

### When to Mock

- ✅ External APIs (email, SMS, payments, third-party)
- ✅ HTTP requests to external services
- ✅ File system (if not critical)
- ✅ Time (`datetime.now()`), random, uuid
- ❌ **Database** — use test database!
- ❌ Code under test
- ❌ Internal dependencies (repositories, services)

### AsyncMock for External Services

```python
from unittest.mock import AsyncMock

@pytest.fixture
def mock_email_service():
    """Mock EXTERNAL email service (SendGrid, AWS SES)."""
    mock = AsyncMock()
    mock.send.return_value = True
    return mock

@pytest.fixture
def mock_payment_gateway():
    """Mock EXTERNAL payment gateway."""
    mock = AsyncMock()
    mock.charge.return_value = {"status": "success", "transaction_id": "123"}
    return mock
```

## Coverage: Branch Coverage Matters More Than Line Coverage

**Branch coverage** — one of the most valuable metrics, not just line coverage.

### Why Branch Coverage

```python
def process(value: int | None) -> str:
    if value is not None:
        return f"Value: {value}"
    return "No value"

# Line coverage 100% with one test:
def test_process():
    assert process(42) == "Value: 42"
    # But branch `return "No value"` is NOT tested!

# Branch coverage requires both paths:
def test_process_with_value():
    assert process(42) == "Value: 42"

def test_process_with_none():
    assert process(None) == "No value"
```

### Coverage Configuration

```toml
# pyproject.toml
[tool.coverage.run]
source = ["src"]
branch = true  # IMPORTANT: enable branch coverage

[tool.coverage.report]
fail_under = 80
show_missing = true
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
]
```

### Running with Branch Coverage

```bash
# Run tests with coverage
pytest --cov=src --cov-branch --cov-report=term-missing

# HTML report for detailed analysis
pytest --cov=src --cov-branch --cov-report=html
# Open htmlcov/index.html — shows uncovered branches
```

## Plugin Commands

- `/test:first <feature>` — create test BEFORE implementation (TDD)
- `/test:fixture <name>` — create a fixture
- `/test:mock <dependency>` — create a mock
- Agent `test-reviewer` — coverage and quality analysis

Files in this skill

  • SKILL.md8.9 KB
  • examples/conftest.py7.5 KB
  • references/fastapi-testing.md9.8 KB
  • references/fixtures.md11.8 KB
  • references/mocking.md9.1 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.