Why I Started TDD After 5 Years of Rejecting It
I rejected TDD for 5 years with the excuse "I do not need tests, I am good." Then production exploded because a refactor 2 weeks ago changed the operation order. Tests written after code are not tests, they are formalities.
0xNN · · 9 min read
I rejected TDD for 5 years. During those 5 years, my argument was always the same: "Why write tests before code? That slows you down. I'm good at writing code, I can go straight to production. Tests are for devs who aren't confident in their code."
Then 2023. Production exploded. A payment refund feature I'd written had been running for 6 months without issue, then suddenly double-charged users. I debugged for 8 hours, finally found: a refactor 2 weeks ago had accidentally changed the operation order. The existing tests didn't catch it because they were written after the code, and only verified "code runs," not "code is correct."
That's when I realized: tests written after the code aren't tests. They're formalities. Real tests must be written before the code, because their function isn't to verify the code runs - but to spec that the code is correct. That's TDD. That's what I rejected for 5 years.
This is my experience moving to TDD. Not a tutorial on "what is TDD." But why 5 years of rejection finally made sense, and what changed in how I code.
---
TDD Isn't About "Test First"
Many misunderstand. They think TDD = "write the test function before writing the function." That's superficial.
TDD = Test-Driven Development. Driven = it's the driver. The test drives the design of your code. Not "test first, code later." But "the test determines what code you should write."
Red → Write a failing test (because the code doesn't exist yet)
Green → Write minimal code to make the test pass
Refactor → Clean up the code without changing behavior (test still passes)
Three phases: Red-Green-Refactor. Simple. But the philosophy is deep.
Red - you write a failing test. This defines: "I want to build a function calculateRefund that takes orderId, returns amount, throws an error if the order doesn't exist." The test:
describe('calculateRefund', () => {
it('throws when order does not exist', async () => {
await expect(calculateRefund('order-999')).rejects.toThrow('Order not found')
})
it('returns refund amount for paid order', async () => {
const amount = await calculateRefund('order-123')
expect(amount).toBe(50000)
})
})
The test fails because calculateRefund doesn't exist yet. Good. Now you know exactly: you need to build this function, with this contract.
Green - you write the most minimal code to pass the test. Not pretty code. Not scalable code. Minimal.
async function calculateRefund(orderId) {
const order = await db.findOrder(orderId)
if (!order) throw new Error('Order not found')
return 50000 // hardcoded for now
}
Test passes. Refactor later.
Refactor - now you clean up. Hardcoded 50000 becomes order.amount. Add logic. Test still passes as a safety net.
async function calculateRefund(orderId) {
const order = await db.findOrder(orderId)
if (!order) throw new Error('Order not found')
if (order.status !== 'paid') throw new Error('Order not paid')
return order.amount
}
At every step, the test is verification. You refactor without fear of changing behavior because the test will scream if something's wrong.
---
Why I Rejected It for 5 Years
1. "TDD makes you slow."
Most common argument. And half true. Early on, writing tests = 30-50% extra time. But:
• When refactoring: 0 bugs, because tests are a safety net
• When onboarding new devs: they read tests, immediately understand contracts
• During production incidents: bug fix + new test in 1 hour, not 8 hours
Total: slow at the start, fast at the end. What I didn't realize for 5 years: you pay time upfront, and that investment compounds.
2. "I'm good at writing code, I don't need tests."
That's ego. After the 2023 double-charge incident, I realized: I wasn't good. I was lucky. 6 months without bugs = 6 months of luck. Not proof I was good.
My stats from the last 5 years:
• 70% of production bugs could have been prevented with tests covering edge cases
• 90% of debugging time was spent on "code I thought was right but wasn't"
• 100% of refactors that caused regressions = code without tests
3. "Tests make code inflexible, every code change requires test changes."
True - but that's the point. If every code change requires test changes, you're changing behavior often. Frequently changing behavior = unstable design. "Annoying to update tests" is a signal: your design isn't stable enough, think again.
You don't have to write tests for everything. But critical parts (payments, auth, data integrity) must have tests. Experimental parts can skip. Choose what's critical.
---
What Changed After TDD
1. You think "contract" before "implementation."
I used to write functions directly. Now I write the test first, and the test forces me to think: "what's the input? what's the output? what are the error cases? what are the edge cases?" Implementation becomes clear, just executing the contract.
2. Edge cases become explicit.
I used to write the happy path, then "eh, edge cases later." Production exploded on edge cases. Now edge cases become test cases:
it('throws when amount is zero', () => {})
it('throws when amount is negative', () => {})
it('handles very large amounts', () => {})
it('handles null order field gracefully', () => {})
Every edge case I write, I realize "yeah, this could happen." Tests = executable behavior documentation.
3. Refactoring becomes safe.
This is the biggest benefit for me. Before, refactoring = fear. "If I change this, what will break?" I didn't know. Now: test passes = safe. Test fails = I know exactly what broke.
Refactoring 1000 lines down to 500, with tests passing, is a feeling that can't be described. Like dropping a weight.
4. Onboarding is fast.
New devs join the team, they immediately read the tests. They know: calculateRefund throws if order doesn't exist, returns amount if paid, etc. They can refactor or add features without fear. Tests = documentation that's always up-to-date (if not, the test fails).
---
TDD Isn't a Silver Bullet - When Not to Use It
1. Prototyping / exploration.
You don't know the final shape yet. Writing tests for code that will change 10 times = waste. Write code first, stabilize, then test.
2. UI / styling.
Testing "this button is teal" = low value, high maintenance. Visual regression tests exist, but ROI is low for small teams. Manual QA is still valid for UI.
3. Truly throwaway code.
One-off scripts, internal tooling used by 3 people. Tests = overkill. Write code, run, discard.
4. When you're hotfixing production.
Server down, users complaining. Fix first, test later. TDD isn't dogma. But after the fix, mandatory write a regression test so it doesn't happen again.
---
Quick Comparison: Test After Code vs TDD
| | Test After Code | TDD |
|---|---|---|
| When tests are written | After code is done | Before code |
| Test focus | "Code runs" | "Code is correct" (contract) |
| Edge case coverage | Often missed | Thought through from the start |
| Refactor safety | Weak (tests verify happy path only) | Strong (tests verify the contract) |
| Initial time | Fast | Slow 30-50% |
| Total time (debugging + regressions) | Slow | Faster long-term |
| Mindset | "Code first, then verify" | "Spec first, then implement" |
---
Common Misconceptions
"TDD is dogma, every function must have a test." - Wrong. What matters: business logic, edge cases, critical integrations. Getters/setters, simple CRUD, don't need tests.
"TDD = 100% coverage." - No. 100% coverage isn't the goal. The goal: important behavior is tested. 80% coverage with good tests is better than 100% coverage with tests that only verify "code runs."
"Tests must be perfect from the start." - No. Tests evolve with the code. Start with the happy path, add edge cases later. What matters: the test exists before the code, so it specs, not verifies.
"TDD is only for devs who aren't confident." - Ego. TDD is for devs who know failure. After 1 production incident that could have been prevented with 1 test, your ego will drop.
---
An Honest Closing
TDD isn't about "fast" or "slow." TDD is about a mindset shift. From "I write code, I'm sure it's right" to "I write the spec, code must match the spec." The spec is executable. The code is verifiable. Refactoring is safe.
I rejected it for 5 years out of ego. I thought being good = not needing a safety net. 6 months without bugs isn't proof I was good, it was just probability. When probability finally turned against me, 8 hours of debugging that should have been 30 minutes of writing tests.
The skill I consider mandatory for senior devs isn't just "can write tests." But:
1. Read tests, immediately understand the function contract
2. Write tests that cover edge cases, not just happy paths
3. Refactor with tests as a safety net
4. Know when to skip tests (prototypes, UI, throwaway)
5. Understand the trade-off: slow at the start, fast long-term
If you've never tried TDD, try it on 1 small feature. Write the test first, let it fail, write the code to make it pass. When the test goes green without you changing the test, you'll feel "oh, so that's how." That moment changes your mindset.
If you reject it for 5 years like I did, I'm not forcing you. But when you hit a production incident that 1 test could have prevented, remember this article.
---
Sources
• Martin Fowler: Test Driven Development
• Kent Beck: Test-Driven Development: By Example