DEV Community

Timevolt
Timevolt

Posted on

The TDD Awakens: Why and How to Start

The Quest Begins (The "Why")

I still remember the first time I shipped a feature that felt solid… until it wasn’t. I’d spent the afternoon crafting a neat little function that calculated a discount based on a user’s loyalty tier. The code looked clean, I ran the test    after the  code  and  thought  “Done!”  Only  later  did  a  QA  engineer  point  out  that  when  the  discount  exceeded  100 %  the  function  returned  a  negative  price.  I  had  missed  the  edge  case  entirely.  That  bug  felt  like  rolling  a  stone  up  a  hill  only  to  watch  it  come  crashing  down  again  —  Sisyphus  with  a  laptop.

I  started  asking  myself  why  my  tests  were  always  an  afterthought.  Was  I  just  writing  code   same  way  over  and  over,  hoping  the  bugs  would  magically  disappear?  The  truth  was  that  I  was  treating  tests  like  a  check‑list  item  instead  of  the  compass  that  guides  the  design.

The Revelation (The Insight)

The  single  best  practice  that  changed  everything  for  me  was  write  a  failing  test  for  one  small  behavior  before  you  write  any  production  code.  In  TDD  lingo  that’s  the  “Red”  step  of  Red‑Green‑Refactor.

Why  does  that  tiny  habit  shift  the  whole  game?

1.  It  forces  you  to  think  about  the  API  first.  You  ask  “What  should  this  function  do  given  these  inputs?”  instead  of  “How  do  I  make  the  code  work?”

2.  It  makes  edge  cases  visible  right  away.  When  the  test  fails,  you  see  exactly  what  is  missing.

3.  It  gives  you  instant  feedback.  Every  time  you  run  the  test  you  know  whether  you  moved  forward  or  stepped  backward.

4.  It  turns  the  test  suite  into  living  documentation  that  never  gets  out‑of‑date  (because  it  is  the  spec).

In  short,  writing  the  test  first  turns  coding  from  a  solo  jam  session  into  a  conversation  with  your  future  self  (and  your  teammates).

Wielding the Power (Code & Examples)

The  “Before”  –  Code  First,  Test  Later

Let’s  look  at  a  simple  discount  calculator  that  I  once  built  the  old‑fashioned  way.

// discount.js  –‑ written first
function calculateDiscount(price, loyaltyPoints) {
  // 1 point = 0.1% off, max 20% off
  const rate = Math.min(loyaltyPoints * 0.001, 0.2);
  return price * (1 - rate);
}

module.exports = { calculateDiscount };
Enter fullscreen mode Exit fullscreen mode

I  felt  proud  of  the  logic  and  moved  on  to  the  next  ticket.  Later,  I  wrote  a  test  (​after‑the‑fact​)  that  only  checked  the  happy  path:

// discount.test.js  –‑ written after
const { calculateDiscount } = require('./discount');

test('applies discount for loyal customer', () => {
  expect(calculateDiscount(100, 50)).toBeCloseTo(95); // 5% off
});
Enter fullscreen mode Exit fullscreen mode

All  tests  passed,  so  I  merged.  A  week  later  the  support  team  got  a  ticket:  “User  with  500  points  got  a  negative  price.”  The  bug?  My  function  allowed  the  discount  rate  to  exceed  1  (100 %)  when  loyaltyPoints  >  1000,  but  the  test  never  touched  that  branch.  Because  I  wrote  the  test  after,  I  had  no  incentive  to  think  about  that  edge  case  until  it  blew  up  in  production.

The  “After”  –  Test  First,  Then  Code

Now  let’s  do  the  same  feature  using  the  TDD  best  practice:  write  a  failing  test  for  one  behavior,  make  it  pass,  then  refactor.

Step  1  –  Write  the  failing  test  (normal  case)

// discount.test.js  –‑ written first
const { calculateDiscount } = require('./discount');

test('applies 10% discount for 100 loyalty points', () => {
  expect(calculateDiscount(200, 100)).toBe(180); // 200 * (1 - 0.1)
});
Enter fullscreen mode Exit fullscreen mode

Run  the  test  →  RED  (because  discount.js  doesn’t  exist  yet).

Step  2  –  Make  the  test  pass  (with  the  simplest  code)

// discount.js
function calculateDiscount(price, loyaltyPoints) {
  const rate = loyaltyPoints * 0.001; // 0.1% per point
  return price * (1 - rate);
}

module.exports = { calculateDiscount };
Enter fullscreen mode Exit fullscreen mode

Run  the  test  →  GREEN.

Step  3  –  Add  another  test  (the  max  discount  rule)

test('caps discount at 20% no matter how many points', () => {
  expect(calculateDiscount(100, 500)).toBeCloseTo(80); // 20% off
});
Enter fullscreen mode Exit fullscreen mode

Again  RED  →  update  the  implementation:

function calculateDiscount(price, loyaltyPoints) {
  const rawRate = loyaltyPoints * 0.001;
  const rate = Math.min(rawRate, 0.2); // enforce 20% cap
  return price * (1 - rate);
}
Enter fullscreen mode Exit fullscreen mode

GREEN  again.

Step  4  –  Refactor

Now  that  the  behavior  is  covered  by  tests,  I  can  safely  extract  helpers,  rename  variables,  or  even  swap  the  algorithm  without  fear.

function discountRate(points) {
  return Math.min(points * 0.001, 0.2);
}

function calculateDiscount(price, loyaltyPoints) {
  return price * (1 - discountRate(loyaltyPoints));
}
Enter fullscreen mode Exit fullscreen mode

All  tests  still  pass.

Notice  the  difference:  the  tests  drove  the  design.  I  never  had  to  guess  whether  the  max‑discount  rule  was  honored  because  the  test  told  me  exactly  when  it  was  missing.

Traps  to  Avoid

  • Testing  multiple  behaviors  in  one  test –  it  makes  the  failure  message  cryptic  and  hard  to  debug.  Keep  each  test  focused  on  a  single  assertion.

Top comments (0)