Week 8: Testing Node & Express Apps

Weeks 6 and 7 gave your API real authentication and authorization logic — exactly the kind of code that's dangerous to ship untested, since a broken auth check fails silently until someone exploits it. This week is about proving your routes, middleware and business logic actually do what you think they do: unit testing pure functions with Vitest, integration testing real Express routes end-to-end with Supertest, mocking the external dependencies those routes touch, and picking a test-database strategy you'll keep using through the rest of this course. Week 14's CI/CD pipeline runs this exact test suite before it ever builds a deployable image, so what you write this week is the gate everything later has to pass through.

Module 6 of 22 Week 8 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write focused unit tests for pure business logic with Vitest
  • Test real Express routes end-to-end with Supertest, including auth-protected ones
  • Mock external dependencies and choose a sound test-database strategy

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.

terminal
npm install -D vitest
vitest.config.ts
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.

src/lib/pricing.ts
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;
}
src/lib/pricing.test.ts
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);
  });
});
terminal
npx vitest run          # run once, e.g. in CI
npx vitest              # watch mode for local development
npx vitest run --coverage
Test the edge cases, not just the happy path

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.

src/app.ts
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;
}
src/server.ts
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:

src/routes/users.test.ts
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();
  });
});
Never bind a port in tests

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.

src/lib/mailer.ts
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" }),
  });
}
src/routes/users.test.ts (mocking excerpt)
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 the boundary, not your own logic

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:

.env.test
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp_test"
package.json (scripts excerpt)
{
  "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:

src/test/setup.ts — strategy A: truncate between tests
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:

src/test/setup.ts — strategy B: rollback per test (conceptual)
// 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.
Start with truncation

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

Hands-on

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:

  1. Install vitest and supertest, add a vitest.config.ts, and split your Express setup into createApp() (no listen) and a thin server.ts that calls it.
  2. 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.
  3. 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 signAccessToken helper.
  4. 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.
  5. Set up a .env.test with a separate test database connection string and a test npm script that runs migrations before the suite.
  6. Add an afterEach hook that truncates the tables your tests touch, and confirm running the suite twice in a row gives identical results both times.
Hint

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()?

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?

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?

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?

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.