In this chapter, we'll set up the Fastify project that will become our URL Shortener API — configuring TypeScript, Jest, and Swagger, then writing our first real failing test for a health check endpoint using TDD.
We're writing this entire project in TypeScript. If you've only used plain JavaScript for Node.js, here's why the switch is worth it:
Type safety catches bugs before tests run. TypeScript's compiler will flag typos, wrong argument types, and missing properties before you even run a test. That's an extra safety net on top of TDD — your types catch one class of bugs, your tests catch another.
IDE autocompletion becomes useful. With TypeScript, your editor knows the shape of every object, every function signature, every return type. When you type app. in a Fastify project, you get a full list of available methods with their signatures. This is especially valuable when learning a new framework.
It's the industry standard for production Node.js. Most serious Node.js projects use TypeScript today. Learning TDD with TypeScript means the patterns you learn here transfer directly to real-world codebases.
No build step needed. We use tsx — a TypeScript runtime that executes .ts files directly. No tsc compilation, no dist folder, no build step. You write .ts files and run them. For development and testing, it's as simple as plain JavaScript.
You might wonder why we're using Fastify instead of Express. Express is the most popular Node.js framework, and it's what most tutorials teach. But for a production backend in 2025, Fastify is a better choice. Here's why:
Fastify is actively maintained. Express went years without a major release. Fastify ships regular updates and has an active core team. When you're building something that needs to run in production, you want a framework that keeps up with the Node.js ecosystem.
It's built around a plugin system. Everything in Fastify is a plugin — routes, database connections, authentication. This forces you to write modular code from the start. You don't end up with a 500-line app.ts file because the framework naturally pushes you toward separation.
Schema validation is built in. Fastify uses JSON Schema to validate requests and responses. You define the shape of your data once, and Fastify handles validation automatically. This same schema also powers automatic API documentation — write it once, get validation and docs for free.
It's fast. Fastify is one of the fastest Node.js frameworks available. For our URL Shortener, raw speed isn't the main concern — but it's nice to know we're not leaving performance on the table.
Start by checking out the start branch:
bashgit checkout 03-project-setup-start
You'll notice the hello.js and hello.test.js from Chapter 2 are gone. We're starting fresh with a proper project structure.
Run npm install to install the dependencies:
bashnpm install
Here's how the project is organized:
url-shortener/
├── __tests__/
│ └── health.test.ts
├── src/
│ ├── routes/
│ │ └── health.ts
│ ├── app.ts
│ └── server.ts
├── jest.config.ts
├── tsconfig.json
├── package.json
└── package-lock.json
src/app.ts — builds and configures the Fastify applicationsrc/server.ts — starts the server (entry point)src/routes/ — route handlers, one file per resource__tests__/ — test files, mirroring the source structuretsconfig.json — TypeScript configuration (strict mode, no emit)jest.config.ts — Jest configuration with ts-jestThis separation between app.ts and server.ts is important. The app builder creates and configures the Fastify instance without starting it. The server file calls the builder and then listens on a port. This means our tests can create a fresh app instance without starting a real HTTP server — which makes tests fast and isolated.
Let's look at what we're working with. Open package.json:
json// package.json{"name": "url-shortener","version": "1.0.0","description": "A URL shortener API built with Fastify and TDD","scripts": {"start": "tsx src/server.ts","dev": "tsx watch src/server.ts","test": "jest --verbose","typecheck": "tsc --noEmit"},"dependencies": {"@fastify/swagger": "^9.7.0","@fastify/swagger-ui": "^5.2.5","fastify": "^5.8.2"},"devDependencies": {"@types/jest": "^29.5.14","@types/node": "^22.15.21","jest": "^30.0.0-beta.3","ts-jest": "^29.3.4","tsx": "^4.19.4","typescript": "^5.8.3"}}
Four scripts:
npm start — runs the server using tsx (TypeScript, no build step)npm run dev — runs the server with file watching, so it restarts automatically when you change a filenpm test — runs Jest in verbose modenpm run typecheck — runs the TypeScript compiler to check types without emitting filesFor dependencies, we have Fastify as our web framework, @fastify/swagger and @fastify/swagger-ui for automatic API documentation. For dev dependencies: TypeScript and tsx for the runtime, Jest with ts-jest for testing, and type definitions for Node.js and Jest.
In Chapter 2, we used node:test — Node's built-in test runner. It worked fine for a single file, but for a real project we need more. Jest gives us:
We use ts-jest to transform TypeScript test files. It plugs into Jest's transform pipeline so .ts files just work — no separate compilation step needed.
The trade-off is a few extra dependencies. For this course, the benefits far outweigh the cost.
Let's look at tsconfig.json:
json// tsconfig.json{"compilerOptions": {"target": "ES2022","module": "Node16","moduleResolution": "Node16","strict": true,"isolatedModules": true,"esModuleInterop": true,"skipLibCheck": true,"noEmit": true,"outDir": "dist","rootDir": ".","resolveJsonModule": true,"declaration": true,"declarationMap": true,"sourceMap": true},"include": ["src/**/*", "__tests__/**/*"],"exclude": ["node_modules", "dist"]}
A few key options:
strict: true — enables all strict type-checking options. This catches more bugs at compile time. We want the strictest checks possible.noEmit: true — the compiler only checks types, it doesn't output JavaScript files. We use tsx to run TypeScript directly, so there's no build step.module: "Node16" — uses Node.js's native module resolution. This matches how tsx resolves imports.isolatedModules: true — ensures each file can be compiled independently, which is required by ts-jest.typescript// jest.config.tsimport type { Config } from "jest";const config: Config = {preset: "ts-jest",testEnvironment: "node",roots: ["<rootDir>/__tests__"],};export default config;
The ts-jest preset handles transforming .ts test files. roots tells Jest to look for tests in the __tests__ directory. That's it — minimal configuration.
Let's walk through src/app.ts:
typescript// src/app.tsimport Fastify, { FastifyInstance } from "fastify";import swagger from "@fastify/swagger";import swaggerUi from "@fastify/swagger-ui";import { healthRoute } from "./routes/health";interface BuildAppOptions {logger?: boolean;}export const buildApp = async (opts: BuildAppOptions = {}): Promise<FastifyInstance> => {const app = Fastify({ logger: opts.logger ?? true });await app.register(swagger, {openapi: {info: {title: "URL Shortener API",description: "A URL shortener service built with Fastify and TDD",version: "1.0.0",},},});await app.register(swaggerUi, {routePrefix: "/documentation",});// Register routesawait app.register(healthRoute);return app;};
The buildApp function is an app factory — it creates a new Fastify instance every time you call it. This is a common pattern in Fastify applications.
A few things to notice:
BuildAppOptions interface — defines what options the factory accepts. Right now it's just logger, but as the app grows we'll add database URLs and other configuration here. TypeScript makes sure callers pass valid options.FastifyInstance return type — the function signature tells you (and your IDE) exactly what you get back. No guessing.opts.logger ?? true — the logger is on by default, but tests can turn it off by passing { logger: false }. Without this, test output would be cluttered with HTTP request logs.schema will automatically appear in the API docs.app.register(). This is Fastify's plugin system in action — each route file exports a FastifyPluginAsync that receives the app instance.typescript// src/server.tsimport { buildApp } from "./app";const start = async (): Promise<void> => {const app = await buildApp();try {await app.listen({ port: 3000 });app.log.info(`Documentation at http://localhost:3000/documentation`);} catch (err) {app.log.error(err);process.exit(1);}};start();
This file does one thing: build the app and start listening on port 3000. After a successful start, it logs the documentation URL so you know where to find the Swagger docs. If something goes wrong, it logs the error and exits. We won't need to touch this file very often — most of our work happens in routes and app.ts.
Before we write our first test, let's talk about what we're testing and why.
A health check endpoint — a simple route like GET /health that returns a 200 status — is the first thing you should add to any API. It seems trivial, but it serves a critical purpose in production:
/health stops returning 200, the load balancer removes your server from the pool./health goes down, your team gets paged.It's also the simplest possible endpoint — no database, no authentication, no business logic. That makes it the perfect first test. We can verify that our entire stack works (Fastify boots, routes are registered, responses are correct) without any complexity.
Here's the test file at __tests__/health.test.ts:
typescript// __tests__/health.test.tsimport { FastifyInstance } from "fastify";import { buildApp } from "../src/app";describe("GET /health", () => {let app: FastifyInstance;beforeAll(async () => {app = await buildApp({ logger: false });await app.ready();});afterAll(async () => {await app.close();});it("should return 200 with a message", async () => {const response = await app.inject({method: "GET",url: "/health",});expect(response.statusCode).toBe(200);expect(response.json()).toEqual({ message: "hello" });});});
Let's break this down:
let app: FastifyInstance — we declare the type explicitly. This gives us full autocompletion on app — every method, every property, fully typed.
buildApp({ logger: false }) — we create a fresh Fastify instance with logging disabled. No noisy request logs in our test output.
await app.ready() — Fastify boots asynchronously (loading plugins, registering routes). We need to wait for it to be ready before sending requests.
await app.close() — clean up after the tests. This closes any open connections and prevents Jest from hanging.
app.inject() — this is Fastify's built-in testing utility. It simulates an HTTP request without actually starting a server or opening a network port. No need for supertest or any other HTTP testing library — Fastify has this built in.
The test makes two assertions:
200{ message: "hello" }Now let's run it:
bashnpm test
FAIL __tests__/health.test.ts
● GET /health › should return 200 with a message
expect(received).toEqual(expected) // deep equality
- Expected - 3
+ Received + 1
- Object {
- "message": "hello",
- }
+ Object {}
21 |
22 | expect(response.statusCode).toBe(200);
> 23 | expect(response.json()).toEqual({ message: "hello" });
| ^
24 | });
25 | });
26 |
at Object.<anonymous> (__tests__/health.test.ts:23:29)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 0 total
Time: 0.183 s
Ran all test suites.

Red. The test fails. It expected { message: "hello" } but received an empty object {}. Look at the health route to see why:
typescript// src/routes/health.tsimport { FastifyPluginAsync } from "fastify";export const healthRoute: FastifyPluginAsync = async (app) => {app.get("/health", {schema: {description: "Health check endpoint",tags: ["Health"],response: {200: {type: "object",properties: {message: { type: "string" },},},},},handler: async () => {// TODO: implement health check responsereturn {};},});};
The route is registered. The schema is defined. But the handler returns an empty object. The test tells us exactly what needs to change.
FastifyPluginAsync — this is the type for a Fastify plugin function. It tells TypeScript (and you) that this function receives a Fastify instance and registers routes on it. If you pass the wrong type or forget to make it async, the compiler catches it.
Notice the schema block — this is Fastify's JSON Schema integration. It describes what the response looks like: an object with a message property of type string. This schema serves double duty: Fastify uses it for response serialization, and Swagger uses it to generate API documentation. Write the schema once, get both for free.
The fix is one line. Update src/routes/health.ts:
typescript// src/routes/health.tsimport { FastifyPluginAsync } from "fastify";export const healthRoute: FastifyPluginAsync = async (app) => {app.get("/health", {schema: {description: "Health check endpoint",tags: ["Health"],response: {200: {type: "object",properties: {message: { type: "string" },},},},},handler: async () => {return { message: "hello" };},});};
Run the tests again:
bashnpm test
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.175 s
Ran all test suites.

Green. The test passes. We wrote the minimum code needed — returning { message: "hello" } from the handler.
You can verify this by checking out the finish branch:
bashgit checkout 03-project-setup-finish
There's nothing to refactor here — the code is already as simple as it gets. One route, one handler, one return statement. In later chapters, as the codebase grows, the refactor step will become more meaningful.
Start the server to see the API docs:
bashnpm start
Open your browser and go to http://localhost:3000/documentation. You'll see a Swagger UI page showing the URL Shortener API with our GET /health endpoint listed under the "Health" tag.

Click on the endpoint, hit "Try it out", then "Execute". You'll see the 200 response with { "message": "hello" }.
This is the benefit of defining schemas on your routes. We didn't write any documentation separately — Swagger reads the JSON Schema we already defined on the route and generates interactive docs automatically. As we add more endpoints in future chapters, they'll all appear here without any extra work.
In the next chapter, we'll start building the actual URL shortening feature — POST /shorten — using in-memory storage and full TDD.