TDD Workflow with a Real-World Example
Walk through the red-green-refactor cycle step by step, building a password validator to learn practical test-driven development.
What you'll learn
- ✓The red-green-refactor cycle and why the order matters
- ✓Building a real feature from scratch using TDD
- ✓When to write the simplest possible code vs. the "right" code
- ✓Common TDD mistakes and how to avoid them
Prerequisites
- •Basic unit testing knowledge
- •Familiarity with JavaScript or TypeScript
What TDD Actually Is
Test-Driven Development is a workflow, not a testing technique. The process is deceptively simple:
- Red: Write a test that fails.
- Green: Write the minimum code to make it pass.
- Refactor: Clean up the code while keeping all tests green.
Repeat. Every feature starts with a failing test. You never write production code without a test that demands it.
This feels backward at first. You are writing tests for code that does not exist yet. But TDD is not about testing — it is about design. Writing the test first forces you to think about the API before the implementation. It prevents over-engineering because you only write code that a test requires.
The Example: A Password Validator
We will build a validatePassword function using TDD. The requirements:
- At least 8 characters long.
- Contains at least one uppercase letter.
- Contains at least one lowercase letter.
- Contains at least one digit.
- Returns an object with
valid: booleananderrors: string[].
We will use Vitest, but the approach works with any test framework.
Setup
npm install --save-dev vitest
Create two files:
src/validatePassword.ts # (empty for now)
src/validatePassword.test.ts
Cycle 1: The Simplest Case
Red — write a test that fails:
// src/validatePassword.test.ts
import { describe, it, expect } from "vitest";
import { validatePassword } from "./validatePassword";
describe("validatePassword", () => {
it("returns valid for a password meeting all criteria", () => {
const result = validatePassword("Abcdefg1");
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
});
Run the test:
npx vitest run
It fails because validatePassword does not exist. Good. Red.
Green — write the minimum code to pass:
// src/validatePassword.ts
export function validatePassword(password: string) {
return { valid: true, errors: [] as string[] };
}
This is absurdly simple. It always returns valid. But the test passes. That is the point — we only write what a test demands. The next test will force us to add real logic.
Refactor — nothing to clean up yet.
Cycle 2: Minimum Length
Red:
it("rejects passwords shorter than 8 characters", () => {
const result = validatePassword("Ab1");
expect(result.valid).toBe(false);
expect(result.errors).toContain("Must be at least 8 characters");
});
This fails because our function always returns valid: true.
Green:
export function validatePassword(password: string) {
const errors: string[] = [];
if (password.length < 8) {
errors.push("Must be at least 8 characters");
}
return { valid: errors.length === 0, errors };
}
Both tests pass now.
Refactor — the code is clean enough. Move on.
Cycle 3: Uppercase Requirement
Red:
it("rejects passwords without uppercase letters", () => {
const result = validatePassword("abcdefg1");
expect(result.valid).toBe(false);
expect(result.errors).toContain("Must contain at least one uppercase letter");
});
Green:
export function validatePassword(password: string) {
const errors: string[] = [];
if (password.length < 8) {
errors.push("Must be at least 8 characters");
}
if (!/[A-Z]/.test(password)) {
errors.push("Must contain at least one uppercase letter");
}
return { valid: errors.length === 0, errors };
}
All three tests pass.
Cycle 4: Lowercase Requirement
Red:
it("rejects passwords without lowercase letters", () => {
const result = validatePassword("ABCDEFG1");
expect(result.valid).toBe(false);
expect(result.errors).toContain("Must contain at least one lowercase letter");
});
Green:
if (!/[a-z]/.test(password)) {
errors.push("Must contain at least one lowercase letter");
}
Cycle 5: Digit Requirement
Red:
it("rejects passwords without digits", () => {
const result = validatePassword("Abcdefgh");
expect(result.valid).toBe(false);
expect(result.errors).toContain("Must contain at least one digit");
});
Green:
if (!/[0-9]/.test(password)) {
errors.push("Must contain at least one digit");
}
Cycle 6: Multiple Errors
This test does not require new code, but it verifies an important behavior:
it("returns all errors at once", () => {
const result = validatePassword("ab");
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(3);
expect(result.errors).toContain("Must be at least 8 characters");
expect(result.errors).toContain("Must contain at least one uppercase letter");
expect(result.errors).toContain("Must contain at least one digit");
});
This passes without changes. It confirms that our implementation collects all errors rather than short-circuiting on the first one.
Time to Refactor
Now we have six passing tests and our implementation looks like this:
export function validatePassword(password: string) {
const errors: string[] = [];
if (password.length < 8) {
errors.push("Must be at least 8 characters");
}
if (!/[A-Z]/.test(password)) {
errors.push("Must contain at least one uppercase letter");
}
if (!/[a-z]/.test(password)) {
errors.push("Must contain at least one lowercase letter");
}
if (!/[0-9]/.test(password)) {
errors.push("Must contain at least one digit");
}
return { valid: errors.length === 0, errors };
}
The code works but is repetitive. Let us refactor using a rules array:
interface Rule {
test: (password: string) => boolean;
message: string;
}
const rules: Rule[] = [
{
test: (p) => p.length >= 8,
message: "Must be at least 8 characters",
},
{
test: (p) => /[A-Z]/.test(p),
message: "Must contain at least one uppercase letter",
},
{
test: (p) => /[a-z]/.test(p),
message: "Must contain at least one lowercase letter",
},
{
test: (p) => /[0-9]/.test(p),
message: "Must contain at least one digit",
},
];
export function validatePassword(password: string) {
const errors = rules
.filter((rule) => !rule.test(password))
.map((rule) => rule.message);
return { valid: errors.length === 0, errors };
}
Run all tests. They still pass. The refactoring changed the structure without changing the behavior, and our tests confirm it.
Adding a New Rule Is Easy Now
Suppose the product manager asks for a special character requirement. The TDD cycle:
Red:
it("rejects passwords without special characters", () => {
const result = validatePassword("Abcdefg1");
expect(result.valid).toBe(false);
expect(result.errors).toContain("Must contain at least one special character");
});
Green — add one rule:
{
test: (p) => /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(p),
message: "Must contain at least one special character",
},
Refactor — update the first test to include a special character:
it("returns valid for a password meeting all criteria", () => {
const result = validatePassword("Abcdefg1!");
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
The rules-based design made this trivial. TDD guided us to a clean architecture.
Common TDD Mistakes
Writing too many tests before any code
TDD is one test at a time. Writing five tests and then implementing them all defeats the purpose. Each test should drive one small design decision.
Skipping the refactor step
The refactor step is where design happens. Without it, TDD produces working but messy code. After every green, ask: “Is there duplication? Can I extract a pattern? Is the naming clear?”
Making the green step too big
When the test fails, write the smallest change that makes it pass. If you find yourself writing 20 lines to make one test pass, your test is probably too ambitious. Break it into smaller steps.
Testing implementation instead of behavior
// Bad: tests implementation
it("calls the regex test method", () => {
const spy = vi.spyOn(RegExp.prototype, "test");
validatePassword("abc");
expect(spy).toHaveBeenCalled();
});
// Good: tests behavior
it("rejects short passwords", () => {
expect(validatePassword("abc").valid).toBe(false);
});
Not running tests after refactoring
Every refactor must be followed by running the full test suite. If you refactor without running tests, you are not doing TDD — you are just editing code and hoping.
When TDD Works Best
TDD is most effective for:
- Pure functions with clear inputs and outputs (validators, calculators, formatters).
- Business logic where requirements can be expressed as rules.
- Bug fixes — write a test that reproduces the bug first, then fix it.
- Library/API design — writing the test first helps you design a usable API.
TDD is harder (but still possible) for:
- UI components — consider pairing TDD with visual tests.
- Integration with external systems — requires fakes or mocks.
- Exploratory code — when you do not know what the output should look like.
The Full Test Suite
Here is the complete test file:
import { describe, it, expect } from "vitest";
import { validatePassword } from "./validatePassword";
describe("validatePassword", () => {
it("returns valid for a password meeting all criteria", () => {
const result = validatePassword("Abcdefg1!");
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("rejects passwords shorter than 8 characters", () => {
const result = validatePassword("Ab1!");
expect(result.valid).toBe(false);
expect(result.errors).toContain("Must be at least 8 characters");
});
it("rejects passwords without uppercase letters", () => {
const result = validatePassword("abcdefg1!");
expect(result.valid).toBe(false);
expect(result.errors).toContain("Must contain at least one uppercase letter");
});
it("rejects passwords without lowercase letters", () => {
const result = validatePassword("ABCDEFG1!");
expect(result.valid).toBe(false);
expect(result.errors).toContain("Must contain at least one lowercase letter");
});
it("rejects passwords without digits", () => {
const result = validatePassword("Abcdefgh!");
expect(result.valid).toBe(false);
expect(result.errors).toContain("Must contain at least one digit");
});
it("rejects passwords without special characters", () => {
const result = validatePassword("Abcdefg1");
expect(result.valid).toBe(false);
expect(result.errors).toContain("Must contain at least one special character");
});
it("returns all errors at once", () => {
const result = validatePassword("ab");
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThanOrEqual(3);
});
});
Key Takeaways
TDD is a design discipline disguised as a testing practice. The red-green-refactor cycle keeps you focused on one small step at a time, prevents over-engineering, and produces code with thorough test coverage as a side effect. Start with the simplest failing test, write the minimum code to pass it, then refactor to clean design. The password validator example shows how TDD naturally leads to an extensible rules-based architecture. The tests guided the design — not the other way around.
Related articles
- Testing Test-Driven Development (TDD): A Practical Guide
Learn the TDD workflow with real examples in Python and JavaScript. Covers red-green-refactor, when TDD works best, and how to handle common challenges.
- Python Python Testing with pytest: Fixtures, Marks, and Plugins
Master pytest from fixtures and parametrize to marks, plugins, and CI integration. Write fast, maintainable Python tests with practical examples.
- Java JUnit 5 Tutorial: Writing Clean, Modern Java Tests
A complete introduction to JUnit 5. Learn the new architecture, lifecycle, parameterized tests, assertions, and patterns that keep your test suite fast and readable.
- Testing Integration Testing: Test Real Dependencies, Not Mocks
Learn how to write effective integration tests that verify real database queries, API calls, and service interactions using testcontainers and proper test infrastructure.