Unit tests with a mocked Mongoose model tell you very little about a MEAN API. They pass while your unique index is missing, your aggregation pipeline returns the wrong shape, or your transaction code fails on a real replica set. This tutorial sets up integration tests that run the Express 5 app against a real, disposable MongoDB container using Testcontainers and the Node built-in test runner. The same suite runs locally and in CI.
What we are building
- An Express 5 app exported as a factory, so tests can start it without binding a port.
- A MongoDB container started once per test run, with a fresh database per test file.
- Tests that call the API over HTTP, including a unique-index failure and a multi-document transaction.
1. Make the app testable
Separate the app from the server. Tests import createApp(), and only server.ts calls listen.
// src/app.ts
import express from 'express';
import { Order } from './models/order.js';
export function createApp() {
const app = express();
app.use(express.json());
app.post('/orders', async (req, res) => {
const order = await Order.create(req.body);
res.status(201).json(order);
});
app.get('/orders/:id', async (req, res) => {
const order = await Order.findById(req.params.id).lean();
if (!order) return res.status(404).json({ error: 'not_found' });
res.json(order);
});
// Express 5 forwards rejected promises from async handlers here
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (err?.code === 11000) return res.status(409).json({ error: 'duplicate' });
if (err?.name === 'ValidationError') return res.status(400).json({ error: 'invalid' });
res.status(500).json({ error: 'internal' });
});
return app;
}
// src/server.ts
import mongoose from 'mongoose';
import { createApp } from './app.js';
await mongoose.connect(process.env.MONGO_URL!);
createApp().listen(3000);
The model carries the unique index we want to verify:
// src/models/order.ts
import { Schema, model } from 'mongoose';
const orderSchema = new Schema({
reference: { type: String, required: true, unique: true },
total: { type: Number, required: true, min: 0 },
});
export const Order = model('Order', orderSchema);
2. Start MongoDB with Testcontainers
Install the dev dependencies. You need Docker (or a compatible runtime) available on the machine.
npm i -D testcontainers @testcontainers/mongodb
Create a helper that starts one container and gives each test file its own database name:
// test/helpers/db.ts
import { MongoDBContainer, StartedMongoDBContainer } from '@testcontainers/mongodb';
import mongoose from 'mongoose';
import { randomUUID } from 'node:crypto';
let container: StartedMongoDBContainer | undefined;
export async function connectTestDb() {
container ??= await new MongoDBContainer('mongo:8').start();
const dbName = `test_${randomUUID().slice(0, 8)}`;
await mongoose.connect(container.getConnectionString(), {
dbName,
directConnection: true,
});
// Build indexes now so unique constraints exist before the first test
await mongoose.connection.syncIndexes();
}
export async function disconnectTestDb() {
await mongoose.connection.dropDatabase();
await mongoose.disconnect();
await container?.stop();
container = undefined;
}
MongoDBContainer starts a single-node replica set, so transactions work. That is a large advantage over mocks and over a plain standalone mongod. Pinning mongo:8 keeps the test database aligned with production; match your real server version.
3. Write the tests
We use node:test and fetch, so there is no Jest or Supertest. Starting the app on port 0 picks a free port.
// test/orders.test.ts
import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import type { Server } from 'node:http';
import { createApp } from '../src/app.js';
import { Order } from '../src/models/order.js';
import { connectTestDb, disconnectTestDb } from './helpers/db.js';
describe('orders API', () => {
let server: Server;
let base: string;
before(async () => {
await connectTestDb();
server = createApp().listen(0);
const { port } = server.address() as { port: number };
base = `http://127.0.0.1:${port}`;
});
after(async () => {
server.close();
await disconnectTestDb();
});
beforeEach(async () => {
await Order.deleteMany({});
});
const post = (body: unknown) =>
fetch(`${base}/orders`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
it('creates and reads an order', async () => {
const created = await post({ reference: 'A-1', total: 25 });
assert.equal(created.status, 201);
const { _id } = await created.json();
const res = await fetch(`${base}/orders/${_id}`);
assert.equal(res.status, 200);
assert.equal((await res.json()).reference, 'A-1');
});
it('rejects a duplicate reference with 409', async () => {
await post({ reference: 'A-1', total: 25 });
const dup = await post({ reference: 'A-1', total: 30 });
assert.equal(dup.status, 409);
});
it('rejects a negative total with 400', async () => {
const res = await post({ reference: 'A-2', total: -5 });
assert.equal(res.status, 400);
});
});
The duplicate test is the one a mock can never give you: it fails if someone removes unique: true or forgets to build indexes.
4. Test a transaction against the real thing
Because the container is a replica set, you can test code that relies on session.withTransaction:
// test/transfer.test.ts
import { it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import mongoose from 'mongoose';
import { Order } from '../src/models/order.js';
import { connectTestDb, disconnectTestDb } from './helpers/db.js';
before(connectTestDb);
after(disconnectTestDb);
it('rolls back both writes when the second fails', async () => {
await Order.create({ reference: 'T-1', total: 10 });
const session = await mongoose.startSession();
await assert.rejects(() =>
session.withTransaction(async () => {
await Order.updateOne({ reference: 'T-1' }, { $inc: { total: 5 } }, { session });
await Order.create([{ reference: 'T-1', total: 1 }], { session }); // duplicate key
})
);
await session.endSession();
const order = await Order.findOne({ reference: 'T-1' }).lean();
assert.equal(order?.total, 10); // the $inc was rolled back
});
5. Run it
{
"scripts": {
"test:integration": "node --test --test-concurrency=1 --experimental-test-isolation=process test/**/*.test.ts"
}
}
On recent Node versions TypeScript files run through type stripping, so no transpile step is needed. Check the flags against your Node version, since test-runner options have changed between releases. Each test file runs in its own process, so each starts its own container; for larger suites, move container startup into a single shared setup step and pass the connection string through an environment variable.
6. Run it in CI
GitHub-hosted Ubuntu runners include Docker, so no service container is required:
# .github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run test:integration
Practical tips
- Pre-pull the image in CI (
docker pull mongo:8) to keep timing predictable. - Isolate by database, not by container. Starting a container takes seconds; dropping a database takes milliseconds.
- Always call
syncIndexes(). Mongoose builds indexes lazily, and a test that races index creation will be flaky. - Keep the pyramid. Pure logic still belongs in fast unit tests. Use containers for queries, indexes, transactions and HTTP wiring.
- Seed with factories, not shared fixtures, so tests can run in any order.
Need help with it?
If your MEAN API has little or no automated test coverage, or tests that are slow and flaky, our MEAN Stack Testing service can help set up this kind of pipeline. You can reach us on the Get In Touch page.