What you'll build
By the end of this tutorial you'll have a real test suite for a small Express + TypeScript task API. It's the same task API from our REST API tutorial: five routes, an in-memory store, one Task type. Here we add tests that cover the three cases every API has: a happy path (list tasks, create one, read it back), a validation-error path (a bad POST returns 400), and a missing-resource path (an unknown id returns 404).
The tests run with Vitest and drive the app with Supertest. You'll make one structural change to the app first (split the part that creates the app from the part that starts the server) so the tests can import the app and call it in-process, with no port to bind and no server to start or stop by hand.
Every file you need is in this page. Paste each one into a fresh project (or into the project from the REST API tutorial), follow the setup steps, and the suite runs green. No repo to clone.
Should you bother testing a small API? The honest verdict
Yes, for anything with more than one route or anything you'll touch again in three months. A test suite is the cheapest way to change code without breaking the routes you already shipped. The moment you add a sixth route, refactor the store, or bump a dependency, the suite tells you in two seconds whether the existing behavior still holds. That is worth far more than the ten minutes it costs to write.
Where it doesn't pay off: a one-route throwaway script you'll delete next week. If nothing depends on it and nobody will read it again, skip the tests and move on.
The honest limitation of what you're about to build: these are integration-style tests against the Express app in-process, not full end-to-end tests against a running server and a real database. They exercise your routing, validation, and status codes exactly as a client would see them, which catches most real bugs. They do not catch problems that only show up against your production database, your reverse proxy, your TLS config, or a cold-started container. Those need a separate end-to-end layer against a deployed instance. In-process integration tests are the high-value 80% you write first; treat them as the floor, not the ceiling.
Why Vitest and Supertest in 2026
Vitest is the default test runner for TypeScript projects now. It runs .ts files directly with no separate compile step, it's ESM-native (which matches the "type": "module" setup the REST API tutorial uses), and its API is Jest-compatible: describe, it, expect, beforeEach all work the way a Jest user expects. If you already know Jest, you know Vitest. If you don't, you're learning the API that transfers to both.
Supertest is the long-standing, still-dominant way to test HTTP behavior on an Express app. You hand it your app, it starts the app on an ephemeral port for the duration of one request, makes the call, and shuts it down. You get a real HTTP round trip (headers, status codes, JSON body parsing) without managing a server or picking a port. It's the tool the Express docs themselves point at, it has millions of weekly downloads, and it pairs with any runner.
Both are mainstream, heavily-downloaded packages, which is the bar the code standard for these tutorials sets: you're learning tools a working team would actually reach for, not a clever niche pick.
Keep the reference open while you write tests
New to the syntax? Keep our TypeScript cheat sheet open in a tab, and if you want the free courses that sequence the backend skills around this, the backend developer learn path lines them up in order.
Setup
This tutorial builds on the REST API tutorial. If you already have that project, work in it. If not, the two source files you need are reproduced below, so a fresh project works too. Either way, start from a project with Express, TypeScript, and tsx installed, then add the test tooling:
npm install -D vitest supertest @types/supertestThat's three dev dependencies and zero runtime ones. Vitest is the runner, Supertest is the HTTP client, and @types/supertest gives you the types (Supertest itself ships untyped). Add a test script to package.json alongside the ones from the REST API tutorial:
{
"scripts": {
"dev": "tsx src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest run"
}
}vitest run runs the suite once and exits, which is what you want in CI and in a test script. During development, npx vitest (no run) starts watch mode and reruns on save.
Do you need a vitest.config.ts? For this case, no. Vitest works with zero config: it finds files named *.test.ts, runs them, and understands the TypeScript and ESM settings from your existing tsconfig.json. Add a config file only when you need something specific, like a coverage provider, a global setup file, or a jsdom environment for frontend tests. For a backend API suite, skip it and keep the surface small.
Step 1: make the app testable
Here is the one change that makes everything else easy: split creating the app from starting the server.
In the REST API tutorial, src/app.ts both defines the routes and calls app.listen() at the bottom. That's fine to run, but it's a problem to test: importing the file to get at the app would also start a real server on a real port, every time, in every test file. Two test files would fight over the same port.
The fix is a clean split. src/app.ts creates and exports the app, and never calls .listen(). A separate src/server.ts imports that app and starts the server. Your tests import app directly and hand it to Supertest, which handles the port. Production still runs server.ts exactly as before.
Here is src/app.ts, the same task API as the REST tutorial with two edits: it exports app, and the app.listen() block is gone. (If you're following from that tutorial, src/types.ts with the Task interface is unchanged.)
import express, { type Request, type Response } from "express";
import type { Task } from "./types.js";
export const app = express();
app.use(express.json());
const tasks: Task[] = [];
let nextId = 1;
// Exported so a test can reset state between cases. Not used in production.
export function resetTasks(): void {
tasks.length = 0;
nextId = 1;
}
// GET /tasks: return every task.
app.get("/tasks", (_req: Request, res: Response) => {
res.json(tasks);
});
// POST /tasks: create a task from { title }. 400 if the title is missing.
app.post("/tasks", (req: Request, res: Response) => {
const { title } = req.body ?? {};
if (typeof title !== "string" || title.trim() === "") {
res.status(400).json({ error: "title is required and must be a non-empty string" });
return;
}
const task: Task = { id: nextId++, title, done: false, createdAt: new Date() };
tasks.push(task);
res.status(201).json(task);
});
// GET /tasks/:id: return one task or 404.
app.get("/tasks/:id", (req: Request, res: Response) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
res.status(404).json({ error: "task not found" });
return;
}
res.json(task);
});
// DELETE /tasks/:id: remove a task. 204 on success, 404 if it was not there.
app.delete("/tasks/:id", (req: Request, res: Response) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
res.status(404).json({ error: "task not found" });
return;
}
tasks.splice(index, 1);
res.status(204).end();
});And here is the new src/server.ts. It's tiny on purpose: import the app, start it. This is the only file that touches a port, and no test ever imports it.
import { app } from "./app.js";
const port = process.env.PORT ?? 3000;
app.listen(port, () => {
console.log(`API listening on http://localhost:${port}`);
});npm run dev now runs tsx src/server.ts and behaves exactly as before. The difference is that app is importable on its own, which is all Supertest needs.
I added one small helper above, resetTasks(), which empties the in-memory store. Tests call it before each case so one test's data can't leak into the next. In a real app backed by a database you'd reset differently (a transaction rollback or a truncate between tests), but the idea is the same: every test starts from a known, clean state.
Step 2: write your first test
Create src/app.test.ts. Vitest finds any *.test.ts file automatically. The first test covers the happy path on the read side: an empty list returns 200 with an empty array.
Notice the shape. supertest(app) wraps the app; .get("/tasks") makes the request; await runs it and gives you the response. Then plain expect assertions on res.status and res.body. There's no server to start and no afterAll to close one, because Supertest opens and closes the connection per request.
import { describe, it, expect, beforeEach } from "vitest";
import request from "supertest";
import { app, resetTasks } from "./app.js";
describe("GET /tasks", () => {
beforeEach(() => {
resetTasks();
});
it("returns 200 and an empty array when there are no tasks", async () => {
const res = await request(app).get("/tasks");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});Run it:
npm test
# -> ✓ src/app.test.ts (1 test)
# -> Test Files 1 passed (1)
# -> Tests 1 passed (1)One green test proves the whole chain works: Vitest found the file, Supertest reached the route, and the assertions ran against a real HTTP response. Everything else is more of the same.
Step 3: test the write path and validation errors
Now the parts that actually break in production: creating a resource, rejecting bad input, and asking for something that isn't there. Add these to src/app.test.ts. .send({...}) sets the JSON body on a POST; Supertest sets the Content-Type header for you.
describe("POST /tasks", () => {
beforeEach(() => {
resetTasks();
});
it("creates a task and returns 201 with the new resource", async () => {
const res = await request(app)
.post("/tasks")
.send({ title: "Write tests" });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
id: 1,
title: "Write tests",
done: false,
});
// The server assigns id and createdAt; assert they exist, not exact values.
expect(typeof res.body.id).toBe("number");
expect(res.body.createdAt).toBeDefined();
});
it("returns 400 when the title is missing", async () => {
const res = await request(app).post("/tasks").send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/title is required/);
});
it("returns 400 when the title is an empty string", async () => {
const res = await request(app).post("/tasks").send({ title: " " });
expect(res.status).toBe(400);
});
});
describe("GET /tasks/:id", () => {
beforeEach(() => {
resetTasks();
});
it("returns 404 for an id that does not exist", async () => {
const res = await request(app).get("/tasks/999");
expect(res.status).toBe(404);
expect(res.body.error).toBe("task not found");
});
it("returns the task after it is created", async () => {
await request(app).post("/tasks").send({ title: "Read me back" });
const res = await request(app).get("/tasks/1");
expect(res.status).toBe(200);
expect(res.body.title).toBe("Read me back");
});
});Two things worth calling out. First, on the create test, the server sets id and createdAt, so asserting on their exact values would make the test brittle. toMatchObject checks the fields you care about and ignores the rest, and for the server-assigned fields you assert on type and presence, not value. Second, the last test chains two requests: it creates a task, then reads it back. That's an integration test doing real work: it proves the create and read routes agree on the same data through the store.
Step 4: keep tests isolated
You've seen beforeEach(() => { resetTasks(); }) on every block. This is the rule that keeps a suite trustworthy: every test starts from a clean, known state.
The task API holds its data in a module-level array. Without a reset, a task created in one test is still there in the next. Tests would then pass or fail depending on what ran before them, which is the definition of a flaky suite. Worse, they'd pass when run together and fail when run alone (or the reverse), and you'd waste an afternoon chasing a bug that's really just shared state.
resetTasks() empties the array and resets the id counter before each test, so id: 1 is always the first task created in that test. Vitest runs the tests in a single file sequentially by default, so beforeEach gives you a clean slate every time. If you split tests across files, Vitest runs the files in isolated module contexts, so state doesn't leak across files either, but the beforeEach inside each file is still what protects tests from each other within a file.
The principle scales. Swap the array for a real database and the reset becomes a truncate or a transaction rollback in beforeEach. The tests above don't change; only the reset does.
Common mistakes
- Forgetting to `await` the Supertest call.
request(app).get("/tasks")returns a thenable. If you don'tawaitit (orreturnit), the assertions run before the request finishes and the test can pass without actually testing anything. Every Supertest call in a test needsawait. - Asserting on implementation details instead of the response contract. Test what a client sees: the status code and the response body. Don't reach into the app's internal
tasksarray from a test. If you assert on the contract, you can refactor the internals freely; if you assert on internals, every refactor breaks the tests for no real reason. - Not resetting state between tests. Covered above, and it's the number-one cause of flaky API suites. If a test passes alone but fails in the full run (or vice versa), leaked state is almost always why.
- Using exact-match assertions on server-set fields.
createdAtis a timestamp andidis assigned by the server. Asserting on their exact values makes tests fail for the wrong reasons. Assert on type and presence for those, exact values for the fields the client controls. - Calling `app.listen()` in the file you import into tests. If your app file starts a server on import, two test files will collide on the port. Keep
.listen()inserver.tsand never import that file from a test.
Where this fits in the backend path
This tutorial is the testing step for two builds: the REST API tutorial and the JWT authentication API tutorial. For the free courses that cover backend testing in depth, the backend developer learn path sequences them, and best free backend development courses 2026 is our ranked pick list.
What to test next
You have a suite that covers the happy path, validation errors, and missing resources. Three moves take it further.
Test the auth API. If you built the JWT authentication API, the same pattern tests it: POST /register, POST /login, and a protected GET /me that returns 401 without a token. Supertest sets the header with .set("Authorization", \Bearer \${token}\), so you can log in in one request and use the token in the next.
Add coverage reporting. Install @vitest/coverage-v8 and run vitest run --coverage to see which lines your tests actually hit. It's a quick way to spot a route with no test at all. Don't chase 100%: use it to find gaps, not as a target.
Wire it into CI. npm test exits non-zero when a test fails, so a GitHub Actions step that runs it blocks a merge on a red suite. That's the payoff of the whole exercise: the tests run themselves on every push and catch a break before it ships.
For the full path around this, the backend developer learn path sequences the free courses in order, and the backend roadmap shows what comes after.
Frequently asked questions
Do I need to start the server to test it with Supertest?
No, and that's the point of the app/server split. You pass the Express app object to Supertest (request(app)), and Supertest starts it on an ephemeral port for the length of a single request, then shuts it down. You never call app.listen() in a test and you never pick a port. That's why the app file exports app without listening, and a separate server.ts is the only file that binds a port.
Vitest vs Jest for a new TypeScript project?
Vitest for a new project. It runs TypeScript and ESM with no extra config, starts faster, and its API is Jest-compatible (describe, it, expect, beforeEach all match), so knowledge transfers both ways. Jest is still everywhere in existing codebases and is a fine choice if your team already runs it, but for a fresh TypeScript project on modern module settings, Vitest has less setup friction.
How do I test routes that hit a real database?
Point the tests at a dedicated test database, not production or your dev database, and reset it between tests. The common patterns are: truncate the relevant tables in beforeEach, or wrap each test in a transaction and roll it back afterward. The Supertest assertions in this tutorial don't change at all; only the reset step in beforeEach does. For true isolation from external services, spin up the database in a container (Docker or Testcontainers) so each CI run gets a clean instance.
What's the difference between unit and integration tests here?
A unit test checks one function in isolation, with its dependencies stubbed out. The tests in this tutorial are integration tests: they send a real HTTP request through the full Express stack (routing, express.json() body parsing, your handler, the store) and assert on the real response. Integration tests catch bugs in how the pieces fit together, which is where most API bugs actually live. Unit tests are still useful for complex pure logic (a pricing calculator, a date parser); test that in isolation and let Supertest cover the routes.
Can I use this same setup with Fastify instead of Express?
The Vitest half is identical. The Supertest half changes slightly: Fastify has its own built-in testing method (app.inject()) that's the idiomatic choice and skips the network layer entirely. You can still use Supertest against a Fastify server, but for Fastify most teams use inject(). The pattern of splitting app creation from server start, and resetting state between tests, applies to both frameworks unchanged.
Why does Supertest need @types/supertest?
Supertest ships as plain JavaScript with no bundled type definitions, so TypeScript doesn't know its shape out of the box. @types/supertest is the community-maintained type package (from DefinitelyTyped) that gives you autocomplete on request(app).get(...).send(...) and typed responses. Install it as a dev dependency alongside supertest. Vitest and Express both ship their own types, so they don't need a separate @types package.