# Python Testing

> Write and improve Python tests using pytest. Use when adding tests to a project, fixing broken tests, improving test coverage, setting up async tests, or asking how to test a specific pattern (HTTP endpoints, database, mocking, etc.).

- Skill: `lwaetzig/python-testing` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lwaetzig/python-testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lwaetzig/python-testing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: LWaetzig (https://skillmd.com/u/lwaetzig)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lwaetzig/python-testing

---


# Python Testing Skill

Write clear, fast, trustworthy tests. Tests are code — they should be readable, maintainable, and test behaviour not implementation.

---

## Step 1: Read the Code Under Test

Before writing any tests:
1. Read the function/module to understand its contract (inputs, outputs, side effects)
2. Check for existing tests — mimic the file structure and naming conventions in use
3. Check `pyproject.toml` or `setup.cfg` for pytest config, markers, and plugins already installed

---

## Step 2: Choose the Right Test Type

| Scenario | Approach |
|---|---|
| Pure functions | Unit test with `assert` |
| Code with I/O / external dependencies | Unit test + mock the boundary |
| FastAPI / Flask / Django endpoints | Integration test with `TestClient` / `AsyncClient` |
| Database-backed code | Integration test against real DB (use fixtures for setup/teardown) |
| Complex workflows | End-to-end test, sparingly |

**Default to the smallest test type that gives you confidence.** Don't mock what you can test cheaply for real.

---

## Step 3: Test Structure

Use the **Arrange / Act / Assert** pattern. One logical assertion per test.

```python
def test_discount_applied_to_eligible_order():
    # Arrange
    order = Order(items=[Item(price=100)], customer_tier="gold")

    # Act
    total = calculate_total(order)

    # Assert
    assert total == 90  # 10% gold discount
```

### File and function naming

- Test files: `test_<module>.py`
- Test functions: `test_<what>_<when/condition>_<expected>`
- Keep names honest — `test_returns_none` should return None, not raise

---

## Core Patterns

### Fixtures

```python
import pytest

@pytest.fixture
def user():
    return User(id=1, name="Ada", email="ada@example.com")

@pytest.fixture
def db(tmp_path):
    """Isolated SQLite DB per test."""
    engine = create_engine(f"sqlite:///{tmp_path}/test.db")
    Base.metadata.create_all(engine)
    with Session(engine) as session:
        yield session

# Reuse fixtures across files with conftest.py
```

### Parametrize

```python
@pytest.mark.parametrize("email,valid", [
    ("user@example.com", True),
    ("not-an-email", False),
    ("", False),
    ("user@", False),
])
def test_email_validation(email, valid):
    assert validate_email(email) == valid
```

### Mocking

```python
from unittest.mock import patch, MagicMock

def test_sends_welcome_email(mock_email_client):
    with patch("myapp.services.email_client") as mock_client:
        register_user("ada@example.com")
        mock_client.send.assert_called_once_with(
            to="ada@example.com",
            template="welcome",
        )

# Or use pytest-mock for cleaner syntax
def test_sends_welcome_email(mocker):
    mock_send = mocker.patch("myapp.services.email_client.send")
    register_user("ada@example.com")
    mock_send.assert_called_once()
```

### Async tests

```python
# Requires: pytest-asyncio
# pyproject.toml: [tool.pytest.ini_options] asyncio_mode = "auto"

import pytest

@pytest.mark.asyncio
async def test_fetch_user():
    async with AsyncClient(app=app, base_url="http://test") as client:
        response = await client.get("/users/1")
    assert response.status_code == 200

# With pytest-asyncio asyncio_mode="auto", mark is optional
async def test_fetch_user_auto():
    result = await fetch_user(user_id=1)
    assert result.name == "Ada"
```

### Testing FastAPI endpoints

```python
from fastapi.testclient import TestClient
from httpx import AsyncClient
import pytest

client = TestClient(app)  # sync — fine for most tests

def test_create_item_returns_201():
    response = client.post("/items/", json={"name": "Gadget", "price": 9.99})
    assert response.status_code == 201
    assert response.json()["name"] == "Gadget"

def test_get_item_not_found_returns_404():
    response = client.get("/items/99999")
    assert response.status_code == 404

# Override dependencies for isolation
def override_get_db():
    yield test_db_session

app.dependency_overrides[get_db] = override_get_db
```

### Testing exceptions

```python
import pytest

def test_raises_on_invalid_input():
    with pytest.raises(ValueError, match="must be positive"):
        compute_score(-1)

def test_raises_http_exception():
    response = client.get("/users/0")
    assert response.status_code == 422  # FastAPI validation error
```

---

## Fixtures Best Practices

- **Keep fixtures focused** — one fixture does one thing
- **Use `yield` fixtures** for setup/teardown (replaces `setup_method`)
- **Scope appropriately**: `function` (default), `module`, `session`
  - `session`-scoped DB migrations, `function`-scoped transactions (rollback after each test)
- **Put shared fixtures in `conftest.py`** — pytest auto-discovers it

```python
# conftest.py
@pytest.fixture(scope="session")
def engine():
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    yield engine
    engine.dispose()

@pytest.fixture
def db(engine):
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    yield session
    session.close()
    transaction.rollback()
    connection.close()
```

---

## Markers and Configuration

```toml
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
markers = [
    "slow: marks tests as slow (deselect with -m 'not slow')",
    "integration: marks integration tests",
]
```

```python
@pytest.mark.slow
@pytest.mark.integration
def test_full_order_pipeline():
    ...
```

Run subsets: `pytest -m "not slow"`, `pytest -m integration`

---

## Coverage

```bash
pytest --cov=myapp --cov-report=term-missing
pytest --cov=myapp --cov-fail-under=80
```

```toml
# pyproject.toml
[tool.coverage.run]
omit = ["tests/*", "*/migrations/*"]

[tool.coverage.report]
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]
```

**Coverage is a floor, not a target.** 80% with meaningful tests beats 100% with trivial ones.

---

## What NOT to Test

- Framework internals (don't test that FastAPI parses JSON — test your logic)
- Implementation details (test behaviour, not which private method was called)
- Trivial getters/setters
- Code you don't own

---

## Common Gotchas

- **Don't patch where it's defined — patch where it's used**: `patch("myapp.routes.send_email")` not `patch("myapp.email.send_email")`
- **Async fixtures need `pytest-asyncio`** and `async def` fixtures require the right scope
- **`TestClient` runs the app synchronously** — use `AsyncClient` when you need true async behaviour
- **Avoid `time.sleep` in tests** — mock `datetime.now()` or use `freezegun`

