Skip to main content
Back to Blog
Guide
2026-05-02

Testcontainers MongoDB Node.js — Integration Testing Guide 2026

Complete guide to Testcontainers for MongoDB in Node.js. Real integration tests with Docker, Mongoose, replica sets, aggregation, and CI/CD patterns.

Testcontainers MongoDB Node.js Integration Testing Guide

MongoDB is the most-deployed document database in the world, and Node.js is the most popular runtime that connects to it. Yet testing MongoDB-dependent code well has historically been a struggle. The mongodb-memory-server package was good enough for years, but it does not support change streams, transactions require replica sets that mongodb-memory-server only emulates partially, and aggregation pipeline behavior occasionally diverges from real MongoDB. Testcontainers fixes all of this by running a real MongoDB instance in a Docker container, programmatically managed by your test runner, with one-line setup.

This guide is a deep walkthrough of using Testcontainers with MongoDB in Node.js for 2026. We cover installation, container lifecycle, replica set configuration for transaction testing, schema migration patterns, fixture seeding, change streams, aggregation pipelines, container reuse for fast local dev, and CI/CD setup. Every example is working TypeScript with Vitest and either the official mongodb driver or Mongoose.


Key Takeaways

  • MongoDbContainer is the official module for one-line MongoDB setup
  • Transactions require a replica set — MongoDbContainer can configure this for you
  • Change streams require a replica set and only work against real MongoDB, not in-memory mocks
  • Container reuse brings local startup from 5 seconds to under 1 second
  • CI/CD setup is trivial because Docker is available on GitHub Actions ubuntu runners

Why Use Testcontainers for MongoDB

mongodb-memory-server has been the de facto standard for years, and it works well for basic CRUD testing. But it has limitations. Change streams do not work because they require oplog tailing on a replica set; mongodb-memory-server only emulates a replica set superficially. Transactions work but with subtle differences in error behavior. Some aggregation operators behave inconsistently. The version of MongoDB bundled with mongodb-memory-server lags behind the latest releases.

Testcontainers solves all of these by running real MongoDB. Your tests see exactly the same behavior they will see in production, including change streams, transactions, and the latest aggregation operators.


Installation

npm install --save-dev testcontainers @testcontainers/mongodb
npm install --save-dev vitest mongodb
# or
npm install --save-dev vitest mongoose

Verify Docker is running with docker info.

Vitest config:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    testTimeout: 60_000,
    hookTimeout: 60_000,
    pool: 'forks',
  },
});

Your First Test (mongodb driver)

import { MongoDBContainer, StartedMongoDBContainer } from '@testcontainers/mongodb';
import { MongoClient, Db } from 'mongodb';
import { describe, it, beforeAll, afterAll, expect } from 'vitest';

describe('MongoDB integration', () => {
  let container: StartedMongoDBContainer;
  let client: MongoClient;
  let db: Db;

  beforeAll(async () => {
    container = await new MongoDBContainer('mongo:7.0').start();
    client = new MongoClient(container.getConnectionString(), {
      directConnection: true,
    });
    await client.connect();
    db = client.db('test');
  });

  afterAll(async () => {
    await client.close();
    await container.stop();
  });

  it('inserts and finds a document', async () => {
    const users = db.collection('users');
    await users.insertOne({ name: 'Alice', age: 30 });
    const result = await users.findOne({ name: 'Alice' });
    expect(result?.age).toBe(30);
  });
});

Note the directConnection: true option. MongoDbContainer starts MongoDB as a single-node replica set so transactions work, and the driver needs directConnection to skip SRV lookup.


With Mongoose

import mongoose from 'mongoose';

let connection: typeof mongoose;

beforeAll(async () => {
  container = await new MongoDBContainer('mongo:7.0').start();
  connection = await mongoose.connect(container.getConnectionString(), {
    directConnection: true,
    dbName: 'test',
  });
});

afterAll(async () => {
  await mongoose.disconnect();
  await container.stop();
});

it('saves a Mongoose document', async () => {
  const User = mongoose.model('User', new mongoose.Schema({ name: String, age: Number }));
  await User.create({ name: 'Bob', age: 25 });
  const found = await User.findOne({ name: 'Bob' });
  expect(found?.age).toBe(25);
});

MongoDbContainer API Reference

MethodPurpose
new MongoDBContainer(image)Constructor; image like mongo:7.0
.withReuse()Reuse container across runs
.withExposedPorts(port)Override exposed port
.withCommand(cmd)Override Docker CMD
.withEnvironment(env)Set env vars
.start()Boot container

After start:

MethodReturns
getHost()Hostname
getMappedPort(27017)Mapped port
getConnectionString()mongodb:// URI
getName()Container name

Per-Test Isolation

Two patterns work well.

Pattern 1: Drop collections between tests. Simple, works for most cases:

afterEach(async () => {
  const collections = await db.collections();
  await Promise.all(collections.map(c => c.deleteMany({})));
});

Pattern 2: Use a unique database per test. Clean isolation but slightly slower:

let testDb: Db;
beforeEach(async () => {
  const dbName = `test_${Date.now()}_${Math.random().toString(36).slice(2)}`;
  testDb = client.db(dbName);
});
afterEach(async () => {
  await testDb.dropDatabase();
});

Testing Transactions

Transactions require a replica set, which MongoDbContainer provides automatically:

it('rolls back on error', async () => {
  const accounts = db.collection('accounts');
  await accounts.insertMany([
    { _id: 'a', balance: 100 },
    { _id: 'b', balance: 0 },
  ]);

  const session = client.startSession();
  try {
    await session.withTransaction(async () => {
      await accounts.updateOne({ _id: 'a' }, { $inc: { balance: -50 } }, { session });
      await accounts.updateOne({ _id: 'b' }, { $inc: { balance: 50 } }, { session });
      throw new Error('simulated failure');
    });
  } catch (e) {
    // expected
  } finally {
    await session.endSession();
  }

  const a = await accounts.findOne({ _id: 'a' });
  expect(a?.balance).toBe(100); // unchanged because txn rolled back
});

This test would not work reliably against mongodb-memory-server because of transaction emulation gaps.


Testing Change Streams

Change streams stream document changes in real time. They require a replica set:

it('emits change events', async () => {
  const events: any[] = [];
  const users = db.collection('users');
  const stream = users.watch();
  stream.on('change', (change) => events.push(change));

  await new Promise(r => setTimeout(r, 100)); // let stream initialize

  await users.insertOne({ name: 'Carol' });
  await users.updateOne({ name: 'Carol' }, { $set: { age: 28 } });

  await new Promise(r => setTimeout(r, 500));
  await stream.close();

  expect(events.length).toBe(2);
  expect(events[0].operationType).toBe('insert');
  expect(events[1].operationType).toBe('update');
});

This was effectively impossible to test with mongodb-memory-server.


Testing Aggregation Pipelines

Aggregation pipelines are where production behavior matters most:

it('aggregates correctly', async () => {
  const orders = db.collection('orders');
  await orders.insertMany([
    { customer: 'alice', total: 100 },
    { customer: 'alice', total: 50 },
    { customer: 'bob', total: 200 },
  ]);

  const result = await orders.aggregate([
    { $group: { _id: '$customer', total: { $sum: '$total' } } },
    { $sort: { total: -1 } },
  ]).toArray();

  expect(result).toEqual([
    { _id: 'bob', total: 200 },
    { _id: 'alice', total: 150 },
  ]);
});

Indexing in Tests

Always create the same indexes in tests as in production:

beforeAll(async () => {
  await db.collection('users').createIndex({ email: 1 }, { unique: true });
});

it('enforces unique index', async () => {
  await db.collection('users').insertOne({ email: 'a@b.com' });
  await expect(
    db.collection('users').insertOne({ email: 'a@b.com' })
  ).rejects.toThrow(/duplicate key/);
});

Without the index, the duplicate would succeed and your test would falsely pass.


Container Reuse

container = await new MongoDBContainer('mongo:7.0')
  .withReuse()
  .start();

Enable in ~/.testcontainers.properties:

testcontainers.reuse.enable=true

Local test startup drops from 5 seconds to under 1 second.


CI/CD Configuration

name: test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm test

Comparison: Testcontainers vs mongodb-memory-server

FeatureTestcontainersmongodb-memory-server
Real MongoDBYesEmbedded
Latest MongoDB versionAny tagged imageLags behind
Change streamsFull supportLimited
TransactionsFullMostly works
Startup time (cold)5-8s3-5s
Startup time (warm)< 1s with reuse2-3s
CI complexityZeroZero
Resource usageContainer overheadIn-process
Multi-engine consistencyYesNo

For new projects in 2026, Testcontainers is the better default. mongodb-memory-server still has its place for ultra-fast unit tests, but anything touching transactions, change streams, or recent aggregation features should use Testcontainers.


Common Pitfalls

Missing directConnection. Without directConnection: true, the driver attempts SRV lookup and fails. Always include it.

Replica set not ready. MongoDbContainer waits for the replica set to be ready before returning from start(), but if you switch to GenericContainer manually, you need to wait yourself.

Resource limits. MongoDB containers use 1-2 GB RAM by default. If you spin up many in parallel, your machine will swap.

Forgetting to close. Always close the client in afterAll, or your test process will hang.


Conclusion

Testcontainers with MongoDB gives Node.js teams real, isolated MongoDB instances per test suite, with full support for transactions, change streams, and the latest aggregation operators. The setup is one line, CI requires no configuration, and container reuse keeps local iteration fast. For new projects, this is the right default for any test that touches MongoDB.

Explore the QA skills directory for more integration testing patterns, or compare with our PostgreSQL guide for multi-engine architectures.

Testcontainers MongoDB Node.js — Integration Testing Guide 2026 | QASkills.sh