In the previous chapter, we talked about building a URL Shortener API entirely through Test-Driven Development (TDD). Before writing any real code, we need to understand what TDD actually is and why it changes the way you build software.
Test-Driven Development (TDD) is a practice where you write a test before you write the code that makes it pass. You do not write the feature first and add tests later. You start with the test.
This might sound backwards. How can you test something that does not exist yet? That is exactly the point. The test describes what you want the code to do. Then you write just enough code to make it work.
Most developers follow a traditional approach: write the code first, then write tests to verify it works (if they write tests at all). The problem here is that tests become an afterthought. They are often skipped when deadlines are tight. When they are written, they tend to test the implementation details rather than the actual behavior.
TDD flips this around. The test comes first, and the code follows.
TDD follows a simple three-step cycle called Red-Green-Refactor:
Start by writing a test for the behavior you want. Run it. It should fail. If it does not fail, something is wrong. Either the test is checking something that already works, or the test itself is broken.
A failing test is a good thing. It means you have clearly defined what "done" looks like before writing a single line of production code.
Now write the simplest code that makes the test pass. Do not overthink it. Do not optimize. Do not add features the test did not ask for. Just make the red turn green.
This step is about getting the correct result. You can write ugly code here. We will clean it up next.
With a passing test as your safety net, improve the code. Remove duplication, rename variables, or extract functions to make the code cleaner. Run the tests after every change to make sure nothing breaks.
Refactoring with tests is safe. Without tests, refactoring is gambling.
Then you repeat the cycle. Write the next failing test, make it pass, and refactor. You build your application one small, tested piece at a time.

Early in my career, I knew tests were important, but I always wrote them after the fact. The result was usually tests that were hard to write, tested the wrong things, and gave me false confidence.
When I started practicing TDD, three things changed:
It catches bugs early. Writing the test first makes you think about edge cases before you write the code. You are forced to ask: what should happen when the input is empty? What if the user passes a number instead of a string? You handle these questions naturally before they become bugs in production.
It forces better design. Code that is easy to test is usually well-designed code. If you cannot write a simple test for a function, it is often because the function is doing too much. TDD pushes you toward small, focused functions with clear inputs and outputs.
It gives you confidence to refactor. Changing existing code without tests is scary. With a solid test suite, you can refactor aggressively and know immediately if you broke something. This makes your codebase maintainable over time.
TDD is not magic. It has real costs:
The goal of this course is to get you past the learning curve by building something real, step by step.
We can see the Red-Green-Refactor cycle in action with a simple example. We will write a hello function that takes a name and returns a greeting.
Start by checking out the start branch:
bashgit checkout 01-what-is-tdd-start
You will find two files:
Open hello.test.js. This is our test file:
jsconst { describe, it } = require("node:test");const assert = require("node:assert");const { hello } = require("./hello");describe("hello", () => {it("should return a greeting with the given name", () => {const result = hello("Tung");assert.strictEqual(result, "Hello, Tung");});});
A few things to notice:
node:test and node:assert. These are built into Node.js. There is no need to install Jest, Mocha, or any other dependencies.describe groups related tests together.it defines a single test case for a specific behavior we expect.assert.strictEqual checks that the result exactly matches what we expect.The test says: "When I call hello('Tung'), it should return 'Hello, Tung'."
Now look at hello.js, the implementation file:
jsconst hello = (name) => {};module.exports = { hello };
The function exists, but it does not do anything. It returns undefined.
Run the test:
bashnode --test hello.test.js
You will see output like this:
✖ hello > should return a greeting with the given name (0.551ms)
AssertionError: Expected values to be strictly equal:
+ actual - expected
+ undefined
- 'Hello, Tung'

Red. The test fails because hello('Tung') returns undefined instead of 'Hello, Tung'. This is exactly what we want. We have defined the behavior, and now we know it does not work yet.
Now write the simplest code that makes the test pass. Update hello.js:
jsconst hello = (name) => {return `Hello, ${name}`;};module.exports = { hello };
Run the test again:
bashnode --test hello.test.js
✔ hello > should return a greeting with the given name (0.505ms)
ℹ tests 1
ℹ suites 1
ℹ pass 1
ℹ fail 0

Green. The test passes. We wrote the minimum code needed: a template literal that combines "Hello, " with the name.
You can verify this by checking out the finish branch:
bashgit checkout 01-what-is-tdd-finish
The code there matches exactly what we just wrote.
In this example, the code is already as simple as it can be. In real-world scenarios, this is where you clean up. You might extract a helper function, rename a variable, or remove duplication. With a passing test, you can make these changes confidently.
Write a failing test, make it pass, clean up. We will follow this exact pattern throughout the entire course.
In the next chapter, we will set up the actual project by scaffolding a Fastify application and writing our first real failing test.