
Test-Driven Development: It's Not Just "Write Tests First"
What is test driven development?
Test-driven development (TDD) is a practice in software engineering where tests are written prior to the corresponding functionality they verify. To be more specific, TDD is a loop that involves the following steps:
- Red - Write one failing test
- Green - Increment the code to pass the test
- Refactor - Clean up the code
How to implement test driven development?
This blog post will be implementing tests via Vitest, but the practices can transfer to any testing framework.
Say that we're building a calculator that does the following:
- Takes in a string of comma-separated numbers
- Returns the sum of the comma-separated numbers
Let's go through the loop!
What is the 'red' step?
Write one failing test. Not two. Not the full test suite. Just a single test that describes a behavior you want and nothing else. Say that one desired behavior is returning 0 upon receiving an empty string.
1import { it, describe } from 'vitest'2import {add} from './add'34it("should return 0 for an empty string", () => {5 const result = add("")67 expect(result).toBe(0)8})
When you run it, you should expect it to fail (i.e. the test is red). This proves that the test is actually checking for something. A test that immediately passes (i.e. is green), it could end up testing nothing.
The important constraint here is don't write the next test until this one passes. Don't think about other behaviors like custom delimiters or error handling. The only behavior that should exist at the moment is "empty string returns zero".
What is the 'green' step?
Write the minimum code to make the test pass. It doesn't matter if the code is right or elegant. This step is solely concerned with introducing the smallest amount of changes possible to make the test from the previous step pass.
1export function add(input: string): number {2 return 0;3}
You may think that this feels wrong. After all, it's just a hard-coded return that doesn't actually work. However, this is fine. The test only asked for one thing and the code only does this one thing.
The important constraint in this step is don't write more code than the test demands. Under TDD, every line of production code only exists if a test requires it. If you write more code than what the tests cover, you risk introducing undesirable behavior that no test can catch.
What is the 'refactor' step?
Rewrite the code or the test to improve its quality or performance. Think of improvements like logic duplication, unclear naming conventions, or steps that introduce unnecessary complexity. Fix these now while all tests are green.
The important constraint here is to ensure that all tests keep passing. The moment a test reverts from green to red, you've modified the behavior in a way that you shouldn't. When this happens, undo your changes and start again.
In this calculator example, there's nothing to refactor as the test and function are both simple enough already. With this, we move to the next cycle.
These include:
- Removing duplication between production code and tests
- Extract a function when the implementation grows a second concern
- Rename when a variable or function name no longer matches what it does
- Simplify an earlier guard clause that becomes redundant
Just keep in mind the one rule that a test should never revert back to red during refactor
Can you give another example?
Let's add another behavior for our calculator: When given a single number, it should return that number.
We start with the 'red' step. Add the test.
12it("should return the number itself for a single number", () => {3 const result = add("3")45 expect(result).toBe(3)6})
Running it should yield red, as our implementation is currently just a hard-coded return. Next, we make it green. Introduce the smallest number of changes possible to make the test pass.
1export function add(input: string): number {2 if (input === "") return 0;3 return Number(input)4}
Running the test again should yield green! Once again, our test and code are simple enough that we don't need to do a refactor. Let's introduce another behavior that lets us go through this step!
Say that we want the function to return the sum of the input, given an input of multiple comma-separated numbers. Add the test.
1it("should return the sum of the input numbers", () => {2 const result = add("3,9,1");34 expect(result).toBe(13);5});
Again, running it shows the test doesn't pass. Now we introduce the code changes to make it pass.
1export function add(input: string): number {2 if (input === "") return 0;3 const numbers = input.split(",");4 return numbers.reduce((sum, n) => sum + Number(n), 0);5}
With this, our test passes! Now, we move on to the refactor step. Notice that, with the new logic added, our empty string check is now redundant. Splitting would yield "", resulting in numbers being [""]. Since Number("") is 0, we don't need the empty string check anymore.
1export function add(input: string): number {2 const numbers = input.split(",");3 return numbers.reduce((sum, n) => sum + Number(n), 0);4}
The tests still pass, but notice how our function is now smaller and simpler. This is how you carry out a refactor step.
Why should I only add one test at a time?
A common pitfall we may find ourselves falling into is as follows:
- Write all tests
- Implement everything all at once
This defeats the purpose of TDD. The point is that each test drives a small design decision. If you write all the tests upfront, you end up guessing at the design that satisfies everything instead of letting each test reveal a specific aspect of the design.
Another mistake we could find ourselves making is writing more code than needed to satisfy an individual test. In TDD, we write the minimum amount of code needed to make the failing test pass. Any extra code introduced is untested code which, if it breaks later, will not be captured by any of your tests.
How do I make the test green?
There are three strategies for making a red test turn green. What you choose depends on your confidence level in solving the problem:
- Obvious implementation
- Fake it
- Triangulation
Obvious implementation, as the name implies, is the strategy you use when you already know the solution. Type it out and move on. For example, the comma-splitting behavior earlier is obvious as we just need to split the string and sum the parts.
Fake it is the strategy you employ when you're unsure about the general solution. As a result, you just hard-code the solution to pass the test, relying on the next test to push you towards the general solution. Let's illustrate this by trying to add custom delimiter support.
1it("should support custom delimiters", () => {2 const result = add("//;\n1;2");34 expect(result).toBe(3);5});
How do we parse the delimiter header? Don't know, so we fake it by hard-coding a solution.
1export function add(input: string): number {2 if (input.startsWith("//")) return 3;34 const numbers = input.replaceAll("\n", ",").split(",");5 return numbers.reduce((sum, n) => sum + Number(n), 0);6}
Here, we hard-coded 3 to make the test pass. Is the implementation correct? Obviously not, but we rely on the next tests to reveal the correct implementation for parsing the header. Let's say our next test uses a different custom delimiter:
1it("should support a single-character delimiter", () => {2 const result = add("//|\n4|5|6");34 expect(result).toBe(15);5});
The test clearly reveals that our current implementation (i.e. the hard-coded 3 ) breaks. As a result, we have to write real parsing logic because we have two concrete examples that a fake implementation can't satisfy.
1export function add(input: string): number {2 if (input.startsWith("//")) {3 const delimiter = input[2];4 const body = input.slice(4);5 return body.split(delimiter).reduce((sum, n) => sum + Number(n), 0);6 }78 const numbers = input.replaceAll("\n", ",").split(",");9 return numbers.reduce((sum, n) => sum + Number(n), 0);10}
In the solution above, we grab the single-character delimiter at input[2] and then use that to split the string after \n.
Lastly, triangulation is what you use when you're deeply unsure about the correct abstraction. In other words, you're unable to see what the general solution even looks like. The way we employ this is that we write an additional test (or multiple additional tests) with different expected values that constrain the solution space until the abstraction emerges.
To demonstrate, say that we're able to implement single-character custom delimiters and we now have to implement multi-character custom delimiters:
1it("should support custom multi-character delimiters", () => {2 const result = add("//**\n3**4**1");34 expect(result).toBe(8);5});
This breaks our single-character parsing! What solution do we go for now? Regex? A string search? Or some different parsing strategy? In this case, we have no idea what the general solution looks like, so we add another test to triangulate:
1it("should support a pipe delimiter", () => {2 const result = add("//||\n1||2||3");34 expect(result).toBe(6);5});
With these test cases, we can see a pattern: We need to extract everything between // and \n as the delimiter, regardless of the length. The abstraction emerges from the concrete cases:
1export function add(input: string): number {2 if (input.startsWith("//")) {3 const newlineIndex = input.indexOf("\n");4 const delimiter = input.slice(2, newlineIndex);5 const body = input.slice(newlineIndex + 1);6 return body.split(delimiter).reduce((sum, n) => sum + Number(n), 0);7 }89 const numbers = input.replaceAll("\n", ",").split(",");10 return numbers.reduce((sum, n) => sum + Number(n), 0);11}
In this solution, instead of grabbing just one character at input[2], we extract everything between // and \n.
When you have high confidence, go for obvious implementation. Moderate confidence, fake it. Low confidence (i.e. can't see the abstraction), triangulate. You can move between them fluidly. For example, Fake It doesn't always need to precede Triangulation.
What to do if my code has dependencies?
TDD works the same way! The trick for this situation is that your dependencies are injected, so the tests just need to substitute them.
For example, a weather alert that classifies temperature
1interface WeatherApiClient {2 fetchTemperatureInCelsius: () => Promise<number>;3}45export async function weatherAlertService(6 client: WeatherApiClient7): Promise<string> {8 const temperature = await client.fetchTemperatureInCelsius();910 if (temperature >= 40) return "extreme-heat";11 if (temperature >= 35) return "heat-warning";12 if (temperature <= -20) return "extreme-cold";13 if (temperature <= 0) return "freeze-warning";14 return "normal";15}
The WeatherApiClient interface is the seam. The tests will inject a stub instead of calling the real API:
1it("should return 'extreme-heat' when temperature >= 40", async () => {2 const stub: WeatherApiClient = {3 fetchTemperatureInCelsius: vi.fn().mockResolvedValue(42),4 };56 const result = await weatherAlertService(stub);78 expect(result).toBe("extreme-heat");9});
Now, each TDD cycle tests a specific temperature range. Since the stub controls the input, every test is now deterministic, allowing us to test the business logic in isolation. The stub just returns canned data.
Say a temperature threshold is >=40. Make sure to write a test for the edge case (i.e. input is 40), not just the interior case (e.g. input is 42).
Boundaries are where bugs hide
When is TDD unwarranted?
It's important to remember that, despite its prevalence, TDD is a tool, not a religion. It pays off when you have:
- Business logic with multiple rules and edge cases
- Code where the design is uncertain and you want tests to guide it
- Features with dependencies that need to be verified (e.g. did we call thee payment gateway? did we send the notification?)
TDD is overkill for situations such as:
- Trivial config changes or logging tweaks (i.e. no business rule change)
- UI-heavy code where the expected output is a visual artifact
- Disposable scripts that aren't expected to live long (e.g. a script that reads from the database to debug a data issue)
In short, if each test teaches you something about the design, keep going. However, if you're writing tests for code you already know is correct, stop and just write the code directly.
Hope you learned something! Happy coding🚀
Related Posts

Dependency inversion and dependency injection are terms that developers often mix up the two. In this article, we tackle this confusion.

Hexagonal architecture enforces a codebase structure where business logic is isolated from external systems. In this article, we cover it in depth.