Files
dotfiles/skills/PYTEST.md
T
Mark Eaton a75048290f add comprehensive pytest TDD workflow skill
Guides test-driven development with red-green-refactor cycle,
Flask/Quart test patterns, fixtures, parametrize, mocking,
and a test quality checklist.
2026-02-23 16:45:18 -05:00

11 KiB

Pytest TDD Workflow

Guide development using test-driven development (TDD) with pytest. Always write the test first, watch it fail, then write the minimal code to make it pass. Follow the red-green-refactor cycle for every piece of functionality.

Instructions

  • Never write implementation code before its test exists and fails.
  • Follow the numbered workflow below for each unit of functionality.
  • Run tests after every change — both after writing the failing test and after writing the implementation.
  • If the project lacks pytest configuration or test infrastructure, set it up first (Section 2).
  • Use Flask/Quart testing patterns when working on web applications (Section 7).

1. The Red-Green-Refactor Cycle

Repeat this cycle for every piece of functionality:

Red — Write a failing test

  1. Write a test that describes the desired behavior.
  2. Run the test. Confirm it fails. If it passes, the test is not testing new behavior — revise it.
  3. Verify it fails for the right reason (e.g. AssertionError or AttributeError, not ImportError or SyntaxError).

Green — Make it pass

  1. Write the minimum code needed to make the test pass. No more.
  2. Run the test. Confirm it passes.

Refactor — Clean up

  1. Improve the implementation (remove duplication, clarify names, extract functions) while keeping all tests green.
  2. Run tests after each refactoring change.

Show each step explicitly. When presenting work, label which phase you are in (Red/Green/Refactor) so the workflow is visible.


2. Project Setup

Before writing tests, ensure the project has proper test infrastructure.

Directory layout

project/
├── src/              # or the package name directly
│   └── myapp/
│       └── __init__.py
├── tests/
│   ├── conftest.py   # shared fixtures
│   ├── test_models.py
│   └── test_routes.py
└── pyproject.toml

pyproject.toml — pytest configuration

If pytest config does not exist, add it:

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"

conftest.py

Create tests/conftest.py for shared fixtures. Keep it organized — if it grows large, split fixtures into multiple conftest.py files in subdirectories.

Dependencies

Ensure pytest is installed. Use uv for dependency management:

uv add --dev pytest

3. Writing the Failing Test (Red)

File naming

  • Test files: test_<module>.py (e.g. test_models.py, test_auth.py)
  • Test functions: test_<behavior> — describe what is being tested, not how
# Good — describes behavior
def test_new_user_has_default_role():
    ...

def test_login_rejects_invalid_password():
    ...

# Bad — describes implementation
def test_user_init():
    ...

def test_check_password_returns_false():
    ...

Write one test at a time

Write a single test, run it, see it fail, then implement. Do not write a batch of tests upfront.

Assert clearly

Each test should have a single, clear assertion (or a small group of closely related assertions). Use plain assert statements — pytest's introspection provides detailed failure messages.

def test_new_user_has_default_role():
    user = User(name="alice")
    assert user.role == "viewer"

Run and confirm failure

pytest tests/test_models.py::test_new_user_has_default_role -v

Read the failure output. Confirm the error is what you expect (e.g. NameError: name 'User' is not defined or AssertionError). If the failure is something else (syntax error, import misconfiguration), fix the test before proceeding.


4. Making the Test Pass (Green)

Write the minimal implementation to make the failing test pass.

  • Do not add features the test does not require.
  • Do not handle edge cases that are not yet tested.
  • Hard-coding a return value is acceptable if only one test exists — the next test will force a real implementation.

Run the full test suite, not just the new test:

pytest -v

All tests must pass before moving on.


5. Refactor

With all tests green, improve the code:

  • Remove duplication between implementation and tests.
  • Rename variables and functions for clarity.
  • Extract helper functions or methods.
  • Simplify logic.

Run the full test suite after every refactoring change. If a test breaks, undo the last change and try a smaller step.

Do not add new behavior during refactoring. If you notice missing behavior, go back to Red and write a test for it first.


6. 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

7. 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",
        # override database URI for tests, etc.
    })
    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

8. 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)

9. 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",
    )

Testing exceptions

def test_divide_by_zero_raises():
    with pytest.raises(ZeroDivisionError):
        divide(1, 0)

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

10. 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.