Home / Courses / Testing for Beginners / II — Hands-On Practice
II — Hands-On Practice
Article 8 of 9

Hands-On: TDD — Red, Green, Refactor

Building a task search feature from scratch through the Test-Driven Development cycle — write a test that's guaranteed to fail (Red), the simplest code that makes it pass (Green), then clean up without changing behavior (Refactor).

July 10, 2026

So far, in every hands-on article, we've written tests for code that ALREADY EXISTED. In this article we flip the order: writing the test BEFORE the feature itself is written at all. This is called Test-Driven Development (TDD), with a three-step cycle: Red (write a failing test), Green (write just enough code to make it pass), Refactor (clean up without changing behavior).

The feature we're building: searching tasks by titleGET /api/tasks/search?q=<keyword>.

Red — Write a Test That's GUARANTEED to Fail

Before a single line of endpoint code is written, we write down how we EXPECT this feature to behave:

# tests/integration/test_search_tasks.py
def test_search_returns_matching_tasks(client: TestClient):
    token = register_and_login(client)
    client.post("/api/tasks", json={"title": "Learning FastAPI"}, headers=auth_headers(token))
    client.post("/api/tasks", json={"title": "Learning Testing"}, headers=auth_headers(token))
    client.post("/api/tasks", json={"title": "Buy coffee"}, headers=auth_headers(token))

    response = client.get("/api/tasks/search", params={"q": "learning"}, headers=auth_headers(token))
    assert response.status_code == 200
    titles = {task["title"] for task in response.json()}
    assert titles == {"Learning FastAPI", "Learning Testing"}

Run it:

pytest tests/integration/test_search_tasks.py::test_search_returns_matching_tasks -v
FAILED — assert 404 == 200

Red, as expected. The /api/tasks/search endpoint doesn't exist at all yet, so FastAPI tries to match it to /{task_id} (with task_id="search"), fails the int type validation, and returns an error. This point matters: we haven't written any feature code yet, but we ALREADY have a clear definition of "this feature is done when this test passes."

Why Write the Test First?

An immediate benefit: writing the test BEFORE the implementation forces us to think from the perspective of someone USING the endpoint (what's the input, what output is expected) before getting lost in implementation details. It also automatically verifies that our test CAN actually fail — if we write the implementation first and the test second, there's a risk the test "happens to pass" even with broken logic, and we'd never know.

Green — Write the LEAST Amount of Code to Make It Pass

The temptation here is to jump straight to a "perfect" version. Hold off — focus only on making that test PASS:

@router.get("/search", response_model=List[TaskRead])
def search_tasks(
    q: str = Query(min_length=1, max_length=200),
    session: Session = Depends(get_session),
    current_user: User = Depends(get_current_user),
):
    query = select(Task).where(
        Task.owner_id == current_user.id,
        Task.title.ilike(f"%{q}%"),
    )
    return session.exec(query).all()

Run it again:

PASSED

Green. But before moving on, there's one detail that MUST be handled carefully — route registration order:

router = APIRouter(prefix="/api/tasks", tags=["tasks"])

@router.get("", response_model=List[TaskRead])
def list_tasks(...): ...

@router.get("/search", response_model=List[TaskRead])   # MUST come before "/{task_id}"
def search_tasks(...): ...

@router.get("/{task_id}", response_model=TaskRead)        # if this comes first, "/search" gets
def get_task(...): ...                                       # captured as task_id="search"

FastAPI (via Starlette) matches paths IN ORDER of registration — the first matching path wins. If /{task_id} is registered BEFORE /search, a request to /api/tasks/search gets captured as task_id="search", failing validation because "search" isn't an integer. This is exactly the kind of bug that EASILY slips through if you only test manually once, with a "coincidentally correct" order, but an automated test like this one catches it immediately the moment the registration order is wrong.

Adding More Tests — Still the Red-Green Cycle

TDD isn't just one round. We continue with the next scenario:

def test_search_is_case_insensitive(client: TestClient):
    token = register_and_login(client)
    client.post("/api/tasks", json={"title": "Learning FastAPI"}, headers=auth_headers(token))

    response = client.get("/api/tasks/search", params={"q": "FASTAPI"}, headers=auth_headers(token))
    assert response.status_code == 200
    assert len(response.json()) == 1

As it happens, our implementation (Task.title.ilike(...)) was ALREADY case-insensitive from the start (ilike = "insensitive like"), so this test goes Green immediately with no code changes needed. That's valid in TDD too — sometimes an existing implementation already covers a new scenario, and the new test serves as LIVING DOCUMENTATION proving it, rather than just an assumption.

Two security scenarios that must not be skipped (remember Security for Beginners):

def test_search_only_returns_own_tasks(client: TestClient):
    alice_token = register_and_login(client, username="alice")
    bob_token = register_and_login(client, username="bob")
    client.post("/api/tasks", json={"title": "Alice's secret"}, headers=auth_headers(alice_token))

    response = client.get("/api/tasks/search", params={"q": "secret"}, headers=auth_headers(bob_token))
    assert response.json() == []


def test_search_empty_query_rejected(client: TestClient):
    token = register_and_login(client)
    response = client.get("/api/tasks/search", params={"q": ""}, headers=auth_headers(token))
    assert response.status_code == 422

The first test verifies the Task.owner_id == current_user.id filter wasn't forgotten on this new endpoint — if we'd left it out, this test would immediately go Red, catching a security regression BEFORE it could ever be deployed. The second test passes automatically thanks to Query(min_length=1, ...) — Pydantic validation whose principles we've covered since the FastAPI series.

Refactor — Clean Up Without Changing Behavior

With all tests Green, now is a SAFE time to refactor — for example, if there's duplication between list_tasks and search_tasks worth cleaning up, or variable names worth clarifying. The key rule: refactoring does NOT change any assert in any test. After refactoring, rerun the whole suite:

pytest -v
======================= 41 passed in 20.02s =======================

If everything stays Green, the refactor succeeded WITHOUT breaking any already-verified behavior — this is the confidence to change discussed in the first article of this series, now felt directly rather than just as theory.

Summary

  • Red — write a test that defines the desired behavior, and MAKE SURE it fails first (proving the test actually tests something).
  • Green — write the LEAST amount of code needed to make the test pass, resisting the temptation to write a "perfect" version up front.
  • Refactor — clean up code safely, as long as all tests stay Green, guaranteeing behavior hasn't changed.
  • Route registration order in FastAPI matters — a specific path (/search) must be registered BEFORE a dynamic path (/{task_id}) that could accidentally capture it.
  • Security tests (resource ownership, input validation) should be part of the TDD cycle from the start, not bolted on later.
  • Topics

    TestingTDD