Isolating MongoDB with mongodb-memory-server
Aggregation pipelines and index behavior are the parts of a MongoDB data layer most worth testing and least possible to fake — a mock of the driver can never tell you whether a $group stage or a partial index actually does what you think. This guide is for engineers on Vitest 1.x who want each test to run against a real mongod, spun up ephemerally by mongodb-memory-server, fast enough for CI and isolated per worker. It sits in the database and ORM mocking topic area and takes the low seam, the document-store counterpart to an in-memory SQLite for repository tests. We cover one server per worker, per-test cleanup, and amortizing the startup cost that makes this the most expensive strategy in the topic area.
Root Cause Analysis
MongoDB code resists cheap mocking more than most because its power lives in the server, not the client. Query operators, aggregation stages, unique and partial indexes, and TTL collections are all evaluated by mongod; a driver mock that returns canned documents bypasses every one of them, so the exact logic you most want to verify goes untested. Pointing tests at a shared Mongo instead makes them slow to connect, stateful across runs, and hostile to parallelism because collections persist between tests.
mongodb-memory-server resolves this by downloading a real mongod binary once and launching a throwaway instance backed by an ephemeral storage engine. Your code connects with the ordinary driver or Mongoose, and every query, pipeline, and index runs against the genuine server — then the instance is torn down and leaves nothing behind. The one cost that dominates the design is startup: booting mongod takes real time, so doing it per test would be ruinous. The correct pattern is one server per worker started in global setup and reused, with cleanup happening at the collection level between tests. That gives you real-engine fidelity with a startup cost paid once per worker rather than once per test, which is the trade-off the topic area flags as this strategy’s defining constraint.
The payoff for accepting that startup cost is that you can finally test the parts of MongoDB that carry the most risk and are the least amenable to reasoning by inspection. Aggregation pipelines are effectively small programs, and a $group followed by a $lookup and a $sort can produce results that surprise even their author; only running the pipeline tells you the truth. Index behavior is similarly opaque — whether a partial index actually excludes the documents you intended, or a compound index supports the sort you rely on, is not something a mock can answer. By running against a real mongod, these tests move from hopeful to authoritative, and the one-time boot cost is a small price for that certainty.
Reproducible Setup
Install Vitest, the memory server, and the driver. The memory server package manages the mongod binary for you, downloading and caching it on first use, so there is no separate MongoDB installation to maintain; the only operational concern is making sure that cache survives between CI runs, which the troubleshooting section covers.
npm install -D vitest mongodb-memory-server
npm install mongodb
Create a helper that starts one server and connects a client. Keeping this in a module lets each worker own its own instance.
// src/db/mongo-test.ts
import { MongoMemoryServer } from 'mongodb-memory-server';
import { MongoClient, type Db } from 'mongodb';
let server: MongoMemoryServer;
let client: MongoClient;
export async function startMongo(): Promise<Db> {
server = await MongoMemoryServer.create(); // boots a real mongod
client = await MongoClient.connect(server.getUri());
return client.db('test');
}
export async function stopMongo() {
await client?.close();
await server?.stop();
}
// src/repos/order-repo.ts — the system under test
import type { Db } from 'mongodb';
export async function revenueByCustomer(db: Db) {
return db.collection('orders').aggregate([
{ $group: { _id: '$customerId', total: { $sum: '$amount' } } },
{ $sort: { total: -1 } },
]).toArray();
}
Implementation
Step 1 — Boot one server per worker, not per test. Start mongod in beforeAll so the expensive step runs once for the whole file, which Vitest schedules on a single worker. Tear it down in afterAll.
// src/repos/order-repo.test.ts
import { beforeAll, afterAll, afterEach, describe, it, expect } from 'vitest';
import type { Db } from 'mongodb';
import { startMongo, stopMongo } from '../db/mongo-test';
import { revenueByCustomer } from './order-repo';
let db: Db;
beforeAll(async () => { db = await startMongo(); });
afterAll(async () => { await stopMongo(); });
Step 2 — Clean collections between tests, not the whole server. Dropping collections in afterEach is far cheaper than restarting mongod and gives each test an empty slate.
afterEach(async () => {
const collections = await db.collections();
await Promise.all(collections.map((c) => c.deleteMany({}))); // empty every collection
});
Step 3 — Seed and run the real pipeline. Insert documents, then execute the aggregation through the code path under test so the genuine $group and $sort stages run on the server.
it('sums revenue per customer, highest first', async () => {
await db.collection('orders').insertMany([
{ customerId: 'c1', amount: 30 },
{ customerId: 'c1', amount: 20 },
{ customerId: 'c2', amount: 40 },
]);
const rows = await revenueByCustomer(db);
expect(rows).toEqual([
{ _id: 'c1', total: 50 },
{ _id: 'c2', total: 40 },
]); // proves the $group and $sort actually executed
});
Step 4 — Test index behavior, which no mock can fake. Create a unique index and assert the server rejects a duplicate. This is exactly the fidelity that justifies a real engine.
it('enforces a unique index on sku', async () => {
await db.collection('products').createIndex({ sku: 1 }, { unique: true });
await db.collection('products').insertOne({ sku: 'A1' });
await expect(
db.collection('products').insertOne({ sku: 'A1' }),
).rejects.toThrow(/duplicate key/);
});
Step 5 — Guarantee per-worker isolation in parallel CI. Because each worker calls startMongo in its own beforeAll, every worker gets a distinct mongod on its own port and URI. There is no shared server to collide over, so parallelism is safe by construction — the same per-worker isolation the topic area applies to every store.
Verification
Confirm the tests run against a real engine by asserting on behavior only mongod produces: the aggregation must return the exact grouped and sorted totals, and the unique index must throw a duplicate-key error on a second insert. A driver mock could fake the first with enough wiring but never the second. Then run the file alone and under parallel workers and confirm identical results, which proves each worker’s server is isolated and the per-test collection cleanup is complete. Watch the first run download the mongod binary once and cache it; subsequent runs should skip the download and start in a second or two.
npx vitest run src/repos/order-repo.test.ts
Correct aggregation output, a real duplicate-key rejection, and stable results across parallel workers together confirm the seam is exercising genuine MongoDB rather than a stand-in.
Where the cost of this strategy actually lands is worth picturing, because it explains why the per-worker, per-file boot pattern matters so much. The binary download is a one-time cost cached across all runs; the mongod boot is a per-worker cost paid once at the start of a file; and collection cleanup is the only per-test cost, and it is cheap. Push any of these to the wrong granularity and the suite’s runtime balloons.
Troubleshooting
Symptom: the first test run stalls or times out. Diagnosis: mongodb-memory-server is downloading the mongod binary on first use, which can exceed the default test timeout. Fix: raise the hook timeout for beforeAll, pre-download the binary in a CI cache step, and pin the version so the cache is stable across runs.
Symptom: tests are slow because a server starts for every test. Diagnosis: MongoMemoryServer.create() is being called in beforeEach rather than beforeAll, paying the startup cost per test. Fix: boot once in beforeAll and clean collections in afterEach; restart the server only when a test genuinely needs a fresh instance.
Symptom: parallel workers intermittently fail to connect. Diagnosis: a single shared server URI is being reused across workers, or connections are not closed in afterAll, exhausting handles. Fix: start one server per worker inside the file’s beforeAll, and always client.close() and server.stop() in afterAll so no mongod process or connection leaks.
FAQ
Why not just mock the MongoDB driver instead of running a real server?
Because the behavior worth testing lives in the server, not the driver — aggregation stages, query operators, unique and partial indexes, and TTL collections are all evaluated by mongod, and a driver mock bypasses every one of them. Mock the driver only when the database is a mere collaborator and the logic under test is orchestration, in which case the fast high seam is appropriate. When the pipeline or index is the subject, a real ephemeral server is the only thing that gives a truthful answer.
How do I keep mongodb-memory-server fast enough for CI?
Pay the startup cost as rarely as possible: boot one instance per worker in beforeAll and reuse it, cleaning collections between tests rather than restarting the server. Cache the downloaded mongod binary in CI and pin its version so runs after the first skip the download entirely. With those two measures the per-file overhead is a one-time couple of seconds, and every test after that runs at in-memory speed. If your suite is large enough that even per-file boots add up, consider a global setup that starts a single server and shares its URI across files through an environment variable, trading a little isolation for a lower fixed cost; most teams find per-file boots strike the better balance.
Does each parallel worker need its own server, and how is that isolated?
Yes — give each worker its own instance, which happens naturally when startMongo runs in each file’s beforeAll, since Vitest schedules a file on a single worker and each MongoMemoryServer.create() binds a distinct port and URI. There is no shared server, so workers cannot see each other’s data and cannot collide on state. Combined with per-test collection cleanup, this makes a fully parallel MongoDB suite isolated by construction.
Related
- Back to Database & ORM Mocking
- Using an in-memory SQLite for repository tests — the relational counterpart to this real-engine approach.
- Mocking Prisma client in Vitest — the high seam for when the query is not the subject.
- When to skip integration tests in favor of unit tests — decide when a real-server test earns its cost.