# 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 4. Write the **minimum** code needed to make the test pass. No more. 5. Run the test. **Confirm it passes.** ### Refactor — Clean up 6. Improve the implementation (remove duplication, clarify names, extract functions) while keeping all tests green. 7. 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: ```toml [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: ```bash uv add --dev pytest ``` --- ## 3. Writing the Failing Test (Red) ### File naming - Test files: `test_.py` (e.g. `test_models.py`, `test_auth.py`) - Test functions: `test_` — describe what is being tested, not how ```python # 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. ```python def test_new_user_has_default_role(): user = User(name="alice") assert user.role == "viewer" ``` ### Run and confirm failure ```bash 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: ```bash 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 ```python # tests/conftest.py import pytest @pytest.fixture def sample_user(): return User(name="alice", email="alice@example.com") ``` ```python # tests/test_models.py def test_user_display_name(sample_user): assert sample_user.display_name == "alice" ``` ### Yield fixtures for setup/teardown ```python @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 ```python # 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 ```python 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 ```python 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 ```python 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 ```python def test_db_query_within_app_context(app): with app.app_context(): users = User.query.all() assert len(users) >= 0 ``` ### Testing with request context ```python def test_url_generation(app): with app.test_request_context(): assert url_for("index") == "/" ``` ### Quart async tests For Quart applications, use `pytest-asyncio`: ```python 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 ```python @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 ```python @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) ```python 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 ```python 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 ```python 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 ```python 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 ```bash uv add --dev pytest-cov pytest --cov=src --cov-report=term-missing -v ``` Report untested lines and suggest tests for them.