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 testdescribe("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 structuredescribe("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 Resultsfunction 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 awkwardexpect(() => 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 clearconst 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:
- Write refactoring
- Tests break (because they test structure)
- Rewrite tests while refactoring
- Uncertainty: “Did I break something, or just the tests?”
- Conservative refactoring (minimalist changes to keep tests green)
- Weak design improvements
New approach:
- Write refactoring
- Tests still pass (because they test behavior)
- Confidence: “Tests pass = behavior is preserved”
- Bold refactoring (aggressive design improvements)
- 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 implementationit("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 behaviorit("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 trivialfunction 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:
- Take one function you wrote recently
- Rewrite its tests to verify behavior instead of implementation
- 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:
- Test behavior, not implementation — What does your code do, not how?
- Treat errors as values — Use Result<T, E> instead of exceptions
- Encode rules into types — Value Objects that validate themselves
- 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.
The Backend Blueprint
Get weekly backend engineering insights delivered to your inbox.