Module 8: Capstone Project — A Complete Test Suite with TDD
Mocks, Fixtures and the CI Pipeline
Mocks, Fixtures and the CI Pipeline
Capsule overview
With the TaskFlow API's core working, now you professionalize the test suite: a conftest.py hierarchy, mocks for external services, E2E tests of complete flows, and the CI pipeline in GitHub Actions. You also verify that the final coverage meets the ≥90% target.
This capsule closes the testing and automation layer. By the end, you'll have a project with tests that run on every push, downloadable coverage reports, and a README with a CI badge ready for your portfolio.
A Professional conftest.py Hierarchy
Why multiple conftests
Fixtures are inherited: conftest.py at the root of tests/ defines fixtures available to all the tests; conftest.py in tests/unit/ or tests/integration/ defines fixtures specific to that level. This avoids duplication and keeps each layer with its own needs.
The target structure
tests/
├── conftest.py # Global fixtures: app, client, auth helpers
├── unit/
│ ├── conftest.py # Fixtures for unit: isolated services
│ ├── test_auth.py
│ ├── test_teams.py
│ └── ...
├── integration/
│ ├── conftest.py # Fixtures for integration: client + auth
│ ├── test_auth_endpoints.py
│ └── ...
└── e2e/
├── conftest.py # Fixtures for E2E: complete setup
└── test_flows.py
The Root conftest.py (tests/conftest.py)
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
"""A TestClient for the whole suite."""
return TestClient(app)
@pytest.fixture
def auth_service():
"""A clean AuthService instance for tests that need total control."""
from app.auth.service import AuthService
return AuthService()
@pytest.fixture
def team_service():
from app.teams.service import TeamService
return TeamService()
@pytest.fixture
def task_service():
from app.tasks.service import TaskService
return TaskService()
If your app uses global storage (shared dicts), consider fixtures that reset the state between tests to avoid interference. For example:
@pytest.fixture(autouse=True)
def reset_storage():
"""Optional: reset the in-memory storage before each test."""
yield
# Teardown: clean the stores if necessary
The Integration conftest.py (tests/integration/conftest.py)
# tests/integration/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def auth_headers(client):
"""Registers a user, logs in, returns headers with a Bearer token."""
client.post("/auth/register", json={
"email": "test@example.com",
"password": "SecurePass123!"
})
response = client.post("/auth/login", json={
"email": "test@example.com",
"password": "SecurePass123!"
})
token = response.json()["token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def team_id(client, auth_headers):
"""Creates a team and returns its ID."""
response = client.post("/teams", json={"name": "Test Team"}, headers=auth_headers)
return response.json()["id"]
With auth_headers and team_id, the integration tests can focus on the endpoint's behavior without repeating the setup.
The Unit conftest.py (tests/unit/conftest.py)
For unit tests, you typically inject dependencies or use mocks:
# tests/unit/conftest.py
import pytest
@pytest.fixture
def sample_user():
return {"id": 1, "email": "alice@example.com"}
@pytest.fixture
def sample_team():
return {"id": 1, "name": "Dev Team", "owner_id": 1, "member_ids": [1]}
Mocks for External Services
When to mock
The TaskFlow API uses in-memory storage, but if you add a feature like "send an email when a task is assigned", that sending is an external service. In tests, you don't want to send real emails. You mock the email client.
An example: a notification service
Suppose you add a NotificationService that sends emails:
# app/notifications/service.py (hypothetical)
class NotificationService:
def send_assignment_email(self, to_email: str, task_title: str) -> bool:
# Calls an external API or SMTP
...
In tests:
# tests/unit/test_tasks.py
def test_assign_task_triggers_notification(mocker):
mock_notif = mocker.patch("app.tasks.service.NotificationService.send_assignment_email")
service = TaskService(notification_service=NotificationService())
service.assign_task(task_id=1, user_id=2)
mock_notif.assert_called_once()
Or with dependency injection in FastAPI, you inject a mock into the test client.
A prompt for Claude Code
In TaskFlow, if we add email notifications when tasks are assigned,
I need to mock the email service in the tests. Show me how to use
pytest-mock to patch NotificationService.send_email and avoid
real calls.
E2E Tests of Complete Flows
What E2E tests test
E2E tests validate a complete user flow from end to end: registration → login → create a team → create a task → assign → complete. A single test that goes through the whole API as a real client would.
An example: the complete flow
# tests/e2e/test_flows.py
def test_full_user_flow_register_login_create_team_task_complete(client):
"""E2E: A user registers, logs in, creates a team, creates a task, assigns and completes it."""
# Register
r1 = client.post("/auth/register", json={
"email": "e2e@test.com",
"password": "SecurePass123!"
})
assert r1.status_code == 201
# Login
r2 = client.post("/auth/login", json={
"email": "e2e@test.com",
"password": "SecurePass123!"
})
assert r2.status_code == 200
token = r2.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
# Create team
r3 = client.post("/teams", json={"name": "E2E Team"}, headers=headers)
assert r3.status_code == 201
team_id = r3.json()["id"]
# Create task
r4 = client.post(f"/teams/{team_id}/tasks", json={
"title": "E2E Task",
"status": "pending"
}, headers=headers)
assert r4.status_code == 201
task_id = r4.json()["id"]
# Update to in_progress
r5 = client.patch(f"/tasks/{task_id}", json={"status": "in_progress"}, headers=headers)
assert r5.status_code == 200
# Complete
r6 = client.patch(f"/tasks/{task_id}", json={"status": "completed"}, headers=headers)
assert r6.status_code == 200
assert r6.json()["status"] == "completed"
Why E2E tests matter
They detect integration problems that unit and integration tests can miss: dependency order, tokens expiring mid-flow, badly configured routes. An E2E test that passes gives confidence that the app works from start to finish.
Configuring the CI Pipeline in GitHub Actions
The basic workflow
Create .github/workflows/tests.yml:
name: Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with coverage
run: |
pytest tests/ -v --cov=app --cov-report=term-missing --cov-report=xml --cov-fail-under=90
An explanation of the flags
--cov=app: measures the coverage of theapp/directory--cov-report=term-missing: shows the uncovered lines in the output--cov-report=xml: generatescoverage.xmlfor integration with tools--cov-fail-under=90: fails the job if coverage < 90%
Matrix testing (optional)
To validate on several Python versions:
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
Caching dependencies
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
Verifying the Pipeline
A validation checklist
After configuring the workflow:
- Push to your branch
- Go to the Actions tab on GitHub
- The "test" job must run
- The tests must pass
- The coverage must show in the logs
- If coverage < 90%, the job must fail (with
--cov-fail-under=90)
Testing that it fails correctly
Introduce a failing test or temporarily delete a test that covered important code. The pipeline must go red. Then revert. This confirms that CI protects the code.
Final Coverage: The ≥90% Target
The local command
pytest tests/ -v --cov=app --cov-report=term-missing --cov-report=html
term-missing: the uncovered lines in the consolehtml: openhtmlcov/index.htmlto see the visual report
What to do if you don't reach 90%
- Review the report: identify files with low coverage
- Write tests for the missing branches and lines
- Use prompts to Claude Code: "Analyze app/tasks/rules.py and generate tests to cover lines 45-60 that validate the status transitions"
pyproject.toml for coverage
[tool.coverage.run]
source = ["app"]
omit = ["tests/*", "app/__init__.py"]
[tool.coverage.report]
fail_under = 90
show_missing = true
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError"
]
A CI Badge in the README
Add to the README:

Replace YOUR-USERNAME and YOUR-REPO with your org/repo. The badge shows the status of the last run (passing/failing).
Summary
- The conftest hierarchy: root (global fixtures), unit, integration, e2e
- Mocks for external services (email, APIs) avoid side effects in tests
- E2E tests validate complete user flows
- GitHub Actions runs the tests on every push/PR
- Coverage ≥90% with
--cov-fail-under=90 - A CI badge in the README for your portfolio
Next capsule: Delivery and Retrospective — the final rubric, preparing for the portfolio, and closing the module.
Module 8, Capsule 05 — Testing with Claude Code Guide