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: validin_progress→completed: validcompleted→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=90passes - Every module in
app/has unit tests -
app/tasks/rules.pyhas 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