Chapter 89 โ Testing a MERN + TypeScript App
๐ Definition
A MERN test suite usually has three layers:
| Layer | What it tests | Tools |
|---|---|---|
| Unit | Pure functions: services with mocked deps, reducers, selectors, utils | Vitest / Jest |
| Integration | Express routes + real (in-memory) MongoDB; React components + real Redux store + mocked network | Supertest, mongodb-memory-server, React Testing Library, MSW |
| End-to-end | The real app in a browser | Playwright / Cypress |
The testing trophy: most value comes from integration tests; keep a few E2E tests for critical flows (login, checkout).
๐ป Setup โ Vitest for the Server
// apps/server/vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
globals: true,
setupFiles: ["./test/setup.ts"],
hookTimeout: 60_000, // first run downloads a MongoDB binary
},
});Jest works the same way with ts-jest or @swc/jest; Vitest is faster and needs no extra TS transform config.
๐ป Setup โ In-Memory MongoDB
// apps/server/test/setup.ts
import { MongoMemoryServer } from "mongodb-memory-server";
import mongoose from "mongoose";
let mongo: MongoMemoryServer;
// env.ts validates on import โ provide test values first
process.env.NODE_ENV = "test";
process.env.JWT_ACCESS_SECRET = "a".repeat(32);
process.env.JWT_REFRESH_SECRET = "b".repeat(32);
process.env.CLIENT_ORIGIN = "http://localhost:5173";
process.env.MONGO_URI = "mongodb://placeholder";
beforeAll(async () => {
mongo = await MongoMemoryServer.create();
await mongoose.connect(mongo.getUri());
});
afterEach(async () => {
// Clean every collection so tests don't depend on each other
const collections = await mongoose.connection.db!.collections();
await Promise.all(collections.map((c) => c.deleteMany({})));
});
afterAll(async () => {
await mongoose.disconnect();
await mongo.stop();
});Use MongoMemoryReplSet instead if the code under test uses transactions.
๐ป Code Example โ API Integration Tests with Supertest
// apps/server/test/tasks.test.ts
import request from "supertest";
import { createApp } from "../src/app.js";
import { User } from "../src/modules/users/user.model.js";
import { signAccess } from "../src/modules/auth/tokens.js";
import { Task } from "../src/modules/tasks/task.model.js";
const app = createApp(); // no listen(), no port
async function loginAs(role: "user" | "admin" = "user") {
const user = await User.create({ email: `${role}${Date.now()}@x.com`, name: "Test", password: "Passw0rd!", role });
return { user, token: signAccess({ sub: user.id, role }) };
}
describe("Tasks API", () => {
it("401 without a token", async () => {
await request(app).get("/api/tasks").expect(401);
});
it("creates and lists my tasks", async () => {
const { token } = await loginAs();
const created = await request(app)
.post("/api/tasks")
.set("Authorization", `Bearer ${token}`)
.send({ title: "Write tests", priority: "high" })
.expect(201);
expect(created.body.data).toMatchObject({ title: "Write tests", priority: "high", done: false });
const list = await request(app).get("/api/tasks").set("Authorization", `Bearer ${token}`).expect(200);
expect(list.body.data.total).toBe(1);
});
it("400 on invalid input with field details", async () => {
const { token } = await loginAs();
const res = await request(app)
.post("/api/tasks")
.set("Authorization", `Bearer ${token}`)
.send({ title: "", priority: "urgent" })
.expect(400);
expect(res.body.details.map((d: { field: string }) => d.field)).toEqual(expect.arrayContaining(["title", "priority"]));
});
it("cannot update another user's task (404, not 403)", async () => {
const alice = await loginAs();
const bob = await loginAs();
const task = await Task.create({ title: "Alice's", owner: alice.user._id });
await request(app)
.patch(`/api/tasks/${task.id}`)
.set("Authorization", `Bearer ${bob.token}`)
.send({ done: true })
.expect(404);
expect((await Task.findById(task.id))!.done).toBe(false);
});
it("admin sees all tasks", async () => {
const alice = await loginAs();
await Task.create({ title: "x", owner: alice.user._id });
const admin = await loginAs("admin");
const res = await request(app).get("/api/tasks").set("Authorization", `Bearer ${admin.token}`).expect(200);
expect(res.body.data.total).toBe(1);
});
});๐ป Code Example โ Auth Flow Test (Cookies)
it("refresh rotates the token and the old one stops working", async () => {
await request(app).post("/api/auth/register")
.send({ email: "a@x.com", name: "Asha", password: "Passw0rd!" }).expect(201);
const login = await request(app).post("/api/auth/login")
.send({ email: "a@x.com", password: "Passw0rd!" }).expect(200);
const oldCookie = login.headers["set-cookie"]!;
expect(oldCookie[0]).toMatch(/HttpOnly/);
await request(app).post("/api/auth/refresh").set("Cookie", oldCookie).expect(200);
await request(app).post("/api/auth/refresh").set("Cookie", oldCookie).expect(401); // reused โ rejected
});๐ป Code Example โ Unit Testing a Service with Mocks
import { vi } from "vitest";
import { authService } from "../src/modules/auth/auth.service.js";
import { User } from "../src/modules/users/user.model.js";
vi.mock("../src/modules/users/user.model.js", () => ({
User: { findByEmail: vi.fn(), exists: vi.fn(), create: vi.fn() },
}));
it("rejects wrong password with a generic message", async () => {
vi.mocked(User.findByEmail).mockResolvedValue({
comparePassword: vi.fn().mockResolvedValue(false),
} as never);
await expect(authService.login("a@x.com", "wrong")).rejects.toMatchObject({
statusCode: 401,
message: "Invalid credentials",
});
});vi.mocked() / jest.mocked() gives the mock the right TypeScript type so .mockResolvedValue is typed.
๐ป Code Example โ React Component Test with Redux + MSW
// apps/web/src/features/tasks/TasksPage.test.tsx
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../test-utils"; // real store via setupStore (Redux testing chapter)
import { TasksPage } from "./TasksPage";
import type { TaskDto } from "@app/shared";
let tasks: TaskDto[] = [];
const server = setupServer(
http.get("/api/tasks", () => HttpResponse.json({ success: true, data: { items: tasks, total: tasks.length, page: 1, limit: 20 } })),
http.post("/api/tasks", async ({ request }) => {
const body = (await request.json()) as { title: string };
const t: TaskDto = {
id: String(tasks.length + 1), title: body.title, description: "", priority: "medium", done: false,
dueDate: null, ownerId: "u1", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
};
tasks = [t, ...tasks];
return HttpResponse.json({ success: true, data: t }, { status: 201 });
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => { server.resetHandlers(); tasks = []; });
afterAll(() => server.close());
const authed = { auth: { user: null, accessToken: "t", status: "authenticated" as const } };
test("adds a task and shows it in the list", async () => {
const user = userEvent.setup();
renderWithProviders(<TasksPage />, { preloadedState: authed });
await user.type(screen.getByPlaceholderText(/what needs doing/i), "Buy milk");
await user.click(screen.getByRole("button", { name: /add/i }));
const list = await screen.findByRole("list");
expect(await within(list).findByText("Buy milk")).toBeInTheDocument(); // appears after tag invalidation refetch
});Queries priority in React Testing Library: getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId. Testing by role also checks accessibility.
๐ป Code Example โ E2E with Playwright
// e2e/login.spec.ts
import { test, expect } from "@playwright/test";
test("user can log in and create a task", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("demo@x.com");
await page.getByLabel("Password").fill("Passw0rd!");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/");
await page.goto("/tasks");
await page.getByPlaceholder("What needs doing?").fill("E2E task");
await page.getByRole("button", { name: "Add" }).click();
await expect(page.getByText("E2E task")).toBeVisible();
});๐ป CI โ Type-check, Lint, Test
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx tsc -b # type errors fail the build
- run: npm run lint --workspaces --if-present
- run: npm test --workspaces --if-present -- --coverage๐ Real-World Impact
- In-memory Mongo + Supertest tests catch real bugs (missing index, wrong filter, validation gaps) that mocked tests miss, and still run in seconds.
- An explicit "cannot touch another user's data" test protects against the most common API security bug.
onUnhandledRequest: "error"in MSW makes forgotten network mocks fail loudly instead of silently.
๐ฏ Likely Interview Questions
- How do you test an Express API? โ
createApp()+ Supertest + mongodb-memory-server; assert status and body. - Mocking the DB vs an in-memory DB? โ In-memory gives realistic queries/indexes/validation; mocks are for pure unit tests of logic.
- How do you test protected routes? โ Sign a test JWT with the test secret, or log in through the API and reuse the token/cookie.
- How do you test React components that use Redux? โ Render with a real store (
setupStore+preloadedState), mock network with MSW. - What is MSW and why use it? โ Intercepts requests at the network layer; same mocks work in tests and the browser; no need to mock fetch/axios.
- Unit vs integration vs E2E? โ Scope and cost; most tests should be integration.
- How do you type mocks in TypeScript? โ
vi.mocked(fn)/jest.mocked(fn). - What runs in CI for a TS project? โ
tsc --noEmit/tsc -b, lint, tests with coverage, build.
โ Full-Stack Feature Walkthrough | Index | Next: Production & Deployment โ