Skip to main content

🔴🟢 Test-Driven Development Workflow

What if you wrote the test before the code? It sounds backwards, but Test-Driven Development flips the usual order to produce simpler designs, fewer bugs, and code you can refactor without fear. This lesson teaches the Red-Green-Refactor rhythm and gets you practicing it.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what Test-Driven Development is and how it differs from test-after
  • Walk through the Red-Green-Refactor cycle step by step
  • Apply TDD in both JavaScript (Vitest) and Python (pytest)
  • Recognize and avoid the most common TDD pitfalls
  • Relate TDD to BDD and ATDD and know when each fits

Estimated Time: 40–50 minutes  •  Difficulty: Intermediate

Hands-on: Grow a String Calculator entirely test-first, one failing test at a time.

In This Lesson

What Is TDD?

Test-Driven Development (TDD) is a workflow where you write a failing test first, then write just enough code to make it pass, then improve the code. The test isn't an afterthought that verifies finished work — it's a specification you write in advance that drives the design of the code.

The mental shift is subtle but powerful. Before writing any implementation, you must decide exactly what "working" means and express it as an executable test. That forces clarity: you design the interface from the caller's point of view before you get lost in the internals.

flowchart TD subgraph Test-after A[Write code] --> B[Write tests] B --> C[Refactor if there's time] end subgraph Test-first TDD D[Write a failing test] --> E[Write minimal code] E --> F[Refactor] F --> D end
💡 Net before the tightrope: TDD builds the safety net before you step out onto the wire. You define what a fall looks like — and what catching it looks like — before you take a single risky step.

The TDD Cycle: Red, Green, Refactor

TDD is a tight, repeating loop with three phases. Each pass through it should take minutes, not hours.

The Red-Green-Refactor cycle Three connected stages forming a loop: Red (write a failing test), Green (write minimal code to pass), and Refactor (improve the code), which returns to Red. RED write a failing test GREEN minimal code to pass REFACTOR improve, keep it green repeat
Figure 1 — Red-Green-Refactor: fail, pass, polish, and repeat. Never skip refactor, and never write code without a failing test demanding it.

🔴 Red — write a failing test

Describe the behavior you want as a test. It must fail (because the code doesn't exist yet). This step forces you to define success before you build. It's the blueprint before the house.

🟢 Green — make it pass

Write the minimum code to turn the test green. Resist elegance here — a hard-coded return is fine if it passes. The goal is a working slice, not a masterpiece. It's the rough prototype.

🔵 Refactor — improve the code

With a passing test as your safety net, clean up: remove duplication, rename for clarity, extract functions — running the test after each change to confirm nothing broke. This is redesigning the prototype for durability without changing what it does.

TDD in Practice: A JavaScript Example

Let's build an email validator test-first with Vitest (Jest syntax is identical).

🔴 Step 1: Red — the failing test

// emailValidator.test.js
import { describe, it, expect } from 'vitest';
import { isValidEmail } from './emailValidator.js';

describe('isValidEmail', () => {
  it('accepts well-formed addresses', () => {
    expect(isValidEmail('user@example.com')).toBe(true);
    expect(isValidEmail('name.surname@domain.co.uk')).toBe(true);
  });

  it('rejects malformed addresses', () => {
    expect(isValidEmail('not-an-email')).toBe(false);
    expect(isValidEmail('missing@domain')).toBe(false);
    expect(isValidEmail('@domain.com')).toBe(false);
    expect(isValidEmail('')).toBe(false);
  });
});

Running it fails — as it should:

FAIL  emailValidator.test.js
  ✗ isValidEmail accepts well-formed addresses
    Error: Failed to resolve import "./emailValidator.js"

🟢 Step 2: Green — minimal code to pass

// emailValidator.js
export function isValidEmail(email) {
  if (!email) return false;
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

The tests now pass. Note we wrote only what the tests demanded — nothing more.

🔵 Step 3: Refactor — harden without breaking

// emailValidator.js — after refactoring
export function isValidEmail(email) {
  if (typeof email !== 'string' || email.length === 0) return false;
  if (email.length > 254) return false; // RFC upper bound

  const emailRegex =
    /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
  return emailRegex.test(email);
}

We made the implementation more robust and the tests are still green — proof our behavior didn't drift. That confidence is the whole point of the refactor step.

TDD in Practice: A Python Example

The same rhythm applies in any language. Here's a shopping-cart total calculator built with pytest.

🔴 Step 1: Red — the failing test

# test_cart.py
from cart import calculate_total

def test_total_without_discount():
    items = [
        {"name": "Keyboard", "price": 50.00, "quantity": 1},
        {"name": "Mouse", "price": 25.00, "quantity": 2},
    ]
    assert calculate_total(items) == 100.00

def test_total_with_discount():
    items = [
        {"name": "Monitor", "price": 200.00, "quantity": 1},
        {"name": "Headphones", "price": 100.00, "quantity": 1},
    ]
    assert calculate_total(items, discount_percent=10) == 270.00

🟢 Step 2: Green — minimal code to pass

# cart.py
def calculate_total(items, discount_percent=0):
    total = sum(item["price"] * item["quantity"] for item in items)
    if discount_percent:
        total -= total * (discount_percent / 100)
    return total

🔵 Step 3: Refactor — validate inputs and document

# cart.py — refactored
def calculate_total(items, discount_percent=0):
    """Return the cart total after an optional percentage discount.

    Args:
        items: list of dicts with 'price' and 'quantity'.
        discount_percent: discount to apply, 0-100.
    Returns:
        float rounded to 2 decimal places.
    """
    if not items:
        return 0

    if not 0 <= discount_percent <= 100:
        raise ValueError("discount_percent must be between 0 and 100")

    subtotal = sum(
        item.get("price", 0) * item.get("quantity", 0)
        for item in items
    )
    total = subtotal - subtotal * (discount_percent / 100)
    return round(total, 2)

Once the refactor is in place, add tests for the new edge cases (empty cart, invalid discount) — and watch them pass too.

Benefits & When to Use It

✅ Why TDD pays off

  • Better design: writing the test first forces you to design a clean, usable interface.
  • Fewer defects: behavior is pinned down by tests from the very first line.
  • Living documentation: the tests describe exactly how the code is meant to behave.
  • Fearless refactoring: a green suite lets you restructure with confidence.
  • Focus: you work on one small requirement at a time.

TDD shines in some situations more than others. Reach for it when:

  • The logic is complex, with many rules and edge cases to pin down.
  • You're fixing a bug — write a failing test that reproduces it, then make it pass so it never returns.
  • You're refactoring legacy code and need tests to lock in current behavior first.
  • You're designing an API and want the caller's experience to drive the shape.
  • Requirements are clear enough to express as concrete test cases.

💡 In the real world

Teams working on high-stakes systems — payment processing, financial calculations — lean heavily on TDD precisely because the cost of a bug is so high. Writing the test first is cheap insurance against an expensive mistake.

Common Pitfalls

TDD is simple to describe and easy to do badly. Watch for these traps.

⚠️ Pitfalls and their fixes

  • Writing many tests at once before any pass. → Follow the "one failing test at a time" rule; keep the loop tight.
  • Testing implementation, not behavior. → Assert on outcomes the caller can observe, not on private internals.
  • Overly complex tests that are hard to read. → Keep each test small, focused, and named for the behavior it checks.
  • Testing trivial code like plain getters or framework glue. → Spend your effort on real business logic.
  • Skipping the refactor step. → Green is not the finish line; clean-up is where the design pays off.

BDD & ATDD

TDD has close relatives that push the "test first" idea toward the business.

Behavior-Driven Development (BDD)

BDD frames tests as human-readable behavior, often in Given-When-Then form, so non-developers can read and even help write them:

// BDD style with Vitest-Cucumber
import { loadFeature, describeFeature } from '@amiceli/vitest-cucumber';

const feature = await loadFeature('./features/shoppingCart.feature');

describeFeature(feature, ({ Scenario }) => {
  Scenario('Adding items to the cart', ({ Given, When, Then }) => {
    let cart;
    Given('I have an empty shopping cart', () => { cart = new ShoppingCart(); });
    When('I add a $50 keyboard to the cart', () => {
      cart.addItem({ name: 'Keyboard', price: 50 });
    });
    Then('the cart total should be $50', () => {
      expect(cart.getTotal()).toBe(50);
    });
  });
});

📖 Requirement vs BDD scenario

Traditional: "The system shall allow users to reset their password."

BDD: a concrete, executable Given I click "Forgot password"… When I enter my email… Then I receive reset instructions — testable and unambiguous.

Acceptance Test-Driven Development (ATDD)

ATDD starts even higher: the team defines user-facing acceptance criteria first, writes acceptance tests from them, and only then drops into unit-level TDD.

flowchart TD A[Define acceptance criteria] --> B[Write acceptance tests] B --> C[Write unit tests] C --> D[Implement code] D --> E[Tests pass] E --> F[Refactor]

Hands-on Exercise

🏋️ The String Calculator Kata

Objective: Grow a small function entirely test-first, adding one failing test at a time.

Implement a add(numbers) function that:

  1. Returns 0 for an empty string.
  2. Returns the number itself for a single number ("5"5).
  3. Sums comma-separated numbers ("1,2,3"6).
  4. Allows newlines as separators too ("1\n2,3"6).
  5. Throws for any negative number.

Work strictly Red → Green → Refactor: write one failing test, make it pass with minimal code, tidy up, then move to the next rule.

// stringCalculator.test.js — start here
import { describe, it, expect } from 'vitest';
import { add } from './stringCalculator.js';

describe('String Calculator', () => {
  it('returns 0 for an empty string', () => {
    expect(add('')).toBe(0);
  });

  // Add one test at a time, making each pass before the next.
});
💡 Hint

For the empty-string test, the minimal code is literally return 0;. Only once the single-number and multi-number tests force it should you split on separators. Use a regex like /[,\n]/ to split on commas and newlines, and check for negatives with Array.prototype.filter before summing.

✅ One possible solution
// stringCalculator.js
export function add(numbers) {
  if (numbers === '') return 0;

  const parts = numbers.split(/[,\n]/).map(Number);
  const negatives = parts.filter((n) => n < 0);
  if (negatives.length) {
    throw new Error(`negatives not allowed: ${negatives.join(', ')}`);
  }
  return parts.reduce((sum, n) => sum + n, 0);
}
// stringCalculator.test.js — the grown suite
import { describe, it, expect } from 'vitest';
import { add } from './stringCalculator.js';

describe('String Calculator', () => {
  it('returns 0 for an empty string', () => expect(add('')).toBe(0));
  it('returns the number for a single value', () => expect(add('5')).toBe(5));
  it('sums comma-separated numbers', () => expect(add('1,2,3')).toBe(6));
  it('allows newline separators', () => expect(add('1\n2,3')).toBe(6));
  it('throws on negatives', () => {
    expect(() => add('1,-2')).toThrow(/negatives not allowed: -2/);
  });
});

🎯 Quick Quiz

Question 1: In the TDD cycle, what must be true right after you write a new test in the "Red" phase?

Question 2: During the "Green" phase, what kind of code should you write?

Question 3: What distinguishes BDD from plain TDD?

Summary & Quiz

🎉 Key Takeaways

  • TDD writes the test first, letting tests drive the design of the code.
  • The loop is Red (failing test) → Green (minimal passing code) → Refactor (clean up), repeated in minutes.
  • The same rhythm works in any language — we saw it in Vitest and pytest.
  • Avoid pitfalls: one test at a time, test behavior not internals, and never skip refactor.
  • BDD and ATDD extend the test-first idea toward business-readable behavior.

📚 Further Reading

🚀 What's Next?

Now that you understand the TDD workflow, we'll get hands-on with the tooling: the Jest testing framework — matchers, mocks, setup/teardown, and how to structure a real test suite.

🎉 Great work!

You can now let tests lead the way. Time to master the tools that run them.