1. Unit Testing with Vitest
A unit test exercises one small piece of logic in isolation — no
database, no HTTP, no filesystem. Vitest is the
standard test runner for modern TypeScript/Node projects: it's fast, has a Jest-
compatible API (describe, it, expect), runs
your .ts files directly without a separate compile step, and integrates
cleanly with the ES module setup you've used since Week 1.
npm install -D vitest
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
globals: true, // lets you use describe/it/expect without importing them everywhere
},
});
Good unit tests target logic that's worth isolating — validation rules, pricing math, formatting, anything with branches and edge cases. A pricing calculator makes a clean example: it's pure (same input always produces the same output), has no side effects, and has real edge cases worth pinning down.
export interface OrderItem {
price: number;
quantity: number;
}
export function calculateOrderTotal(items: OrderItem[], discountPercent = 0): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const discount = subtotal * (discountPercent / 100);
return Math.round((subtotal - discount) * 100) / 100;
}
import { describe, it, expect } from "vitest";
import { calculateOrderTotal } from "./pricing.js";
describe("calculateOrderTotal", () => {
it("sums price * quantity across all items", () => {
const total = calculateOrderTotal([
{ price: 10, quantity: 2 },
{ price: 5, quantity: 3 },
]);
expect(total).toBe(35);
});
it("applies a percentage discount to the subtotal", () => {
const total = calculateOrderTotal([{ price: 100, quantity: 1 }], 15);
expect(total).toBe(85);
});
it("returns 0 for an empty cart", () => {
expect(calculateOrderTotal([])).toBe(0);
});
it("rounds to two decimal places", () => {
const total = calculateOrderTotal([{ price: 9.999, quantity: 1 }]);
expect(total).toBe(10);
});
});
npx vitest run # run once, e.g. in CI
npx vitest # watch mode for local development
npx vitest run --coverage
The empty-cart and rounding tests above are worth more than another "normal" test would be — an empty array and a fractional cent are exactly where pricing bugs hide in production. When you write a unit test, ask what input would most likely break the function, not just what input proves it works.
2. API/Integration Testing with Supertest
Unit tests don't catch everything — a route can call the right pricing logic and still return the wrong status code, skip a validation check, or forget to attach the authenticated user. Supertest tests your real Express app end-to-end: it sends actual HTTP requests into your route handlers and asserts on the real response, without needing a running server on a real port.
The key setup change this requires: split the code that builds your Express app from the code that starts listening. Supertest only needs the app object — it opens its own ephemeral connection to it internally.
import express, { type Express } from "express";
import { usersRouter } from "./routes/users.js";
import { errorHandler } from "./middleware/error-handler.js";
export function createApp(): Express {
const app = express();
app.use(express.json());
app.use("/api/users", usersRouter);
app.use(errorHandler);
return app;
}
import { createApp } from "./app.js";
const app = createApp();
const port = process.env.PORT ?? 3000;
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
With that split, a test file can build a fresh app instance and hand it directly to Supertest — including a protected route that needs a real JWT from Week 6's auth middleware:
import { describe, it, expect } from "vitest";
import request from "supertest";
import { createApp } from "../app.js";
import { signAccessToken } from "../lib/jwt.js"; // from Week 6
describe("POST /api/users", () => {
const app = createApp();
it("rejects requests with no auth token", async () => {
const res = await request(app)
.post("/api/users")
.send({ name: "Ada Lovelace", email: "ada@example.com" });
expect(res.status).toBe(401);
});
it("creates a user when authenticated as an admin", async () => {
const token = signAccessToken({ sub: "admin-1", role: "admin" });
const res = await request(app)
.post("/api/users")
.set("Authorization", `Bearer ${token}`)
.send({ name: "Ada Lovelace", email: "ada@example.com" });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
name: "Ada Lovelace",
email: "ada@example.com",
});
});
it("returns 400 with a validation error for a missing field", async () => {
const token = signAccessToken({ sub: "admin-1", role: "admin" });
const res = await request(app)
.post("/api/users")
.set("Authorization", `Bearer ${token}`)
.send({ name: "Ada Lovelace" }); // no email
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
});
Because Supertest operates on the app object directly rather than a real listening socket, you avoid the entire class of problems that comes from port conflicts in CI, tests leaking open connections between files, or needing to pick a random free port. It's also considerably faster — no real TCP handshake per request.
3. Mocking External Dependencies
Some code your routes call shouldn't actually run in a test — sending a real welcome
email, calling a real third-party payment API, or hitting a real external service all
make tests slow, flaky, and dependent on things outside your control. Vitest's
vi.mock replaces a module's exports with test doubles you control, at the
module-resolution level, so your route code never knows the difference.
export async function sendWelcomeEmail(to: string): Promise<void> {
// In production this calls a real email provider's API.
await fetch("https://api.emailprovider.com/send", {
method: "POST",
body: JSON.stringify({ to, template: "welcome" }),
});
}
import { describe, it, expect, vi } from "vitest";
import request from "supertest";
import { createApp } from "../app.js";
import { sendWelcomeEmail } from "../lib/mailer.js";
vi.mock("../lib/mailer.js", () => ({
sendWelcomeEmail: vi.fn().mockResolvedValue(undefined),
}));
describe("POST /api/users -- welcome email", () => {
const app = createApp();
it("sends a welcome email to the new user's address", async () => {
await request(app)
.post("/api/users")
.set("Authorization", `Bearer ${adminToken}`)
.send({ name: "Grace Hopper", email: "grace@example.com" });
expect(sendWelcomeEmail).toHaveBeenCalledOnce();
expect(sendWelcomeEmail).toHaveBeenCalledWith("grace@example.com");
});
});
vi.mock is hoisted to the top of the file by Vitest before any imports
run, so the mocked version is what users.ts's route handler actually
receives when it imports sendWelcomeEmail — not the real implementation.
This same pattern applies to any external dependency: mock a payment gateway client to
assert it was called with the right amount, or mock an S3 upload helper to assert the
right key was used, all without a single real network call.
Mock things you don't own or can't control in a test run — third-party APIs, email/SMS providers, payment processors. Don't mock your own database or business logic just to make a test "faster"; that's exactly the code an integration test exists to actually exercise, and over-mocking it hides real bugs.
4. Test Database Strategy
Integration tests that touch Prisma from Weeks 4–5 need a real database to run against — mocking the Prisma client is possible but brittle, since it stops testing your actual queries. The standard approach is a separate test database with its own connection string, kept structurally identical to your real one via the same Prisma migrations:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp_test"
{
"scripts": {
"test": "dotenv -e .env.test -- vitest run",
"test:migrate": "dotenv -e .env.test -- prisma migrate deploy"
}
}
Within that shared test database, each test still needs a clean slate so tests don't leak state into each other. Two common strategies:
import { afterEach } from "vitest";
import { prisma } from "../lib/prisma.js";
afterEach(async () => {
// TRUNCATE ... CASCADE resets tables and their sequences between tests.
await prisma.$executeRawUnsafe(
`TRUNCATE TABLE "User", "Order", "OrderItem" RESTART IDENTITY CASCADE;`
);
});
Truncating is simple and works with any test runner, but it's an extra query per test and can get slow as your schema grows. The alternative — transactional rollback — wraps each test in a database transaction that's always rolled back at the end, so nothing a test writes is ever actually committed:
// Requires a Prisma Client extension or interactive $transaction that the
// test suite never commits -- every query inside the test runs against the
// same open transaction, and afterEach forces a rollback instead of a commit.
// More setup than truncation, but noticeably faster once your test suite
// has hundreds of tests, since there's no per-test cleanup query at all.
Truncation between tests is easier to reason about and debug, and plenty fast for a course-sized or early-stage project's test suite. Reach for transactional rollback later, once your test count is high enough that per-test cleanup queries are a measurable chunk of your CI run time — don't add the complexity before you've felt the pain it solves.
5. Hands-on Exercise
Build a real test suite for your Week 6/7 authenticated API
Add Vitest and Supertest to the project you've been building, and cover its most important behaviors — pure logic, real routes, and the auth layer.
Requirements:
- Install
vitestandsupertest, add avitest.config.ts, and split your Express setup intocreateApp()(nolisten) and a thinserver.tsthat calls it. - Write at least 3 unit tests for one pure function in your project (validation, pricing, formatting — your choice), covering a normal case and two edge cases.
- Write Supertest integration tests for a protected route: assert a 401 with no token, a 403 with the wrong role, and a 2xx with a valid token signed via your Week 6
signAccessTokenhelper. - Mock one external dependency your app calls (an email sender, a third-party API client) with
vi.mock, and assert it was called with the correct arguments. - Set up a
.env.testwith a separate test database connection string and atestnpm script that runs migrations before the suite. - Add an
afterEachhook that truncates the tables your tests touch, and confirm running the suite twice in a row gives identical results both times.
If a test passes the first time you run it but fails the second time, that's almost always leftover state from the previous run — a unique-email constraint violation is the classic symptom. That's exactly the bug your truncation hook exists to prevent, so treat a flaky second run as a signal your cleanup isn't wired up correctly yet.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why do Supertest integration tests call the app object directly instead of starting a real server with app.listen()?
Why do Supertest integration tests call the app object directly instead of starting a real server with app.listen()?
Supertest can drive requests straight into the Express app's request-handling logic without a real listening socket, which avoids picking a port, handling port conflicts when tests run in parallel or in CI, and the overhead of a real TCP handshake per request. It also means tests don't need explicit teardown of a server instance, since there's no long-lived socket to leak between test files.
Q2
Why mock sendWelcomeEmail in an integration test instead of letting the real implementation run?
Why mock sendWelcomeEmail in an integration test instead of letting the real implementation run?
The real implementation makes a network call to a third-party provider, which makes the test slow, dependent on that provider being reachable, and liable to actually send an email to a fake test address every time the suite runs. Mocking it lets you assert your route called it with the right arguments — proving your code's behavior — without any of those side effects or external dependencies.
Q3
What breaks if two test files run against the same test database with no cleanup strategy between tests?
What breaks if two test files run against the same test database with no cleanup strategy between tests?
Rows created by one test are still present when the next test runs, so a test that expects an empty table or a specific row count gets polluted results, and unique constraints (like a unique email) can cause an unrelated test to fail simply because an earlier test already inserted that value. This is exactly why an afterEach truncation hook, or transactional rollback, matters — without it, test outcomes depend on execution order instead of being independent.
Q4
Why write a unit test for calculateOrderTotal separately from an integration test that hits POST /api/orders, when the integration test technically exercises that logic too?
Why write a unit test for calculateOrderTotal separately from an integration test that hits POST /api/orders, when the integration test technically exercises that logic too?
The unit test isolates the pricing math from everything else, so when it fails you know immediately the bug is in the calculation itself, not in routing, auth, or the database layer — a failing integration test only tells you something in that whole chain broke. Unit tests are also far cheaper to run in bulk, letting you cover many pricing edge cases quickly instead of paying the cost of a full HTTP request and database round-trip for each one.