TDD Done Right: Two Practical Approaches Every Backend Developer Should Know

TDD tends to get overcomplicated when people first encounter it. In reality, it’s just a discipline loop that forces you to think before you code, and then continuously tighten the design as you implement it.

In this post, we’ll go through two approaches I often see in practice when working with TDD:

  • Red – Green – Refactor (the classical TDD cycle)
  • Fake It Till You Make It

I’ll also be upfront about my stance: Red–Green–Refactor is, in my opinion, the most reliable and scalable approach, especially for backend systems where correctness and structure matter more than quick prototyping.

This will be part of a broader TDD series. We’ll revisit other approaches and trade-offs in future posts.


Setting the Scene: A Simple Express Feature

Let’s imagine we’re building a basic API endpoint:

GET /users/:id

It should return a user object. Simple enough, but perfect for demonstrating TDD without drowning in complexity.

We’ll assume:

  • Node.js + Express
  • Jest for testing
  • Supertest for HTTP assertions

(By the way, this is my favorite stack of all time. 😁)


Approach 1: Red – Green – Refactor (Classical TDD Cycle)

This is the canonical TDD loop:

  1. Red – write a failing test
  2. Green – write minimal code to pass it
  3. Refactor – improve structure without changing behavior

This cycle forces intentionality. You don’t “design as you go”, you discover design through constraints.


Step 1: Red (Write the failing test first)

import request from "supertest";
import app from "../app";

describe("GET /users/:id", () => {
  it("should return a user by id", async () => {
    const res = await request(app).get("/users/1");

    expect(res.status).toBe(200);
    expect(res.body).toEqual({
      id: "1",
      name: "John Doe"
    });
  });
});

At this point, nothing exists. The test fails. That’s the point.


Step 2: Green (Make it pass as quickly as possible)

No abstractions. No “clean architecture thinking”. Just enough code to satisfy the test.

import express from "express";

const app = express();

app.get("/users/:id", (req, res) => {
  res.status(200).json({
    id: req.params.id,
    name: "John Doe"
  });
});

export default app;

Ugly? Yes. But it works. And more importantly, it’s now safe.


Step 3: Refactor (now you earn your architecture)

Once the test is green, we can introduce structure:

// user.service.ts
export const getUserById = (id: string) => {
  return {
    id,
    name: "John Doe"
  };
};
// user.controller.ts
import { getUserById } from "./user.service";

export const getUser = (req, res) => {
  const user = getUserById(req.params.id);
  res.status(200).json(user);
};
// app.ts
import express from "express";
import { getUser } from "./user.controller";

const app = express();

app.get("/users/:id", getUser);

export default app;

This is where TDD starts paying dividends. You refactor with confidence because tests act as a safety net.


Why Red–Green–Refactor Works So Well

This loop is powerful because it forces three things:

  • You always start from behavior (not structure)
  • You avoid over-engineering early (super important!!!)
  • You get immediate feedback on design decisions

In backend systems (especially Node/Express services), this matters a lot. You end up with cleaner boundaries between controller, service, and data layers without prematurely guessing abstractions.


Approach 2: Fake It Till You Make It

This is a lighter, more “incremental realism” approach.

The idea is simple: instead of building the real implementation immediately, you fake the output first, then gradually replace the fake parts with real logic.


Step 1: Fake the response

app.get("/users/:id", (req, res) => {
  res.json({
    id: "1",
    name: "fake user"
  });
});

No service layer. No DB. Just a hardcoded response to unblock progress.


Step 2: Introduce minimal variability

app.get("/users/:id", (req, res) => {
  res.json({
    id: req.params.id,
    name: "fake user"
  });
});

Now it’s slightly less fake, but still not real logic.


Step 3: Replace fake with real implementation

const users = new Map([
  ["1", { id: "1", name: "John Doe" }]
]);

app.get("/users/:id", (req, res) => {
  const user = users.get(req.params.id);
  res.json(user);
});

Eventually, this evolves into a proper service + repository layer.


Where Fake It Till You Make It Breaks Down

This approach is useful for fast prototyping, but it has limits:

  • It doesn’t enforce correctness upfront
  • It can silently accumulate design debt
  • It encourages “happy path coding” first, testing later

It works well for UI-driven development or exploratory backend features, but not for systems where correctness and maintainability matter from day one.


My Take: Why RGR Wins (Most of the Time)

Between the two, I consistently prefer Red–Green–Refactor.

The reason is simple: it aligns with how backend systems actually evolve in production. You don’t get stable architecture by guessing it upfront. You get it by:

  • Locking behavior with tests first
  • Allowing implementation to stay minimal initially
  • Refactoring safely once the behavior is guaranteed

Fake It Till You Make It is tempting because it feels faster, but it shifts the cost of design decisions into the future, often without visibility.

RGR, on the other hand, makes design constraints visible immediately.


Wrapping Up

We’ve only scratched the surface here.

In future posts, I’ll go deeper into other TDD approaches and how they behave in real-world backend systems, especially when things get messy (async flows, databases, external services, and real production constraints).

This series will continue, and we’ll compare more styles and trade-offs as we go.

1 Comments

  • Amir Zare
    23 Jun, 2026

    In my opinion, RGR is a really effective approach. It naturally builds the habit of writing tests across the team, which is especially valuable when you need to refactor later, like you mentioned in your refactoring post.

    • Completely agree. RGR quietly solves the “testing is optional” mindset because it makes tests part of the workflow, not a separate, pain in the a** phase.

Leave a Comment