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 the app/ directory
  • --cov-report=term-missing: shows the uncovered lines in the output
  • --cov-report=xml: generates coverage.xml for 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:

  1. Push to your branch
  2. Go to the Actions tab on GitHub
  3. The "test" job must run
  4. The tests must pass
  5. The coverage must show in the logs
  6. 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 console
  • html: open htmlcov/index.html to see the visual report

What to do if you don't reach 90%

  1. Review the report: identify files with low coverage
  2. Write tests for the missing branches and lines
  3. 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:

![Tests](https://github.com/YOUR-USERNAME/YOUR-REPO/actions/workflows/tests.yml/badge.svg)

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