# Pytest Patterns

> Pytest testing patterns, anti-patterns, and quality rules for Python test files (test_*.py, *_test.py, conftest.py). Includes deterministic test-smell detector (pytest_smell.py) for no-assertion tests, empty tests, bare excepts, magic numbers, sleep calls, and print calls. Covers fixtures, parametrize, mock, patch, and assert patterns. Use proactively when reviewing or writing pytest test files, diagnosing flaky or unreliable Python test suites, code-reviewing test files, or running a CI quality gate on test file hygiene. Run the smell detector for deterministic issue detection.

- Skill: `everyone-needs-a-copilot/pytest-patterns` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add everyone-needs-a-copilot/pytest-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/everyone-needs-a-copilot/pytest-patterns/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Everyone-Needs-A-Copilot (https://skillmd.com/u/everyone-needs-a-copilot)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/everyone-needs-a-copilot/pytest-patterns

---


# Pytest Patterns

Modern pytest testing patterns, anti-patterns, and quality rules for Python.

pytest_smell.py detects structural test smells deterministically. The prose sections below cover judgment-level guidance that the script cannot evaluate. Never re-derive smell detection by eye when the script can do it; always run the script for consistent, auditable findings.

## Core Principles

| Principle | Description |
|-----------|-------------|
| **Fixtures over setup** | Use fixtures for test dependencies |
| **Parametrize** | Test multiple cases without duplication |
| **Explicit is better** | Clear test names and assertions |
| **Fast isolation** | Each test runs independently |

## Patterns vs Anti-Patterns

### Fixtures

```python
# GOOD: Fixtures for test dependencies
@pytest.fixture
def db_session():
    """Provide a clean database session."""
    session = create_session()
    yield session
    session.rollback()
    session.close()

@pytest.fixture
def sample_user(db_session):
    """Create a sample user for tests."""
    user = User(name="John", email="john@example.com")
    db_session.add(user)
    db_session.commit()
    return user

def test_get_user(db_session, sample_user):
    result = get_user_by_id(db_session, sample_user.id)
    assert result.name == "John"

# BAD: Setup in each test
def test_get_user():
    session = create_session()
    user = User(name="John", email="john@example.com")
    session.add(user)
    session.commit()
    # ... test code
    session.rollback()  # Easy to forget cleanup!
```

### Fixture Scopes

```python
# GOOD: Appropriate fixture scopes
@pytest.fixture(scope="session")
def docker_db():
    """Start database container once per test session."""
    container = start_postgres_container()
    yield container
    container.stop()

@pytest.fixture(scope="module")
def api_client():
    """Create API client once per test module."""
    return APIClient(base_url="http://test.local")

@pytest.fixture  # Default: function scope
def clean_data(db_session):
    """Fresh data for each test."""
    yield
    db_session.query(User).delete()

# BAD: Wrong scope causes test pollution
@pytest.fixture(scope="session")  # DANGER!
def user():
    return User(name="John")  # Shared across all tests!
```

### Parametrization

```python
# GOOD: Parametrize for multiple cases
@pytest.mark.parametrize("email,valid", [
    ("user@example.com", True),
    ("user.name@example.co.uk", True),
    ("invalid-email", False),
    ("@missing.local", False),
    ("", False),
])
def test_email_validation(email, valid):
    assert validate_email(email) == valid

# GOOD: Multiple parameters with IDs
@pytest.mark.parametrize(
    "input_data,expected",
    [
        pytest.param({"name": "John"}, True, id="valid-name"),
        pytest.param({"name": ""}, False, id="empty-name"),
        pytest.param({}, False, id="missing-name"),
    ]
)
def test_user_validation(input_data, expected):
    assert is_valid_user(input_data) == expected

# BAD: Repeated test code
def test_valid_email():
    assert validate_email("user@example.com") is True

def test_valid_email_with_subdomain():
    assert validate_email("user@sub.example.com") is True

def test_invalid_email():
    assert validate_email("invalid") is False
# ... 10 more nearly identical tests
```

### Mocking

A mock-call assertion (`.assert_called_*`) is only a valid substitute for a real assertion at an OUTBOUND boundary the test owns — an HTTP request the code constructs, an event published to an external system — where the call itself IS the observable. It is never a substitute for observing a write path's actual effect; see "Overuse of mocking" below and the write-path rule in `qa.md`.

```python
# GOOD: Outbound HTTP call — the request IS the observable
def test_api_call(mocker):
    mock_get = mocker.patch("requests.get")
    mock_get.return_value.json.return_value = {"data": "test"}

    result = fetch_data("http://api.example.com")

    assert result["data"] == "test"
    mock_get.assert_called_once_with("http://api.example.com")

# GOOD: Context manager for scoped patches
def test_with_timeout(mocker):
    with mocker.patch("time.sleep"):
        result = operation_with_retry()
        assert result is not None

# GOOD: Spy on an outbound side effect (logging) — not a stand-in for verifying a persisted write
def test_logs_error(mocker):
    spy = mocker.spy(logger, "error")

    process_invalid_data()

    spy.assert_called_once()

# BAD: Overuse of mocking — note module.db.save mocked on what should be a write-path test;
# that write should hit a real or in-memory database instead (see qa.md Test Double Taxonomy)
def test_save_user(mocker):
    mocker.patch("module.validate")
    mocker.patch("module.normalize")
    mocker.patch("module.db.save")
    mocker.patch("module.cache.invalidate")
    mocker.patch("module.events.publish")
    # Testing nothing but mocks!
```

### Assertions

```python
# GOOD: Clear, specific assertions
def test_user_creation():
    user = create_user("John", "john@example.com")

    assert user.name == "John"
    assert user.email == "john@example.com"
    assert user.id is not None

# GOOD: Use pytest.raises for exceptions
def test_invalid_email_raises():
    with pytest.raises(ValidationError) as exc_info:
        create_user("John", "invalid-email")

    assert "email" in str(exc_info.value)
    assert exc_info.value.field == "email"

# GOOD: Approximate comparisons
def test_calculation():
    result = complex_calculation()
    assert result == pytest.approx(3.14159, rel=1e-5)

# BAD: Vague assertions
def test_result():
    result = process()
    assert result  # What should it be?
    assert result is not None  # Still unclear

# BAD: Exception testing without context
def test_raises():
    try:
        risky_operation()
        assert False, "Should have raised"
    except Exception:
        pass  # What exception? What message?
```

### Test Organization

```python
# GOOD: Class-based grouping for related tests
class TestUserService:
    """Tests for UserService class."""

    @pytest.fixture(autouse=True)
    def setup(self, db_session):
        self.service = UserService(db_session)
        self.db = db_session

    def test_create_user(self):
        user = self.service.create("John", "john@example.com")
        assert user.id is not None

    def test_create_duplicate_raises(self, sample_user):
        with pytest.raises(DuplicateError):
            self.service.create(sample_user.name, sample_user.email)

# GOOD: conftest.py for shared fixtures
# conftest.py
@pytest.fixture
def app():
    """Create test application."""
    return create_app(testing=True)

@pytest.fixture
def client(app):
    """Create test client."""
    return app.test_client()
```

## Anti-Patterns to Avoid

### Test Pollution

```python
# BAD: Shared mutable state
users = []  # Module-level! Shared across tests!

def test_add_user():
    users.append(User("John"))
    assert len(users) == 1  # May fail if other test ran first!

# GOOD: Fresh state per test
@pytest.fixture
def users():
    return []

def test_add_user(users):
    users.append(User("John"))
    assert len(users) == 1  # Always passes
```

### Slow Tests

```python
# BAD: Real network calls
def test_fetch_data():
    result = requests.get("https://api.example.com/data")  # Slow, flaky
    assert result.status_code == 200

# GOOD: Mock external services
def test_fetch_data(mocker):
    mocker.patch("requests.get").return_value.status_code = 200
    result = fetch_external_data()
    assert result is not None

# BAD: Unnecessary sleep
def test_async_operation():
    start_operation()
    time.sleep(5)  # Why?
    assert is_complete()

# GOOD: Poll or use async waiting
def test_async_operation():
    start_operation()
    wait_for(is_complete, timeout=5)
    assert is_complete()
```

### Assertion in Loop

```python
# BAD: Loop hides failures
def test_all_users_valid():
    users = get_all_users()
    for user in users:
        assert user.is_valid()  # Which one failed?

# GOOD: Clear failure messages
def test_all_users_valid():
    users = get_all_users()
    invalid = [u for u in users if not u.is_valid()]
    assert not invalid, f"Invalid users: {invalid}"

# GOOD: Parametrize instead
@pytest.mark.parametrize("user_id", [1, 2, 3, 4, 5])
def test_user_valid(user_id, db_session):
    user = get_user(db_session, user_id)
    assert user.is_valid()
```

### Testing Implementation

```python
# BAD: Testing internal state
def test_cache_internals():
    cache = Cache()
    cache.set("key", "value")
    assert cache._internal_dict["key"] == "value"  # Private!

# GOOD: Test public behavior
def test_cache_retrieval():
    cache = Cache()
    cache.set("key", "value")
    assert cache.get("key") == "value"

# BAD: Testing order of operations
def test_save_calls_validate(mocker):
    validate = mocker.patch("module.validate")
    save("data")
    validate.assert_called_before(save)  # Implementation detail!

# GOOD: Test the outcome
def test_save_validates_data():
    with pytest.raises(ValidationError):
        save(invalid_data)
```

## Configuration Best Practices

```ini
# pytest.ini or pyproject.toml [tool.pytest.ini_options]
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
addopts = -v --tb=short --strict-markers
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
    integration: marks tests as integration tests
filterwarnings =
    error
    ignore::DeprecationWarning
```

```python
# pyproject.toml
[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-ra -q --strict-markers"
testpaths = ["tests"]

[tool.coverage.run]
source = ["src"]
branch = true

[tool.coverage.report]
fail_under = 80
```

## Quality Checklist

| Check | Rule |
|-------|------|
| Fixtures | Use fixtures over inline setup |
| Parametrize | One test function for similar cases |
| Clear assertions | Specific comparisons, not just truthiness |
| Exception testing | Use pytest.raises with message checks |
| No shared state | Each test is independent |
| Fast execution | Mock external services |
| Descriptive names | test_<action>_<expected> format |
| Proper scopes | Match fixture scope to usage |

---

## Invocation — Test Smell Detector (L3 Script)

After reviewing or writing test files, run the smell detector to get a structured, auditable finding list. Consume the script's **output only** — the script source never enters context.

**Smells detected:**

| ID | Name | Severity | Rule |
|----|------|----------|------|
| SMELL-01 | no_assert | ERROR | Test has no assertion — can never fail |
| SMELL-02 | bare_except | WARN | Bare `except:` / `except Exception:` without re-raise |
| SMELL-03 | test_naming | ERROR | Test function in Test* class doesn't start with `test_` |
| SMELL-04 | magic_number | WARN | Numeric literal ≥ 1000 inside an assert |
| SMELL-05 | empty_test | ERROR | Body is only `pass` or a docstring |
| SMELL-06 | sleep_in_test | WARN | `time.sleep()` call inside a test |
| SMELL-07 | print_in_test | WARN | `print()` call inside a test |
| SMELL-08 | mock_only_assertions | WARN | Scoped to DB-SESSION mocks only (verb `add`/`add_all`/`commit`/`flush`/`refresh`/`execute`/`delete`/`merge`/`begin`/`scalar`/`scalars`, or mock variable name `db`/`session`/`sess`/`conn`/`connection`) — fires when every assertion is either (a) a positive session-mock-call verification or (b) a tautological read-back (an attribute of a `SimpleNamespace`/`Mock`/`MagicMock`/`AsyncMock`/dict/`@dataclass` object the test built inline, compared to a literal it also supplied, or a round-trip identity check on that same object). Does not fire when all session-mock assertions are negative, or when any non-tautological real assertion or non-session mock-call verification (HTTP client, logger, event publisher) is also present — those block firing exactly like a real assertion. Known limitation: name/verb heuristic, not type-aware — an unconventionally-named session mock asserted via a non-verb method is missed (under-fires, by design); tautology detection is single-function AST-local dataflow, not cross-file — advisory only, review findings by hand |

| SMELL-09 | bare_mock_truthiness | WARN | `assert <chain>.called` or `assert <chain>.call_count` used as a bare (uncompared, non-negated) truthiness check on any mock — added specifically because this exact form is the evasion QA found: it evades SMELL-08 too, since a bare `.called`/`.call_count` read is not an `ast.Compare` node and is classified as a real assertion by SMELL-08's classifier. `.called`/`.call_count` do correctly track whether the mock was invoked (they are not always-true) — the defect is evidentiary weakness: no argument-shape or exact-count verification, easy to misread as equivalent to `.assert_called_once()`. `assert not x.called` (the negative form, legitimate equivalent of `.assert_not_called()`) is not flagged. Independent of SMELL-08 — applies to any mock, not just DB sessions |

**SMELL-08 recall limitation — read before trusting a clean run.** "Under-fires, by design" above understates how narrow this rule's recall actually is; the honest reading of a clean SMELL-08 run is "none of the most blatant fully-vacuous DB-mock tests were found," NOT "no vacuous write-path tests exist here." Measured facts, not estimates: against fully-vacuous tests (a test whose *only* assertions are positive DB-session mock-call verifications and/or tautological read-backs — the exact class this rule targets), detection is effectively complete. Against the broader population of tests that contain a positive DB-session mock assertion at all, the rule flags roughly 11% of them — not because the other ~89% are missed defects, but because most of those tests also carry a genuine assertion on a production-computed value alongside the mock-call check, and a single real assertion correctly blocks firing (see E1/E2 above). The 11% is the rule correctly declining to fire on legitimate tests, not a coverage gap. Evasion is trivial and requires no adversarial intent: in a hand-written adversarial check, 5 realistic vacuous write-path tests were constructed and SMELL-08 caught 0 of 5 — evasions included (1) a fixture-provided session bound to a variable name off the heuristic list, (2) the mock-call assertion wrapped inside a helper function rather than inlined, (3) a parametrized test using an unconventional session variable name, (4) a session obtained through an async context manager, and (5) `assert mock_db.commit.called` — bare attribute-access truthiness on a mock instead of `.assert_called_once()` — which evaded every smell rule in this file silently, not just SMELL-08. Ordinary naming variance and normal helper-extraction refactors defeat this rule; treat SMELL-08 as advisory only. Never use a clean run as a blocking CI gate, and never treat it as evidence that a test file has no vacuous write-path tests.

**Run via Bash (single file):**
```bash
python .claude/skills/testing/pytest-patterns/scripts/pytest_smell.py path/to/test_file.py
```

**Run via Bash (directory — walks all test_*.py / *_test.py recursively):**
```bash
python .claude/skills/testing/pytest-patterns/scripts/pytest_smell.py tests/
```

**Run via Bash (stdin):**
```bash
cat test_example.py | python .claude/skills/testing/pytest-patterns/scripts/pytest_smell.py -
```

**Output fields (JSON):**
```json
{
  "findings": [
    {
      "smell_id": "SMELL-01",
      "name": "no_assert",
      "severity": "ERROR",
      "message": "Test 'test_foo' has no assertion — it can never fail",
      "file": "tests/test_foo.py",
      "line": 10,
      "function": "test_foo"
    }
  ],
  "summary": {
    "total": 3,
    "error": 2,
    "warn": 1,
    "files_analyzed": 1
  }
}
```

**What the agent does with the output:**
1. Address all `ERROR` severity findings first — these are broken/useless tests.
2. Review `WARN` severity findings — these are flaky/noisy patterns; fix unless there is a documented reason.
3. Use `summary.error` count in any CI gate decision.

**Error handling:** Invalid file path → exits 1 with `ERROR:` message on stderr. Syntax error in a test file → reported as a `PARSE-ERROR` finding (not a crash). Empty input → exits 0 with zero findings.

