Testing with Databases
Integration tests that exercise real persistence catch bugs that mocks never will: broken migrations, faulty queries, constraint violations, and transaction semantics. The challenge is doing this fast and reliably without tests bleeding state into each other. This page covers two proven strategies in NestJS, in-memory databases (mongodb-memory-server, SQLite) and disposable real databases via Testcontainers, plus per-test isolation through transaction rollback and fixture seeding.
Choosing a strategy
Each approach trades fidelity for speed. In-memory engines start instantly but may diverge from production behavior; Testcontainers spins up the exact engine you ship with, at the cost of Docker and a slower boot.
| Strategy | Fidelity | Speed | Best for |
|---|---|---|---|
mongodb-memory-server | High (real MongoDB binary, in-memory) | Fast | Mongoose-based apps |
| SQLite in-memory | Medium (SQL dialect differs) | Fastest | Simple TypeORM/Prisma logic |
| Testcontainers (Postgres/MySQL) | Highest (production engine) | Slower (Docker) | Migrations, vendor SQL, CI |
Prefer Testcontainers when your code uses database-specific features (JSONB,
RETURNING, window functions). SQLite in-memory will silently behave differently and give you false confidence.
In-memory MongoDB with Mongoose
mongodb-memory-server downloads and runs a real mongod binary backed by RAM. You wire its URI into MongooseModule inside the testing module.
// users.repository.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { MongooseModule, getModelToken } from '@nestjs/mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { Connection, Model } from 'mongoose';
import { User, UserSchema } from './user.schema';
describe('Users (mongo integration)', () => {
let mongod: MongoMemoryServer;
let moduleRef: TestingModule;
let userModel: Model<User>;
let connection: Connection;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
moduleRef = await Test.createTestingModule({
imports: [
MongooseModule.forRoot(mongod.getUri()),
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
],
}).compile();
userModel = moduleRef.get<Model<User>>(getModelToken(User.name));
connection = await moduleRef.get('DatabaseConnection');
});
afterEach(async () => {
await userModel.deleteMany({});
});
afterAll(async () => {
await moduleRef.close();
await mongod.stop();
});
it('persists and reads back a user', async () => {
await userModel.create({ email: '[email protected]', name: 'Ada' });
const found = await userModel.findOne({ email: '[email protected]' }).lean();
expect(found?.name).toBe('Ada');
});
});
The afterEach cleanup wipes the collection so each test starts empty, while afterAll tears down the server.
Disposable Postgres with Testcontainers
Testcontainers programmatically starts a throwaway Docker container and exposes its connection string. This gives the highest fidelity because it runs the real engine.
// orders.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { DataSource } from 'typeorm';
import { OrdersService } from './orders.service';
import { Order } from './order.entity';
describe('OrdersService (postgres integration)', () => {
let container: StartedPostgreSqlContainer;
let moduleRef: TestingModule;
let service: OrdersService;
let dataSource: DataSource;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine').start();
moduleRef = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
url: container.getConnectionUri(),
entities: [Order],
synchronize: true,
}),
TypeOrmModule.forFeature([Order]),
],
providers: [OrdersService],
}).compile();
service = moduleRef.get(OrdersService);
dataSource = moduleRef.get(DataSource);
}, 60_000);
afterAll(async () => {
await moduleRef.close();
await container.stop();
});
it('creates and totals an order', async () => {
const order = await service.create({ sku: 'BOOK-1', qty: 3, price: 9.99 });
expect(order.id).toBeDefined();
expect(Number(order.total)).toBeCloseTo(29.97);
});
});
Container startup can take several seconds, so raise the per-hook timeout (the
60_000above) and reuse a single container across the whole suite, not per test.
Per-test isolation with transaction rollback
Re-truncating tables between tests is correct but slow. A faster pattern wraps each test in a transaction and rolls it back afterward, so nothing is ever committed. With TypeORM you run a query runner per test:
import { QueryRunner } from 'typeorm';
describe('repository with rollback isolation', () => {
let queryRunner: QueryRunner;
beforeEach(async () => {
queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
});
afterEach(async () => {
await queryRunner.rollbackTransaction();
await queryRunner.release();
});
it('leaves no committed rows behind', async () => {
const repo = queryRunner.manager.getRepository(Order);
await repo.save({ sku: 'PEN-2', qty: 1, price: 1.5 });
expect(await repo.count()).toBe(1);
});
});
Because the data is never committed, the next test sees an empty database without an explicit DELETE.
Seeding fixtures
Most tests need a known baseline. Keep fixtures in a small factory so intent stays readable and you avoid copy-pasted object literals.
// test/fixtures/order.factory.ts
import { Repository } from 'typeorm';
import { Order } from '../../src/order.entity';
export async function seedOrders(repo: Repository<Order>) {
await repo.save([
{ sku: 'BOOK-1', qty: 1, price: 9.99, status: 'paid' },
{ sku: 'PEN-2', qty: 5, price: 1.5, status: 'pending' },
]);
}
Call seedOrders(dataSource.getRepository(Order)) inside beforeEach (within the transaction) so every test gets the same starting rows.
Output:
PASS src/orders/orders.service.spec.ts (12.4 s)
OrdersService (postgres integration)
✓ creates and totals an order (38 ms)
repository with rollback isolation
✓ leaves no committed rows behind (9 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Best practices
- Boot one database (container or memory server) per suite in
beforeAll, and always tear it down inafterAllto avoid leaked processes and ports. - Prefer transaction rollback for isolation; fall back to
deleteMany/TRUNCATEonly when your code commits its own transactions. - Match the production engine and version with Testcontainers whenever you rely on vendor-specific SQL or run real migrations.
- Centralize fixtures in factory functions instead of inline literals so tests describe scenarios, not schema.
- Increase Jest hook timeouts for Testcontainers and run integration tests in their own config separate from fast unit tests.
- Always call
moduleRef.close()so connection pools shut down cleanly and Jest can exit.