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
- Write a test that describes the desired behavior.
- Run the test. Confirm it fails. If it passes, the test is not testing new behavior — revise it.
- Verify it fails for the right reason (e.g.
AssertionErrororAttributeError, notImportErrororSyntaxError).
Green — Make it pass
- Write the minimum code needed to make the test pass. No more.
- Run the test. Confirm it passes.
Refactor — Clean up
- Improve the implementation (remove duplication, clarify names, extract functions) while keeping all tests green.
- 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 suitetests/api/conftest.py— fixtures specific to API tests- Fixtures in a
conftest.pyare 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, notassert 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, ortry/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.