Module 8: Capstone Project — A Complete Test Suite with TDD

Business Logic, Coverage and Edge Cases

Business Logic, Coverage and Edge Cases

Capsule overview

The TaskFlow API's core already works: auth, teams, tasks CRUD. But a professional application doesn't just do the basics — it validates business rules, handles edge cases, and exposes useful statistics. This capsule closes those gaps using TDD: you implement valid status transitions, permissions by role, team statistics, and you use hypothesis for property-based tests that discover cases you hadn't thought of.

By the end you'll have coverage ≥90% and confidence that the business rules are correctly implemented and tested.


The Business Rules to Implement

1. Status transitions

Tasks have statuses: pending, in_progress, completed. Not all transitions are valid:

  • pending → in_progress: valid
  • in_progress → completed: valid
  • completed → in_progress: invalid (no going backwards)
  • pending → completed: invalid (it must go through in_progress)
  • Any transition to a nonexistent status: invalid

2. Permissions

  • Only the team's owner can add members (already covered in Teams)
  • Only the assigned user can mark a task as completed
  • An unassigned task: any team member can complete it

3. Team statistics

  • A count of tasks by status (pending, in_progress, completed)
  • The completion rate: completed / total * 100

Implementing with TDD: Status Transitions

Cycle 1: Defining the rules

Tests in tests/unit/test_rules.py:

# tests/unit/test_rules.py
from app.tasks.rules import can_transition, VALID_TRANSITIONS

def test_pending_to_in_progress_is_valid():
    assert can_transition("pending", "in_progress") is True

def test_in_progress_to_completed_is_valid():
    assert can_transition("in_progress", "completed") is True

def test_completed_to_in_progress_is_invalid():
    assert can_transition("completed", "in_progress") is False

def test_pending_to_completed_is_invalid():
    assert can_transition("pending", "completed") is False

def test_invalid_from_status_raises():
    with pytest.raises(ValueError, match="Unknown status"):
        can_transition("invalid", "pending")

def test_invalid_to_status_raises():
    with pytest.raises(ValueError, match="Unknown status"):
        can_transition("pending", "done")

A prompt to Claude Code:

Implement app/tasks/rules.py with:
- can_transition(from_status, to_status) -> bool
- Valid transitions: pending->in_progress, in_progress->completed
- Any other transition returns False
- An unknown status: raise ValueError("Unknown status")
- Valid statuses: pending, in_progress, completed

Cycle 2: Integrating it into TaskService

Tests in tests/unit/test_tasks.py:

def test_update_task_status_valid_transition_succeeds():
    service = TaskService()
    task = service.create_task("T1", team_id=1, created_by=1, status="pending")
    updated = service.update_task(task["id"], status="in_progress")
    assert updated["status"] == "in_progress"

def test_update_task_status_invalid_transition_raises():
    service = TaskService()
    task = service.create_task("T1", team_id=1, created_by=1, status="completed")
    with pytest.raises(ValueError, match="Invalid transition"):
        service.update_task(task["id"], status="in_progress")

A prompt to Claude Code:

Modify TaskService.update_task to validate status transitions.
- Before updating the status, call can_transition(from, to)
- If invalid: raise ValueError("Invalid transition")
- Use app.tasks.rules.can_transition

Implementing with TDD: Permissions

Cycle 3: Only the assignee can complete

Tests in tests/unit/test_rules.py:

def test_only_assigned_user_can_complete_task():
    # The logic: if assigned_to exists and isn't the current user, they can't complete it
    from app.tasks.rules import can_user_complete_task
    assert can_user_complete_task(assigned_to=5, current_user_id=5) is True
    assert can_user_complete_task(assigned_to=5, current_user_id=3) is False

def test_unassigned_task_any_member_can_complete():
    from app.tasks.rules import can_user_complete_task
    assert can_user_complete_task(assigned_to=None, current_user_id=1) is True

A prompt to Claude Code:

Implement can_user_complete_task(assigned_to, current_user_id) in app/tasks/rules.py:
- If assigned_to is None: any user can complete it (return True)
- If assigned_to == current_user_id: they can complete it (return True)
- If assigned_to != current_user_id: they can't (return False)

Cycle 4: Integrating the permission into update

Tests:

def test_non_assigned_user_cannot_complete_task():
    service = TaskService()
    task = service.create_task("T1", team_id=1, created_by=1, status="in_progress")
    service.assign_task(task["id"], user_id=5)  # Assign to user 5
    with pytest.raises(PermissionError, match="Only assigned user"):
        service.update_task(task["id"], status="completed", updated_by=3)  # User 3 tries

A prompt to Claude Code:

Modify TaskService.update_task:
- When the status changes to "completed", check can_user_complete_task(assigned_to, updated_by)
- If False: raise PermissionError("Only assigned user can complete this task")
- updated_by is the user_id of the user making the update

Implementing with TDD: Statistics

Cycle 5: Team stats

Tests in tests/unit/test_rules.py or tests/unit/test_tasks.py:

def test_get_team_stats_returns_tasks_by_status():
    from app.tasks.service import TaskService
    service = TaskService()
    service.create_task("T1", team_id=1, created_by=1, status="pending")
    service.create_task("T2", team_id=1, created_by=1, status="in_progress")
    service.create_task("T3", team_id=1, created_by=1, status="completed")
    service.create_task("T4", team_id=1, created_by=1, status="completed")
    stats = service.get_team_stats(team_id=1)
    assert stats["by_status"]["pending"] == 1
    assert stats["by_status"]["in_progress"] == 1
    assert stats["by_status"]["completed"] == 2
    assert stats["total"] == 4

def test_get_team_stats_completion_rate():
    service = TaskService()
    service.create_task("T1", team_id=1, created_by=1, status="pending")
    service.create_task("T2", team_id=1, created_by=1, status="completed")
    stats = service.get_team_stats(team_id=1)
    assert stats["completion_rate"] == 50.0  # 1/2 * 100

def test_get_team_stats_empty_team():
    service = TaskService()
    stats = service.get_team_stats(team_id=999)
    assert stats["total"] == 0
    assert stats["completion_rate"] == 0.0

A prompt to Claude Code:

Add get_team_stats(team_id) to TaskService:
- Returns { "by_status": {"pending": n, "in_progress": n, "completed": n}, "total": n, "completion_rate": float }
- completion_rate = (completed / total * 100) if total > 0, else 0

Measuring Coverage with pytest-cov

The configuration in pyproject.toml

[tool.coverage.run]
source = ["app"]
omit = ["app/__init__.py", "tests/*"]

[tool.coverage.report]
fail_under = 90
show_missing = true
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise NotImplementedError"
]

The command

pytest tests/ -v --cov=app --cov-report=term-missing --cov-report=html

Interpreting the report

  • Line coverage: the percentage of lines executed
  • term-missing: shows which lines aren't covered
  • The target: ≥90% in app/

Prompts for Edge Case Discovery with Claude Code

Prompt 1: Gap analysis

Review app/auth/service.py and app/tasks/service.py.
List all the edge cases that could fail and that aren't covered by the current tests.
For each one, suggest the name of a test that would cover it.

Prompt 2: Generating tests for a module

Generate 5 additional tests for app/tasks/rules.py that cover edge cases.
Include: an empty status, None as assigned_to, completion_rate with total=0, a transition to the same status.

Prompt 3: Boundary testing

What limit values could break get_team_stats or can_transition?
Generate tests with pytest.mark.parametrize for those cases.

Property-Based Testing with Hypothesis

Installation

pip install hypothesis

Tests with hypothesis in test_rules.py

from hypothesis import given, strategies as st

@given(
    from_s=st.sampled_from(["pending", "in_progress", "completed"]),
    to_s=st.sampled_from(["pending", "in_progress", "completed"])
)
def test_can_transition_property_based(from_s, to_s):
    """Any transition must be consistent: either True or False, never an unexpected exception."""
    result = can_transition(from_s, to_s)
    assert isinstance(result, bool)
    if from_s == to_s:
        # A transition to the same status: it could be True or False depending on your spec
        pass  # We only verify it doesn't crash
    elif (from_s, to_s) in [("pending", "in_progress"), ("in_progress", "completed")]:
        assert result is True
    else:
        assert result is False

Another example: completion_rate

@given(
    pending=st.integers(min_value=0, max_value=100),
    in_progress=st.integers(min_value=0, max_value=100),
    completed=st.integers(min_value=0, max_value=100)
)
def test_completion_rate_always_between_0_and_100(pending, in_progress, completed):
    total = pending + in_progress + completed
    if total == 0:
        rate = 0.0
    else:
        rate = (completed / total) * 100
    assert 0 <= rate <= 100

A Checklist for Coverage ≥90%

Before considering this capsule closed:

  • pytest tests/ --cov=app --cov-fail-under=90 passes
  • Every module in app/ has unit tests
  • app/tasks/rules.py has explicit tests + hypothesis
  • Edge cases documented: invalid transitions, permissions, empty stats
  • There are no unnecessary "pragma: no cover" — if something isn't tested, ask yourself why

Common Mistakes

Mistake 1: The tests pass but the coverage is low

Cause: Superficial tests that don't run the conditional branches (if/else, exceptions).

Solution: Review --cov-report=term-missing and write tests that exercise the missing lines.

Mistake 2: Hypothesis finds counterexamples

Cause: The property you defined is incorrect or the implementation has a bug.

Solution: Hypothesis will give you the failing example. Analyze it: is the test wrong or the implementation? Fix whichever applies.

Mistake 3: Too many "pragma: no cover"

Cause: Hard-to-test code (logging, debug) marked as no cover.

Solution: Only use a pragma on code that's genuinely untestable. If it's business logic, it must have a test.


Summary

  • You implement business rules with TDD: status transitions, permissions, statistics
  • You measure coverage with pytest-cov; the target is ≥90%
  • You use prompts to Claude Code to discover edge cases you hadn't considered
  • You use hypothesis for property-based tests that validate invariants
  • The combination of explicit tests + property-based gives professional confidence

Next capsule: Mocks, fixtures and the CI pipeline — closing the testing infrastructure.


Module 8, Capsule 04 — Testing with Claude Code Guide