My Experience with Test-Driven Development
Why I changed how I write code
Software is never truly finished. Requirements evolve, edge cases appear, and features that looked complete yesterday break after one new change today. I used to write code first and tests later (or never). That usually led to long QA loops, regressions, and uncertainty while refactoring.
TDD did not remove complexity. It gave me a way to control it.
— Robin Singh
What TDD actually means (in simple words)
Core idea
- →1) Write a test for a small behavior before writing implementation.
- →2) Run tests and watch it fail (Red).
- →3) Write the minimum code to pass the test (Green).
- →4) Clean up the code while keeping tests green (Refactor).
This cycle forces clarity. Before coding, I have to decide what success looks like. That one habit alone improved how I design functions, APIs, and components.
Why this helped me in real projects
Practical benefits I noticed
- →Faster debugging: failures point directly to broken behavior.
- →Safer refactors: I can restructure code without guessing what might break.
- →Better architecture: logic naturally moves into testable units instead of bloated UI files.
- →Less QA ping-pong: many regressions are caught before the ticket goes for review.
- →Higher ownership: if I break something, I catch and fix it immediately.
↓
Re-opened tickets
↑
Confidence to refactor
lower
Time spent firefighting
Red → Green → Refactor example
A tiny example shows the flow better than theory. First, define behavior through tests. Then implement just enough logic to satisfy them.
import { describe, it, expect } from 'vitest'
// RED: define behavior first
describe('calculateDiscount', () => {
it('returns 10% discount for premium users', () => {
expect(calculateDiscount(200, 'premium')).toBe(20)
})
it('returns 0 for standard users', () => {
expect(calculateDiscount(200, 'standard')).toBe(0)
})
})
// GREEN: minimum code to pass tests
function calculateDiscount(amount: number, tier: 'premium' | 'standard') {
if (tier === 'premium') return amount * 0.1
return 0
}A mistake I made (and how TDD fixed it)
In one feature, I mixed API calls, formatting rules, and UI state in the same component. Tests became fragile because network responses changed. I split the transformation logic into a pure function and tested that function independently. Result: simpler component, stable tests, and faster feature updates.
Anti-patterns I avoid now
- →Writing huge tests that verify too many things at once.
- →Mocking everything blindly instead of isolating one unit clearly.
- →Asserting implementation details instead of behavior.
- →Treating test count as quality. Reliable tests matter more than many tests.
How to start TDD without getting overwhelmed
Beginner-friendly starting plan
- →Pick one small pure function (formatting, validation, mapping).
- →Write 2-3 clear tests for expected behavior and edge cases.
- →Use naming like "should return X when Y" so intent is obvious.
- →Keep one test file close to the code it validates.
- →Run tests frequently; keep cycles short (2-10 minutes each).
Checklist for a good test
Before you commit
- →Is the test readable without opening implementation?
- →Does it fail for the right reason when behavior breaks?
- →Is it deterministic (no flaky network/time/random dependencies)?
- →Does it validate outcome, not internal private details?
Start small, stay consistent, and let tests guide design. TDD becomes powerful when it is a daily habit, not a one-time sprint ritual.