
Testing MongoDB Code with mongodb-memory-server and Jest
There are two popular ways to test code that talks to MongoDB, and both have problems. You can mock the driver, which gives you fast tests that verify your code called find() with some object, but tells you nothing about whether that object actually matches the documents you expect. Or you can point your tests at a shared development database, which is realistic but slow, flaky, and prone to tests stepping on each other's data.
mongodb-memory-server sits between these. It downloads a real mongod binary, starts it on a random port with its data in a temporary directory, and tears it down when your tests finish. Your queries, aggregation pipelines, indexes, and unique constraints all run against the real server, but nothing touches your development data and nothing needs to be running before npm test.
This guide covers setting up mongodb-memory-server with Jest, choosing between a server per file or per run, isolating data between tests, testing Mongoose models and native driver code, running replica sets for transactions and change streams, and making it all fast and reliable in CI.
Installing the Pieces
You need Jest, the MongoDB driver (or Mongoose), and mongodb-memory-server:
npm install mongodb
npm install --save-dev jest mongodb-memory-server
The first time mongodb-memory-server runs, it downloads a mongod binary for your platform (a bit over 100 MB) and caches it, by default under node_modules/.cache/mongodb-memory-server. Later runs reuse the cached binary.
You can pin the MongoDB version so tests match production. Add a config block to package.json:
{
"config": {
"mongodbMemoryServer": {
"version": "8.0.4"
}
}
}
Or set the MONGOMS_VERSION environment variable. Pinning matters: the default version shipped with the package changes between releases, and you don't want a library upgrade to silently change the server your tests run against. Pick the version that matches your production cluster.
The Code Under Test
Here's a small repository module we'll test. It takes a Db instance rather than creating its own client, which is the most important design decision for testability:
// src/users.js
export function createUserRepo(db) {
const users = db.collection("users");
return {
async init() {
await users.createIndex({ email: 1 }, { unique: true });
},
async register({ email, name }) {
const doc = {
email: email.toLowerCase(),
name,
createdAt: new Date(),
loginCount: 0,
};
const { insertedId } = await users.insertOne(doc);
return { _id: insertedId, ...doc };
},
async recordLogin(email) {
return users.findOneAndUpdate(
{ email: email.toLowerCase() },
{ $inc: { loginCount: 1 }, $set: { lastLoginAt: new Date() } },
{ returnDocument: "after" },
);
},
async topUsers(limit = 5) {
return users
.find({}, { projection: { _id: 0, email: 1, loginCount: 1 } })
.sort({ loginCount: -1 })
.limit(limit)
.toArray();
},
};
}
Because the database is injected, tests can hand it a Db connected to the in-memory server, while production code hands it the real one. If your modules import a global client at load time, refactor that first; it'll make every kind of testing easier.
A Server Per Test File
The simplest setup starts one server in beforeAll and stops it in afterAll:
// src/users.test.js
import { MongoMemoryServer } from "mongodb-memory-server";
import { MongoClient } from "mongodb";
import { createUserRepo } from "./users.js";
let mongod;
let client;
let repo;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
client = new MongoClient(mongod.getUri());
await client.connect();
repo = createUserRepo(client.db("test"));
await repo.init();
});
afterAll(async () => {
await client.close();
await mongod.stop();
});
beforeEach(async () => {
await client.db("test").collection("users").deleteMany({});
});
test("register lowercases email", async () => {
const user = await repo.register({ email: "Ada@Example.com", name: "Ada" });
expect(user.email).toBe("ada@example.com");
});
test("duplicate emails are rejected by the unique index", async () => {
await repo.register({ email: "ada@example.com", name: "Ada" });
await expect(
repo.register({ email: "ADA@example.com", name: "Ada 2" }),
).rejects.toMatchObject({ code: 11000 });
});
test("recordLogin increments the counter", async () => {
await repo.register({ email: "ada@example.com", name: "Ada" });
await repo.recordLogin("ada@example.com");
const updated = await repo.recordLogin("ADA@example.com");
expect(updated.loginCount).toBe(2);
expect(updated.lastLoginAt).toBeInstanceOf(Date);
});
The duplicate key test is a good example of something a mock can't give you. It proves the unique index exists and that your lowercasing happens before insert. If you want to go deeper on that error, see handling duplicate key errors.
Note that findOneAndUpdate in Node.js driver 6.x returns the document directly (or null), not a { value } wrapper as older versions did. Tests like this one catch that kind of upgrade breakage immediately.
ESM and Jest
The example uses ES module syntax. Jest's native ESM support still requires running with NODE_OPTIONS=--experimental-vm-modules, or you can transform with Babel or use ts-jest for TypeScript. If you'd rather not fight the tooling, CommonJS require works everywhere. The MongoDB-specific parts of this guide are the same either way.
One Server for the Whole Run
Starting a mongod takes a second or so. With dozens of test files, that adds up. Jest's globalSetup and globalTeardown let you start a single server for the entire run and share its URI through an environment variable:
// jest.global-setup.js
import { MongoMemoryServer } from "mongodb-memory-server";
export default async function globalSetup() {
const mongod = await MongoMemoryServer.create();
globalThis.__MONGOD__ = mongod;
process.env.MONGO_URI = mongod.getUri();
}
// jest.global-teardown.js
export default async function globalTeardown() {
await globalThis.__MONGOD__.stop();
}
// jest.config.js
export default {
testEnvironment: "node",
globalSetup: "./jest.global-setup.js",
globalTeardown: "./jest.global-teardown.js",
};
Global setup runs in a separate context from the test files, so globalThis.__MONGOD__ is only visible to the teardown script. Environment variables set in global setup, however, are inherited by the test workers, which is why the URI goes through process.env.
Isolating Parallel Workers
Jest runs test files in parallel workers. If every file uses the database test and wipes collections in beforeEach, workers will delete each other's data mid-test and you'll see bizarre intermittent failures. Give each worker (or each file) its own database:
// test/db.js
import { MongoClient } from "mongodb";
import { randomUUID } from "node:crypto";
export async function connectTestDb() {
const client = new MongoClient(process.env.MONGO_URI);
await client.connect();
const db = client.db(
`test_${process.env.JEST_WORKER_ID}_${randomUUID().slice(0, 8)}`,
);
return {
client,
db,
async cleanup() {
await db.dropDatabase();
await client.close();
},
};
}
A database per file is cheap in MongoDB; databases are created lazily on first write. Using a unique name per file means you don't even have to worry about workers sharing an ID across files.
Resetting Data Between Tests
You have three reasonable options for isolating tests within a file:
| Strategy | Speed | Keeps indexes | Notes |
|---|---|---|---|
deleteMany({}) on each collection | Fast | Yes | Best default; indexes stay in place |
collection.drop() | Fast | No | Must recreate indexes before each test |
db.dropDatabase() | Fast | No | Simplest; good in afterAll |
For most suites, clearing every collection in beforeEach while keeping indexes is the right balance:
export async function clearDatabase(db) {
const collections = await db.collections();
await Promise.all(collections.map((c) => c.deleteMany({})));
}
db.collections() skips system collections, so this is safe to call without filtering.
Testing Mongoose Models
Mongoose works the same way, with one wrinkle: Mongoose keeps a default connection and compiled models at module scope. Connect once per file and disconnect at the end:
// src/models/Product.js
import mongoose from "mongoose";
const productSchema = new mongoose.Schema({
sku: { type: String, required: true, unique: true },
name: { type: String, required: true },
price: { type: Number, min: 0, required: true },
tags: [String],
});
productSchema.statics.findByTag = function (tag) {
return this.find({ tags: tag }).sort({ price: 1 }).lean();
};
export const Product = mongoose.model("Product", productSchema);
// src/models/Product.test.js
import mongoose from "mongoose";
import { MongoMemoryServer } from "mongodb-memory-server";
import { Product } from "./Product.js";
let mongod;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
await mongoose.connect(mongod.getUri(), { dbName: "products_test" });
await Product.syncIndexes();
});
afterAll(async () => {
await mongoose.disconnect();
await mongod.stop();
});
afterEach(async () => {
await Product.deleteMany({});
});
test("rejects negative prices", async () => {
await expect(
Product.create({ sku: "A1", name: "Lamp", price: -5 }),
).rejects.toThrow(mongoose.Error.ValidationError);
});
test("findByTag sorts by price", async () => {
await Product.create([
{ sku: "A1", name: "Lamp", price: 40, tags: ["home"] },
{ sku: "A2", name: "Rug", price: 25, tags: ["home"] },
{ sku: "A3", name: "Pen", price: 3, tags: ["office"] },
]);
const home = await Product.findByTag("home");
expect(home.map((p) => p.sku)).toEqual(["A2", "A1"]);
});
Product.syncIndexes() matters. Mongoose builds indexes in the background when the model is first used, and a test that runs immediately afterward may race the index build. Awaiting syncIndexes() (or Model.init()) guarantees the unique index exists before you test it.
Replica Sets for Transactions and Change Streams
A standalone mongod doesn't support multi-document transactions or change streams. If your code uses either, start a single-node replica set instead with MongoMemoryReplSet:
import { MongoMemoryReplSet } from "mongodb-memory-server";
import { MongoClient } from "mongodb";
let replSet;
let client;
beforeAll(async () => {
replSet = await MongoMemoryReplSet.create({
replSet: { count: 1, storageEngine: "wiredTiger" },
});
client = new MongoClient(replSet.getUri());
await client.connect();
});
afterAll(async () => {
await client.close();
await replSet.stop();
});
test("transfer is atomic", async () => {
const accounts = client.db("bank").collection("accounts");
await accounts.insertMany([
{ _id: "a", balance: 100 },
{ _id: "b", balance: 0 },
]);
const session = client.startSession();
await expect(
session.withTransaction(async () => {
await accounts.updateOne(
{ _id: "a" },
{ $inc: { balance: -150 } },
{ session },
);
const a = await accounts.findOne({ _id: "a" }, { session });
if (a.balance < 0) throw new Error("Insufficient funds");
await accounts.updateOne(
{ _id: "b" },
{ $inc: { balance: 150 } },
{ session },
);
}),
).rejects.toThrow("Insufficient funds");
await session.endSession();
expect(await accounts.findOne({ _id: "a" })).toEqual({
_id: "a",
balance: 100,
});
});
The wiredTiger storage engine option is worth setting explicitly. Older versions of mongodb-memory-server defaulted to the ephemeralForTest engine, which was removed from recent MongoDB servers, and recent mongodb-memory-server versions pick wiredTiger automatically for newer binaries. Being explicit avoids surprises.
A replica set takes a little longer to start because it has to elect a primary, so only use it in test files that need it.
Testing Aggregation Pipelines
Aggregations are where real-database tests pay off the most. A pipeline is essentially a small program, and the only way to know it's correct is to run it against data. Keep pipelines in functions you can import, seed a handful of documents that cover the edge cases, and assert on the exact output:
export const revenueByMonth = (year) => [
{
$match: {
status: "paid",
paidAt: {
$gte: new Date(`${year}-01-01`),
$lt: new Date(`${year + 1}-01-01`),
},
},
},
{
$group: {
_id: { $month: "$paidAt" },
revenue: { $sum: "$total" },
orders: { $sum: 1 },
},
},
{ $sort: { _id: 1 } },
];
test("revenueByMonth ignores unpaid and other-year orders", async () => {
const orders = db.collection("orders");
await orders.insertMany([
{ status: "paid", total: 50, paidAt: new Date("2026-01-10") },
{ status: "paid", total: 25, paidAt: new Date("2026-01-20") },
{ status: "paid", total: 80, paidAt: new Date("2026-03-02") },
{ status: "refunded", total: 99, paidAt: new Date("2026-03-05") },
{ status: "paid", total: 10, paidAt: new Date("2025-12-31") },
]);
const rows = await orders.aggregate(revenueByMonth(2026)).toArray();
expect(rows).toEqual([
{ _id: 1, revenue: 75, orders: 2 },
{ _id: 3, revenue: 80, orders: 1 },
]);
});
You can also run explain() in a test to assert that a critical query uses an index, which catches index regressions before they reach production:
const plan = await orders.find({ customerId: 42 }).explain("queryPlanner");
expect(JSON.stringify(plan.queryPlanner.winningPlan)).toContain("IXSCAN");
Making It Fast and Reliable in CI
Cache the binary. In GitHub Actions, cache the download directory so each run doesn't fetch 100+ MB:
- uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.mongodb-binaries
key: mongoms-${{ runner.os }}-8.0.4
- run: npm test
env:
MONGOMS_VERSION: 8.0.4
MONGOMS_DOWNLOAD_DIR: ${{ github.workspace }}/.mongodb-binaries
Match the distro. mongodb-memory-server picks a binary based on the detected OS. On unusual Linux images (Alpine, for example) there may be no official binary, and you'll need a Debian or Ubuntu based image or MONGOMS_SYSTEM_BINARY pointing at an installed mongod.
Raise the timeout. The first run in a fresh environment includes the download. Set testTimeout in your Jest config (or pass a timeout to beforeAll) so setup doesn't fail at Jest's 5-second default.
Close everything. If Jest reports "did not exit one second after the test run has completed", you've left a client open or a server running. Close every MongoClient and call stop() on every server in teardown.
Common Pitfalls
Sharing one database across parallel workers. Cleanup in one file deletes data another file is using. Give each file or worker a unique database name.
Testing before indexes exist. Unique constraint and explain() tests will pass or fail randomly if the index is still building. Await createIndex, syncIndexes(), or Model.init() in setup.
Using a standalone server for transaction code. You'll get "Transaction numbers are only allowed on a replica set member or mongos". Switch that file to MongoMemoryReplSet.
Letting the server version float. Tests pass locally on one MongoDB version and CI runs another. Pin MONGOMS_VERSION to your production version.
Hard-coding the client inside modules. If a module connects to process.env.MONGODB_URI at import time, you have to juggle environment variables before imports. Inject the Db or client instead.
Conclusion
mongodb-memory-server gives you the realism of a real MongoDB server with the isolation and convenience of a mock. Your tests exercise real queries, real indexes, real validation, and real transactions, and every run starts from a clean slate. Start one server per run with Jest's global setup, give each test file its own database, clear collections between tests, and reach for MongoMemoryReplSet when you need transactions or change streams.
Pick the one module in your codebase with the most complex query or aggregation pipeline, write a single test that seeds five documents and asserts the exact output, and run it. Once you see how quickly it catches a wrong $match, you'll want the rest of your data layer covered too.


