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 };
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
});
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)
});
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 };
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
});
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);
}
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));
}
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)