2085 words
10 minutes
TDD Isn't Just for Catching Bugs — It's Your Permission Slip

TDD Isn’t Just for Catching Bugs — It’s Your Permission Slip#

The Moment It Clicked#

Three years ago, I had a crisis of confidence with testing.

I’d built a user registration system that was covered—I thought. 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.

Not because the refactoring was bad. Because my tests were brittle. They tested how the code worked, not what it did. The moment the structure changed, they failed.

I had to rewrite half the test suite just to make the refactoring work.

That’s when I realized: I didn’t understand TDD at all.

I thought TDD was about catching bugs. It’s not.

TDD is about permission.

Permission to refactor your code without fear. Permission to improve the design while keeping behavior the same. Permission to say, “I’m going to change everything about how this works, and I’m confident nothing will break.”

That’s the real power.


The Problem: Testing Implementation vs. Behavior#

Let me show you what I was doing wrong.

Here’s the old code (the kind that breaks when you refactor):

// ❌ This test is brittle
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("Too young");
}
this.email = email;
this.age = age;
}
}
// The brittle test
describe("User", () => {
it("should create a user", () => {
const user = new User("alice@example.com", 25);
expect(user.email).toBe("alice@example.com"); // ❌ Tests structure
expect(user.age).toBe(25); // ❌ Tests structure
});
});

What am I testing here? Structure. Not behavior.

Now, what happens when I refactor to hide the email property?

// Refactored (slightly better design)
class User {
private email: string; // ❌ Now private
private age: number; // ❌ Now private
constructor(email: string, age: number) {
if (!email.includes("@")) {
throw new Error("Invalid email");
}
if (age < 18) {
throw new Error("Too young");
}
this.email = email;
this.age = age;
}
getEmail(): string {
return this.email;
}
getAge(): number {
return this.age;
}
}

My test breaks. Not because the behavior changed. Because I changed the structure.

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

That’s the trap.


The Shift: Test Behavior, Not Implementation#

The fix is simple: test what the code does, not how it’s organized.

Here’s what I learned:

// ✅ This test is robust
// A test that verifies BEHAVIOR, not structure
describe("User Registration", () => {
it("should successfully create a user with valid email and age", () => {
// Behavior: Can I create a user with valid inputs?
const result = User.create("alice@example.com", 25);
expect(result.ok).toBe(true);
expect(result.value.getEmail().getValue()).toBe("alice@example.com");
});
it("should reject users without @ in email", () => {
// Behavior: Invalid email is rejected
const result = User.create("not-an-email", 25);
expect(result.ok).toBe(false);
expect(result.error.message).toMatch(/email/i);
});
it("should reject users under 18", () => {
// Behavior: Age rule is enforced
const result = User.create("alice@example.com", 17);
expect(result.ok).toBe(false);
expect(result.error.message).toMatch(/18/i);
});
});

Now, when I refactor the internals, these tests don’t break. They test the contract, not the implementation.

This is TDD’s real power.


Enter: The Result<T, E> Pattern#

Here’s the pattern that made this click for me: Result types.

Instead of throwing exceptions (which are hidden control flow), treat errors as first-class values.

// The Result Type (the permission slip)
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
// Helper functions to create Results
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}

Now, instead of:

// ❌ Old way: exceptions (hidden, hard to test)
function validateEmail(email: string) {
if (!email.includes("@")) {
throw new Error("Invalid email");
}
return email;
}
// Testing this is awkward
expect(() => validateEmail("bad")).toThrow();

You do:

// ✅ New way: explicit Results
class InvalidEmailError {
message = "Invalid email format";
}
function validateEmail(email: string): Result<string, InvalidEmailError> {
const normalized = email.trim().toLowerCase();
if (!normalized.includes("@")) {
return err(new InvalidEmailError());
}
return ok(normalized);
}
// Testing this is crystal clear
const result = validateEmail("bad");
if (!result.ok) {
console.log(result.error.message); // ✅ Explicit
}

Why does this matter for TDD?

Because now your tests are explicit about both paths (success and failure). You’re not relying on exceptions to convey intent. Errors are data.


The Real Impact: My Refactoring Story#

Fast forward to last year. I was refactoring a production payment system.

Thousands of lines. Complex state. High stakes.

With my old approach (exceptions + brittle tests), I would have spent a week on this.

With Result<T, E> + behavior-based tests, I spent a day.

Here’s why:

Old approach:

  1. Write refactoring
  2. Tests break (because they test structure)
  3. Rewrite tests while refactoring
  4. Uncertainty: “Did I break something, or just the tests?”
  5. Conservative refactoring (minimalist changes to keep tests green)
  6. Weak design improvements

New approach:

  1. Write refactoring
  2. Tests still pass (because they test behavior)
  3. Confidence: “Tests pass = behavior is preserved”
  4. Bold refactoring (aggressive design improvements)
  5. Strong design improvements, high confidence

The tests become a permission slip. You can refactor aggressively because you trust the tests to catch real problems, not structural changes.

That payment system? I cleaned up 40% of the code while improving performance by 30%. The tests guided me every step.

With the old tests, I would have tiptoed through it.


Why This Matters: The Three Levels#

Let me be clear about what’s happening here. There are three levels to testing:

Level 1: Testing For Bugs (What Most Teams Do)#

// ❌ Level 1: Testing implementation
it("should set email to alice@example.com", () => {
const user = new User("alice@example.com", 25);
expect(user.email).toBe("alice@example.com");
});

Problem: Test breaks if you refactor the property structure. Not robust.

Level 2: Testing Behavior (What TDD Actually Is)#

// ✅ Level 2: Testing observable behavior
it("should accept valid emails", () => {
const result = User.create("alice@example.com", 25);
expect(result.ok).toBe(true);
});
it("should reject invalid emails", () => {
const result = User.create("no-at", 25);
expect(result.ok).toBe(false);
});

Better: Tests survive refactoring because they test the contract.

Level 3: Testing As Design (What Great Teams Do)#

// ✅ Level 3: Tests guide design
// Tests are written FIRST, guiding the implementation
describe("Email Value Object", () => {
it("should create valid email", () => {
const result = Email.create("alice@example.com");
expect(result.ok).toBe(true);
});
it("should reject invalid email", () => {
const result = Email.create("no-at");
expect(result.ok).toBe(false);
});
});
// Now, to make these tests pass, you *design* an Email class
// that validates itself. The design emerges from the tests.

Best: Tests guide your design before you write the code. This is TDD.

Most teams never reach level 3 because they don’t understand that tests are about design permission, not bug catching.


The Myth: “TDD Is Slow”#

I used to think this.

“TDD slows me down. I just want to code.”

Then I realized: TDD isn’t slow. Refactoring without tests is slow.

When I couldn’t refactor safely (old approach), I coded defensively. I avoided changes. I left bad code in place because changing it was risky.

That’s actually slow. It compounds with every month.

With TDD + Result<T, E>:

  • I refactor constantly
  • Design improves over time
  • Code becomes easier to change
  • New features are faster

The upfront “time” of writing tests disappears when you count the refactoring time you save.


The Second Shift: Encoding Rules Into Types#

Once I understood this, I realized the next level: encoding your domain rules into types themselves.

Instead of:

// ❌ Rules scattered everywhere
function registerUser(email: string, age: number) {
// Validation here
if (!email.includes("@")) {
return err(new InvalidEmailError());
}
// And here
if (age < 18) {
return err(new InvalidAgeError());
}
// And again here
const user = new User(email, age);
// ...
}
function changeEmail(userId: string, newEmail: string) {
// Validation repeated
if (!newEmail.includes("@")) {
return err(new InvalidEmailError());
}
// ...
}

You create Value Objects that validate themselves:

// ✅ Rules encoded in types
class Email {
private constructor(private readonly value: string) {}
static create(value: string): Result<Email, InvalidEmailError> {
if (!value.includes("@")) {
return err(new InvalidEmailError());
}
return ok(new Email(value));
}
getValue(): string {
return this.value;
}
}
class Age {
private constructor(private readonly value: number) {}
static create(value: number): Result<Age, InvalidAgeError> {
if (value < 18) {
return err(new InvalidAgeError());
}
return ok(new Age(value));
}
getValue(): number {
return this.value;
}
}
// Now these functions are trivial
function registerUser(emailStr: string, ageNum: number) {
const emailResult = Email.create(emailStr);
if (!emailResult.ok) return err(emailResult.error);
const ageResult = Age.create(ageNum);
if (!ageResult.ok) return err(ageResult.error);
return ok(new User(emailResult.value, ageResult.value));
}

The rules live in one place: the Email and Age classes.

Every time you use Email, you get validation for free. No repeated validation logic. No scattered rules.

This is what happens when you design from tests.


How This Changed My Code#

Let me be concrete. Here’s what changed:

Before (2021):

  • 500-line “User” class (doing too much)
  • Validation scattered across 4 different modules
  • Tests were brittle (implementation-focused)
  • Refactoring took weeks
  • Bugs appeared after refactoring

After (2024):

  • Email, Age classes (focused, ~50 lines each)
  • Validation in one place (the Value Object)
  • Tests are robust (behavior-focused)
  • Refactoring takes days
  • No regressions

The difference? TDD + Result<T, E> + thinking about design as “what tests do I need?”


The Permission Slip Moment#

Here’s the metaphor that stuck with me:

Tests are a permission slip.

When you have good tests, you have permission to:

  • ✅ Refactor aggressively
  • ✅ Improve design
  • ✅ Change internals
  • ✅ Delete code
  • ✅ Reorganize modules
  • ✅ Take risks

Without good tests, you don’t have permission. You code conservatively. You leave bad code in place because changing it is scary.

That’s not sustainable.

TDD gives you permission.

But only if you test behavior, not implementation. Only if you treat errors as values, not exceptions. Only if you encode your rules into types.


The Myth-Bust#

“But TDD is slow” → No, refactoring without tests is slow. TDD saves time long-term.

“But tests are boring” → No, they’re your design conversation. They tell you what your code should do before you write it.

“But I’ll add tests later” → You won’t. And if you do, they’ll be brittle because you’ll test implementation.

“But I don’t have time” → You don’t have time not to. The time you “save” now costs you 10x later in technical debt.

“But this is overkill for simple code” → No code stays simple. Today’s simple code becomes tomorrow’s complex system. Better to start right.


What This Means For You#

If you’re reading this and thinking, “Yeah, I’ve felt this pain,” here’s what I want you to do:

This week:

  1. Take one function you wrote recently
  2. Rewrite its tests to verify behavior instead of implementation
  3. Notice how it changes your function design

That’s it. One function.

When you do this, you’ll feel the shift. You’ll see how tests guide design.

That’s TDD.

Next week, we’re going deeper. I’ll show you:

  • How to structure code so it’s testable (Value Objects + Entities)
  • How to refactor safely (Aggregates + Repositories)
  • How to build systems that don’t break (Domain-Driven Design)

Each article builds on this one: test behavior, not implementation.


The Real Reason I’m Writing This#

I’m building a course on TDD + DDD for backend developers.

Not because testing is trendy. Because I’ve seen teams transform when they understand this.

Code that was a mess becomes elegant. Developers who were afraid to refactor become confident. Systems that were brittle become robust.

The shift is always the same: test behavior, give yourself permission, improve design.

If that resonates, I have a free cheatsheet that breaks down the key patterns (Result<T, E>, Value Objects, Aggregates). Grab it below.

And if you want the full system—how to build backends that don’t break—the course is coming in a few weeks.

For now, start with one function. Rewrite its tests. Feel the shift.

That’s the permission slip.


Summary: The Permission Slip#

TDD isn’t about catching bugs. It’s about permission.

Permission to refactor without fear. Permission to improve design. Permission to change internals while keeping behavior the same.

To get this permission:

  1. Test behavior, not implementation — What does your code do, not how?
  2. Treat errors as values — Use Result<T, E> instead of exceptions
  3. Encode rules into types — Value Objects that validate themselves
  4. Refactor boldly — Tests will catch real problems, not structural changes

The pattern that makes this concrete is the Result<T, E> type. It makes errors explicit, tests easier, and refactoring safer.

I spent three years learning this. You can understand it in one article.

Then the real work begins: designing systems that don’t break.

That’s next week.

For now, grab the cheatsheet. It has the patterns. And start with one function.


What’s Next?#

Next article: “Value Objects & Entities — Building Blocks That Can’t Break”

We’ll build on this foundation and show how to structure your code so that invalid states become impossible. Not just hard to create, but literally impossible. Your types will reject bad data before it exists.

Subscribe below for the full series. Each article builds on the last.


The Free Gift: TDD Cheatsheet#

[LEAD MAGNET FORM HERE - ConvertKit]

Get the TDD Cheatsheet: 8-page PDF with the patterns, the Result<T, E> type, testing templates, and a checklist to audit your own code.


Questions? Hit reply. I read everything.

— Corentin

P.S. If you’re thinking “this is too much,” it’s not. Start with one function. Rewrite its tests to test behavior. See what happens. That’s TDD. The rest is just scaling up what you’ve already learned.

TDD Isn't Just for Catching Bugs — It's Your Permission Slip
https://corentings.dev/blog/tdd-permission-slip/
Author
Corentin Giaufer Saubert
Published at
2026-02-24
License
CC BY-NC-SA 4.0

The Backend Blueprint

Get weekly backend engineering insights delivered to your inbox.