Testing strategy for a MERN API: what's actually worth testing
For a long time my test suites looked like every tutorial's: a controller function, a mocked req/res, a mocked model, and an assertion that the right mock method got called with the right arguments. Coverage looked great. Bugs kept shipping anyway. The gap wasn't effort, it was that mocking the database mocks away the exact layer where multi-tenant Express APIs actually break — query filters, index constraints, cast errors on malformed ObjectIds, aggregation pipeline typos. None of that shows up when Model.findOne is a jest mock that always returns whatever you told it to.
What follows is the strategy I've converged on after building a few production Express/MongoDB APIs, most recently a multi-tenant SaaS backend. It's not a purity argument about testing philosophy — it's just where the bugs actually were.
Stop mocking the database, start using a real one
mongodb-memory-server spins up an actual, ephemeral MongoDB instance in the test process. It's slower than a mock — tests run in tens of milliseconds instead of single-digit milliseconds — but it catches an entire category of bug that mocks can't: wrong field names in a query, a unique index that doesn't fire because the compound key is wrong, a $lookup that silently returns nothing because the localField doesn't match the ref.
import { MongoMemoryServer } from "mongodb-memory-server";
import mongoose from "mongoose";
let mongod: MongoMemoryServer;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
await mongoose.connect(mongod.getUri());
});
afterEach(async () => {
const collections = await mongoose.connection.db.collections();
await Promise.all(collections.map((c) => c.deleteMany({})));
});
afterAll(async () => {
await mongoose.disconnect();
await mongod.stop();
});
Wipe collections between tests, not the whole database — recreating indexes on every test is where the time actually goes, and you rarely need to.
Test through the HTTP layer, not the controller function
The second change was testing routes with supertest instead of calling controller functions directly. Calling a controller directly skips the exact code that tends to have the bug: middleware ordering, body parsing, the auth guard, error-handling middleware translating a thrown error into the right status code.
import request from "supertest";
import { app } from "../app";
import { createTestUser, authHeaderFor } from "./helpers";
describe("POST /api/projects", () => {
it("rejects a request with no membership in the target organization", async () => {
const user = await createTestUser();
const otherOrgId = "64f1a2b3c4d5e6f7a8b9c0d1";
const res = await request(app)
.post("/api/projects")
.set("Authorization", authHeaderFor(user))
.set("x-organization-id", otherOrgId)
.send({ name: "Q3 Launch" });
expect(res.status).toBe(403);
});
});
This one test would have caught a real bug I shipped: a route where the organization-scoping middleware ran after the validation middleware instead of before, so a well-formed request from a non-member got a 400 instead of a 403 — and, worse, one code path skipped the check entirely because validation returned early. A mocked-controller unit test can't see middleware ordering. An HTTP-level test sees exactly what a real client would.
Serializers deserve their own tests, deliberately
If your API does role-based field visibility — a member role gets a project without budget fields, an admin gets everything — that logic is a pure function, and pure functions are the one place classic unit tests still earn their keep:
describe("serializeProject", () => {
it.each(["member", "viewer"])("hides budget fields for %s", (role) => {
const project = buildProject({ budget: 50000, billedTotal: 12000 });
const result = serializeProject(project, role as Role);
expect(result).not.toHaveProperty("budget");
expect(result).not.toHaveProperty("billedTotal");
});
it("includes budget fields for admin and finance", () => {
const project = buildProject({ budget: 50000 });
expect(serializeProject(project, "admin")).toHaveProperty("budget", 50000);
});
});
These are cheap, fast, and catch the exact bug that matters most for this kind of logic: someone adds a new sensitive field to the schema and forgets to add it to the visibility rule. A snapshot or a loop over roles catches that in seconds; nothing else will, because it's not a bug that throws an error — the response just quietly contains data it shouldn't.
Where unit tests still make sense
Not everything belongs behind HTTP. Pure business logic with no I/O — pricing calculations, date-range overlap checks, validation schemas, the serializers above — should be unit tested directly, because spinning up Express and Mongo for a function with no side effects just adds noise and slows the suite for no benefit. The rule I use: if the function touches the database, the request, or the response, test it through HTTP with a real (in-memory) database. If it's a pure transformation, unit test it directly and skip the ceremony.
One end-to-end test, not fifty
I keep exactly one true end-to-end suite — a real browser via Playwright, hitting a real deployed staging environment — and it covers the single flow that would be catastrophic if it silently broke: sign up, create an organization, invite a teammate, that teammate accepts and lands with the correct role. Everything else — the twenty edge cases around permissions, validation, pagination — lives in the supertest layer, because it's an order of magnitude faster and the failure is easier to localize. E2E tests are for confirming the seams between systems actually connect, not for enumerating business logic; once you're using it for the latter, every test takes ten times longer to write and to debug when it goes red for an unrelated reason.
What changed in practice
The suite is smaller than the old mock-everything version and it fails less often for the wrong reasons — a refactor that changes an internal function signature but not behavior no longer breaks forty tests that were really just asserting implementation details. When it does fail, it fails on the thing that matters: a real request against a real database returned the wrong thing. That's the signal worth optimizing test suites for, and it's a different one than "every function has a test file."