Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 067 / 100

Testing MongoDB code: a practical MongoDB & NoSQL guide

Reliable tests for data access.

Expona Admin8 min read

Testing MongoDB code: a practical MongoDB & NoSQL guide

Data access layers are the heartbeat of modern applications, and they're also where bugs silently propagate into production. Testing MongoDB code demands a different mindset than testing business logic—you're not just verifying that your code runs, you're ensuring queries perform at scale and indexes actually exist when you need them. This guide walks you through the patterns and tools we've found essential for reliable MongoDB testing across our production systems.

Why MongoDB testing is different

Relational databases have been heavily tested for decades, and ORMs abstract much of the complexity. MongoDB forces you to think differently. You're writing queries directly against collections, managing your own indexes, and handling schema flexibility. A test that passes locally might fail in production because:

  • Your in-memory mock doesn't catch subtle query behavior.
  • Indexes don't exist in your test environment but do in production (or vice versa).
  • Seed data doesn't reflect real-world document shapes and sizes.
  • Pagination or sorting logic breaks with larger datasets.

This means your test strategy must be deliberate. You need real MongoDB instances, realistic data, and explicit assertions about query performance.

mongodb-memory-server — Fast in-memory MongoDB for Node tests

For unit and integration tests in Node.js environments, mongodb-memory-server offers a game-changing trade-off: you get a real MongoDB instance that runs in memory, spinning up and tearing down in milliseconds. This isn't a mock—it's an actual MongoDB process, so query behavior is authentic.

Setup is straightforward. Install the package, then configure it in your test suite:

javascript
import { MongoMemoryServer } from 'mongodb-memory-server';
import { MongoClient } from 'mongodb';

let mongoServer;
let client;
let db;

beforeAll(async () => {
  mongoServer = await MongoMemoryServer.create();
  const mongoUri = mongoServer.getUri();
  client = new MongoClient(mongoUri);
  await client.connect();
  db = client.db('test-db');
});

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

afterEach(async () => {
  const collections = await db.listCollections().toArray();
  for (const collection of collections) {
    await db.dropCollection(collection.name);
  }
});

This setup gives you a clean slate before each test, preventing state leakage. The speed is remarkable—spinning up takes under a second, so you can run hundreds of tests without meaningful overhead.

A realistic test might look like this:

javascript
describe('UserRepository', () => {
  it('should find active users by subscription tier', async () => {
    const usersCollection = db.collection('users');
    
    await usersCollection.insertMany([
      { _id: 1, email: 'alice@example.com', tier: 'premium', active: true },
      { _id: 2, email: 'bob@example.com', tier: 'free', active: true },
      { _id: 3, email: 'charlie@example.com', tier: 'premium', active: false },
    ]);
    
    const result = await usersCollection
      .find({ tier: 'premium', active: true })
      .toArray();
    
    expect(result).toHaveLength(1);
    expect(result[0].email).toBe('alice@example.com');
  });
});

The key advantage: you're testing against genuine MongoDB behavior, not a stubbed version. Aggregation pipelines, projection edge cases, and query operators all behave exactly as they would in production.

Testcontainers — Real MongoDB in Docker for any language

If you're working outside Node.js—or if you need to test across multiple services—Testcontainers gives you a language-agnostic approach. It orchestrates Docker containers for your tests, spinning up a real MongoDB instance that your test framework can connect to.

For Java-based services, the setup is elegant:

javascript
import org.testcontainers.containers.MongoDBContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
public class OrderRepositoryTest {
  
  @Container
  static MongoDBContainer mongo = new MongoDBContainer("mongo:6.0");
  
  private MongoClient mongoClient;
  private MongoDatabase db;
  
  @BeforeEach
  void setup() {
    mongoClient = MongoClients.create(mongo.getReplicaSetUrl());
    db = mongoClient.getDatabase("test-orders");
  }
  
  @Test
  void shouldFindOrdersByCustomerId() {
    MongoCollection<Document> orders = db.getCollection("orders");
    orders.insertOne(new Document("customerId", 123).append("total", 49.99));
    
    var result = orders.find(new Document("customerId", 123)).first();
    assertNotNull(result);
  }
}

The trade-off is startup time—Docker containers take a few seconds to boot, making them slower for rapid unit tests. Use mongodb-memory-server for most unit tests, and reserve Testcontainers for integration tests where you need language parity or testing alongside other services.

Testcontainers also supports replica sets, which is critical for testing transactions. If your code relies on multi-document transactions, you need a replica set:

javascript
@Container
static MongoDBContainer mongo = new MongoDBContainer("mongo:6.0")
  .withReplicaSet(new ReplicaSetOptions());

Seed data — Factories create realistic documents

Brittle tests fail because seed data is hand-coded, static, and unrealistic. Better approach: build factories that generate documents matching your actual schema and constraints.

We typically create a simple factory pattern:

javascript
class UserFactory {
  static create(overrides = {}) {
    return {
      _id: new ObjectId(),
      email: `user-${Math.random().toString(36).slice(2)}@example.com`,
      name: 'Test User',
      createdAt: new Date(),
      tier: 'free',
      metadata: {
        lastLogin: null,
        loginCount: 0,
      },
      ...overrides,
    };
  }
  
  static batch(count, overrides = {}) {
    return Array.from({ length: count }, () => this.create(overrides));
  }
}

// Usage in tests
const users = UserFactory.batch(100, { tier: 'premium' });
await usersCollection.insertMany(users);

This approach has multiple benefits:

  • Realism: Documents have the nested structures, field types, and sizes of production data.
  • Maintainability: When your schema evolves, you update the factory once, not fifty tests.
  • Readability: Tests focus on what matters—the overrides—rather than boilerplate field assignments.
  • Scale: You can easily generate hundreds of documents to test pagination, sorting, and performance edge cases.

For complex scenarios like time-based data or hierarchical documents, extend the factory:

javascript
class OrderFactory {
  static create(overrides = {}) {
    const createdAt = new Date();
    return {
      _id: new ObjectId(),
      customerId: new ObjectId(),
      createdAt,
      updatedAt: createdAt,
      status: 'pending',
      items: [
        { sku: 'ITEM-001', quantity: 1, price: 29.99 },
      ],
      total: 29.99,
      shippingAddress: {
        street: '123 Main St',
        city: 'Seattle',
        state: 'WA',
        zip: '98101',
      },
      ...overrides,
    };
  }
  
  static withItems(itemCount, overrides = {}) {
    const items = Array.from({ length: itemCount }, (_, i) => ({
      sku: `SKU-${String(i).padStart(3, '0')}`,
      quantity: Math.floor(Math.random() * 5) + 1,
      price: Math.random() * 100,
    }));
    
    const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    return this.create({ items, total, ...overrides });
  }
}

Index tests — Assert that critical queries use indexes

An unindexed query that works fine on 1,000 documents becomes a catastrophe at 10 million. Yet tests rarely verify that indexes actually exist and are being used. This is where many teams stumble.

MongoDB provides the explain() method, which returns execution plan details including whether an index was used:

javascript
describe('Query performance', () => {
  beforeEach(async () => {
    const usersCollection = db.collection('users');
    // Ensure the index exists
    await usersCollection.createIndex({ email: 1 });
    await usersCollection.createIndex({ tier: 1, active: 1 });
  });
  
  it('should use index for email lookup', async () => {
    const usersCollection = db.collection('users');
    await usersCollection.insertMany(UserFactory.batch(1000));
    
    const explanation = await usersCollection
      .find({ email: 'target@example.com' })
      .explain('executionStats');
    
    // If an index was used, executionStats.executionStages.stage will be 'COLLSCAN'
    // (collection scan) if NOT used, or 'IXSCAN' (index scan) if used
    expect(explanation.executionStats.executionStages.stage).toBe('IXSCAN');
  });
  
  it('should use compound index for tier and active filters', async () => {
    const usersCollection = db.collection('users');
    await usersCollection.insertMany(UserFactory.batch(1000));
    
    const explanation = await usersCollection
      .find({ tier: 'premium', active: true })
      .explain('executionStats');
    
    expect(explanation.executionStats.executionStages.stage).toBe('IXSCAN');
    // Also verify documents examined roughly equals returned (no excessive filtering)
    const { executionStats } = explanation;
    const efficiency = executionStats.nReturned / executionStats.totalDocsExamined;
    expect(efficiency).toBeGreaterThan(0.8); // At least 80% of examined docs were returned
  });
});

This is a critical safety net. If someone removes an index or adds a new query without creating an index, these tests fail immediately. We recommend treating index validation as a non-negotiable part of your test suite.

Common mistakes

Running tests against production: Even for validation, never point your test suite at a production database. Use isolated test instances—memory servers or containers—always.

Skipping index validation: Many teams test query logic but ignore whether indexes exist. A query returning correct results doesn't mean it's efficient. Always check execution plans.

Over-relying on mocks: Mocking MongoDB behavior leads to false positives. Your tests pass, but production queries fail due to nuanced behavior like type coercion or operator precedence. Prefer real instances.

Seed data that doesn't reflect production: Tests with five documents each don't catch pagination bugs or performance regressions. Use factories to generate realistic volumes.

Ignoring transaction requirements: If your code uses transactions, you need a replica set. A single-node instance will appear to work in tests but fail in production. Be explicit about this dependency.

Key takeaways

  • Use mongodb-memory-server for fast, realistic unit and integration tests in Node.js environments.
  • Testcontainers provides language-agnostic Docker-based testing, ideal for polyglot systems and transaction validation.
  • Build document factories to generate realistic seed data at scale, making tests maintainable and catching real-world edge cases.
  • Always validate that critical queries use indexes via explain(), treating index validation as a core part of your test suite.
  • Keep test databases isolated—never point tests at production or shared environments.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles