Files
dotfiles/skills/PYTEST-ADVANCED.md
Mark Eaton 275425e630 split pytest skill into beginner and advanced versions
PYTEST.md: focused on the core TDD loop (red-green-refactor), simple
assertions, and basic project setup. Suitable for learning TDD.

PYTEST-ADVANCED.md: adds fixtures, Flask/Quart test patterns,
parametrize, and mocking for when the basics are comfortable.
2026-02-23 16:54:13 -05:00

7.1 KiB

Pytest TDD Workflow — Advanced

This skill extends the core PYTEST skill with advanced testing techniques. Use when the basic red-green-refactor cycle is comfortable and you need fixtures, parametrize, mocking, or Flask/Quart test patterns.

All rules from the core PYTEST skill still apply — always follow red-green-refactor, always write the test first.

Instructions

  • Follow the red-green-refactor cycle for every piece of functionality.
  • Use the techniques below when they genuinely simplify or improve tests.
  • Run tests after every change.

1. Fixtures

Use fixtures for setup code that multiple tests share.

Basic fixture

# tests/conftest.py
import pytest

@pytest.fixture
def sample_user():
    return User(name="alice", email="alice@example.com")
# tests/test_models.py
def test_user_display_name(sample_user):
    assert sample_user.display_name == "alice"

Yield fixtures for setup/teardown

@pytest.fixture
def db_session():
    session = create_session()
    yield session
    session.rollback()
    session.close()

Fixture scope

Scope Lifetime Use when
"function" (default) Each test Most cases — tests stay isolated
"class" Each test class Grouping related tests with shared setup
"module" Each test file Expensive setup shared across a file
"session" Entire test run Very expensive one-time setup (e.g. database creation)

Prefer function scope. Only widen scope when setup is genuinely expensive and tests do not mutate shared state.

conftest.py organization

  • tests/conftest.py — fixtures used across the entire test suite
  • tests/api/conftest.py — fixtures specific to API tests
  • Fixtures in a conftest.py are available to all tests in that directory and below

2. Flask/Quart Test Patterns

App factory fixture

# tests/conftest.py
import pytest
from myapp import create_app

@pytest.fixture
def app():
    app = create_app({
        "TESTING": True,
        "SECRET_KEY": "test-secret-key",
    })
    yield app

@pytest.fixture
def client(app):
    return app.test_client()

@pytest.fixture
def runner(app):
    return app.test_cli_runner()

Testing routes

def test_homepage_returns_200(client):
    response = client.get("/")
    assert response.status_code == 200

def test_homepage_contains_title(client):
    response = client.get("/")
    assert b"Welcome" in response.data

Testing POST requests and forms

def test_login_with_valid_credentials(client):
    response = client.post("/login", data={
        "username": "alice",
        "password": "correct-password",
    }, follow_redirects=True)
    assert response.status_code == 200

Testing JSON APIs

def test_api_returns_user_list(client):
    response = client.get("/api/users")
    assert response.status_code == 200
    data = response.get_json()
    assert isinstance(data, list)

Testing with application context

def test_db_query_within_app_context(app):
    with app.app_context():
        users = User.query.all()
        assert len(users) >= 0

Testing with request context

def test_url_generation(app):
    with app.test_request_context():
        assert url_for("index") == "/"

Quart async tests

For Quart applications, use pytest-asyncio:

import pytest

@pytest.fixture
def app():
    app = create_app({"TESTING": True})
    return app

@pytest.fixture
def client(app):
    return app.test_client()

@pytest.mark.asyncio
async def test_homepage(client):
    response = await client.get("/")
    assert response.status_code == 200

3. Parametrize

Use @pytest.mark.parametrize to test multiple inputs without duplicating test functions.

Basic parametrize

@pytest.mark.parametrize("input_val, expected", [
    ("alice", True),
    ("", False),
    ("a" * 256, False),
    ("bob@", False),
])
def test_is_valid_username(input_val, expected):
    assert is_valid_username(input_val) == expected

Testing edge cases systematically

Think about these categories for each function:

  • Valid inputs — normal cases that should succeed
  • Boundary values — empty strings, zero, max length, off-by-one
  • Invalid inputs — wrong types, malformed data, None
  • Error cases — inputs that should raise specific exceptions
@pytest.mark.parametrize("amount, expected", [
    (100, "$1.00"),       # normal
    (0, "$0.00"),         # zero
    (1, "$0.01"),         # minimum
    (99999, "$999.99"),   # large value
])
def test_format_currency(amount, expected):
    assert format_currency(amount) == expected

@pytest.mark.parametrize("amount", [-1, -100])
def test_format_currency_rejects_negative(amount):
    with pytest.raises(ValueError):
        format_currency(amount)

4. Mocking

When to mock

  • Mock: external services (APIs, email, payment), system clock, file system operations, slow or nondeterministic operations.
  • Don't mock: your own code under test, data structures, pure functions. Prefer real objects when practical.

monkeypatch (preferred for simple cases)

def test_get_api_data(monkeypatch):
    def mock_get(url):
        return MockResponse(json_data={"result": "ok"})

    monkeypatch.setattr("myapp.services.requests.get", mock_get)
    data = get_api_data()
    assert data["result"] == "ok"

monkeypatch for environment variables

def test_config_reads_env(monkeypatch):
    monkeypatch.setenv("SECRET_KEY", "test-key")
    config = load_config()
    assert config.secret_key == "test-key"

unittest.mock for complex cases

from unittest.mock import patch, MagicMock

@patch("myapp.services.send_email")
def test_registration_sends_welcome_email(mock_send):
    register_user("alice@example.com")
    mock_send.assert_called_once_with(
        to="alice@example.com",
        subject="Welcome",
    )

5. Test Quality Checklist

After completing a set of tests, review against this checklist:

  • Every public function/route has at least one test.
  • Tests are isolated — no test depends on another test's state or execution order.
  • No hardcoded paths or ports — use fixtures and configuration.
  • Assertions are specific — test exact values, not just truthiness (assert x == 5, not assert x).
  • Error paths are tested — not just the happy path.
  • Test names describe behavior — someone reading only test names understands what the code does.
  • Fixtures are minimal — each fixture sets up only what is needed, no "god fixtures" with everything.
  • No logic in tests — tests should not contain if, for, or try/except. Each test is a straight line.
  • Mocks are focused — only mock what is necessary. Over-mocking makes tests brittle.
  • Tests run fast — the full suite completes in seconds. Slow tests indicate missing mocks or unnecessary I/O.

Running with coverage

uv add --dev pytest-cov
pytest --cov=src --cov-report=term-missing -v

Report untested lines and suggest tests for them.