May 30, 202610 minEngineering
🧪

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.

ts
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.