8 min left
1526 words
8 minutes

TDD Isn't About Bugs — It's Your Permission to Refactor

TDD Isn’t About Bugs — It’s Your Permission to Refactor#

The Moment It Clicked#

Three years ago, I had a crisis of confidence with test-driven development.

I’d built a user registration system with 80% test coverage, all green, CI passing. Then I decided to refactor the email validation logic: just move it to a separate module. A tiny change.

Thirty minutes later, everything was broken.

My tests tested how the code worked, not what it did. The moment the structure changed, they failed. I spent more time fixing tests than doing the refactoring itself.

I’ll show you what I was doing wrong. It might look familiar.


The Trap: Testing Structure#

Here’s the TypeScript code I had, a User class with inline validation:

class User {
email: string;
age: number;
constructor(email: string, age: number) {
if (!email.includes("@")) {
throw new Error("Invalid email");
}
if (age < 18) {
throw new Error("Must be at least 18 years old");
}
this.email = email;
this.age = age;
}
}

And the test:

describe("User", () => {
it("should create a user", () => {
const user = new User("[email protected]", 25);
expect(user.email).toBe("[email protected]");
expect(user.age).toBe(25);
});
});

What am I testing? Structure. I’m reaching into the object and checking its properties directly.

Now watch what happens when I hide the internals:

class User {
private email: string;
private age: number;
constructor(email: string, age: number) {
if (!email.includes("@")) {
throw new Error("Invalid email");
}
if (age < 18) {
throw new Error("Must be at least 18 years old");
}
this.email = email;
this.age = age;
}
get email(): string {
return this.email;
}
get age(): number {
return this.age;
}
}

The test breaks. The behavior hasn’t changed (the same user is created with the same rules), but I changed the structure.

I’m now rewriting tests for a refactoring that shouldn’t require any test changes.


The Fix: Test Behavior#

The solution: test what the code does, not how it’s organized.

But first, I need to fix another problem. Throwing exceptions makes testing awkward: you need expect(() => fn()).toThrow(), which hides the error behind a callback. The fix is treating errors as first-class values instead of hidden control flow.

Here’s the pattern that made this click, the Result type:

type Result<T, E> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}

Instead of throwing, functions return a Result that’s either a success or a failure. Both paths are explicit. No try/catch. Errors are data you can inspect, test, and chain. (In production code, you’d use a library like neverthrow which adds map, flatMap, and match for composing results.)

With Result in place, I can rewrite the User class with a static factory method:

class UserCreationError {
constructor(
public readonly message: string,
public readonly field: string
) {}
}
class User {
private constructor(
private readonly _email: string,
private readonly _age: number
) {}
static create(email: string, age: number): Result<User, UserCreationError> {
if (!email.includes("@")) {
return err(new UserCreationError("Invalid email format", "email"));
}
if (age < 18) {
return err(new UserCreationError("Must be at least 18 years old", "age"));
}
return ok(new User(email, age));
}
get email(): string {
return this._email;
}
get age(): number {
return this._age;
}
}

Now the tests verify behavior, the observable contract:

describe("User Registration", () => {
it("should create a user with valid email and age", () => {
const result = User.create("[email protected]", 25);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.email).toBe("[email protected]");
expect(result.value.age).toBe(25);
}
});
it("should reject invalid emails", () => {
const result = User.create("not-an-email", 25);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.field).toBe("email");
expect(result.error.message).toMatch(/email/i);
}
});
it("should reject users under 18", () => {
const result = User.create("[email protected]", 17);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toMatch(/18/);
}
});
});

When I refactor the internals now (make _email private, change the data structure, extract a class), these tests keep passing. They test the contract: valid inputs succeed, invalid inputs fail with the right error. The internal structure is irrelevant.

This is the red-green-refactor cycle that defines test-driven development: write a failing test (red), make it pass with the simplest code (green), then improve the design while keeping tests green (refactor). The tests give you permission to refactor aggressively because they catch real problems, not structural changes.


Going Deeper: Value Objects#

Once I started testing behavior, I noticed something. My validation logic was scattered. The same email check appeared in registerUser, changeEmail, and resetPassword. Three places, same rule, same potential for bugs.

The fix is a Value Object: a small, self-validating type that represents a single concept. If you have an Email instance, it’s valid by construction:

class InvalidEmailError {
readonly message = "Invalid email format";
}
class Email {
private constructor(private readonly _value: string) {}
static create(raw: string): Result<Email, InvalidEmailError> {
const value = raw.trim().toLowerCase();
if (!value.includes("@")) {
return err(new InvalidEmailError());
}
return ok(new Email(value));
}
get value(): string {
return this._value;
}
equals(other: Email): boolean {
return this._value === other._value;
}
}

Notice the normalization (trim().toLowerCase()) lives inside the Value Object. Every Email instance is guaranteed lowercase and trimmed. The validation rule exists in one place. If the rule changes, you change one class.

Similarly for Age:

class InvalidAgeError {
readonly message = "Must be at least 18 years old";
}
class Age {
private constructor(private readonly _value: number) {}
static create(value: number): Result<Age, InvalidAgeError> {
if (!Number.isInteger(value) || value < 18) {
return err(new InvalidAgeError());
}
return ok(new Age(value));
}
get value(): number {
return this._value;
}
}

The User class now composes these Value Objects instead of accepting raw primitives:

class User {
private constructor(
private readonly _email: Email,
private readonly _age: Age
) {}
static create(email: string, age: number): Result<User, UserCreationError> {
const emailResult = Email.create(email);
if (!emailResult.ok)
return err(new UserCreationError(emailResult.error.message, "email"));
const ageResult = Age.create(age);
if (!ageResult.ok)
return err(new UserCreationError(ageResult.error.message, "age"));
return ok(new User(emailResult.value, ageResult.value));
}
get email(): string {
return this._email.value;
}
get age(): number {
return this._age.value;
}
}

The same tests from before still pass. They test User.create() returning success or failure, which hasn’t changed. The internal composition with Value Objects is invisible to the tests.


The Payment System#

Last year, I refactored a production payment processing module: about 2,400 lines of conditional logic scattered across processPayment, validatePayment, and handleRefund. Validation rules were duplicated in all three functions.

The old tests looked like this:

it("processes payment", () => {
const processor = new PaymentProcessor(mockGateway, mockDB);
processor.validateAmount(100);
processor.validateCurrency("USD");
const result = processor.process({ amount: 100, currency: "USD" });
expect(result.status).toBe("success");
expect(mockGateway.charge).toHaveBeenCalledTimes(1);
});

Those tests called internal methods directly. When I extracted validation into a Payment Value Object, every test broke because validateAmount and validateCurrency no longer existed as separate methods.

I rewrote the tests to focus on the contract:

it("accepts valid payments", () => {
const result = Payment.create({ amount: 100, currency: "USD" });
expect(result.ok).toBe(true);
});
it("rejects negative amounts", () => {
const result = Payment.create({ amount: -50, currency: "USD" });
expect(result.ok).toBe(false);
});

Then I refactored aggressively. Extracted Currency as its own Value Object. Moved validation into the Payment factory. Deleted 800 lines of duplicated logic. Tests never broke once, because they tested what happened, not how.

The whole refactoring took a day. With the old tests, it would have taken a week: three days of refactoring, two days of fixing tests.

The same idea shows up in concurrency code too. When you refactor a pipeline or a worker pool, the contract stays the same while the internals change. The tests that survive are the ones that verify behavior, not goroutine counts.


The “TDD Is Slow” Myth#

I used to think test-driven development slowed me down. Studies tell a more nuanced story: TDD has a modest upfront cost of 10–30% more development time, but reduces defects by 40–90% (Nagappan et al., Microsoft/IBM, 2008). George & Williams (2003) found TDD pairs took 16% more time but passed 18% more black-box tests.

The upfront cost is real. The payoff is also real. And it compounds: every safe refactoring makes the next one easier.

When I couldn’t refactor safely, I coded defensively. I avoided changes. I left bad code in place because changing it was risky. Refactoring without tests is what’s actually slow. It compounds with every month.


When TDD Doesn’t Help#

Test-driven development isn’t always the right tool. Spikes and prototypes (code you’ll throw away) don’t need tests. Exploratory work where you don’t know the shape of the solution yet. UI code where the behavior is visual, not logical. One-off scripts. Code with genuinely unstable requirements that change every sprint.

TDD pays off when you’re building something you’ll maintain and refactor over time. If the code won’t survive the week, write the minimum.


Monday Morning#

Take one function you wrote recently. Rewrite its tests to verify behavior (what it does) instead of implementation (how it’s organized).

Watch what happens to your code.

Three years ago, I was afraid to refactor. Now I do it aggressively, because my tests give me permission. Test-driven development gave me a permission slip I didn’t know I needed.

Next time, I’ll show you how to build Value Objects that make invalid states impossible, and how testing them first makes the design emerge naturally.

If you found this useful, the next article in the series shows how to build Value Objects that make invalid states impossible. It comes out in a couple of weeks. You can subscribe via RSS or Atom to catch it when it lands.

— Corentin

TDD Isn't About Bugs — It's Your Permission to Refactor
https://corentings.dev/blog/tdd-permission-slip/
Author
Corentin Giaufer Saubert
Published at
2026-05-24
License
CC BY-NC-SA 4.0
Share this post