Testing MongoDB-backed TypeScript applications gets messy fast. You need a real database for integration tests, but real databases introduce environment dependencies, data pollution between runs, and CI setup complexity. Three tools solve this problem cleanly: Typegoose for typed Mongoose models, mongodb-memory-server for ephemeral test databases, and the Service Pattern for keeping business logic testable. This post shows how they fit together.
Why Typegoose?
Typegoose wraps Mongoose models with TypeScript classes. Standard Mongoose requires you to define schemas separately from any TypeScript interfaces you're using, which means two representations of the same shape that can drift apart. Typegoose collapses those into one class. Properties are strictly typed, indexes and references are defined with decorators alongside the fields they affect, and the resulting model is directly usable with Mongoose.
For testing specifically, the benefit is that your test data has the same type guarantees as your production code. You catch shape mismatches at compile time rather than in a failing test at 2am.
Why mongodb-memory-server?
When integration tests hit a real external MongoDB instance, a few problems appear:
Tests become environment-dependent. Two developers running tests simultaneously can corrupt each other's data. CI pipelines need extra setup to provision and tear down databases.
mongodb-memory-server starts a fresh MongoDB process in memory for each test run. It's faster than a network connection, each run starts clean, and there's nothing to provision. No Docker containers, no local MongoDB installation required.
The combination of Typegoose and mongodb-memory-server means you get real database behavior (actual queries, real index enforcement, actual Mongoose lifecycle hooks) without any of the infrastructure overhead.
Introducing the Service Pattern
Controllers and route handlers tend to accumulate business logic over time. A route handler that was ten lines becomes fifty, then a hundred, and now it's impossible to test in isolation because it depends on the entire HTTP stack.
The Service Pattern moves business logic into dedicated service classes. Route handlers stay thin, calling services and formatting responses. Services contain the actual logic and can be instantiated and tested directly, without spinning up a server.
Testability is the main payoff. An isolated service class takes plain arguments and returns plain results. Tests for that class don't need supertest, a running server, or any HTTP machinery at all.
Setting the Stage: Project Structure
A typical folder layout that keeps concerns separated:
src/
models/
user.model.ts
services/
user.service.ts
controllers/
user.controller.ts
tests/
setup.ts
user.service.spec.ts
Models define data shapes. Services contain logic. Controllers handle HTTP. Tests live next to the code they test. Each folder has one job, which makes it obvious where a new piece of code belongs as the application grows.
Step-by-Step: Creating a Typegoose Model
Below is a simplified example of a Typegoose model for a "User" entity:
import { prop, getModelForClass } from '@typegoose/typegoose';
export class User {
@prop({ required: true })
public name!: string;
@prop({ required: true, unique: true })
public email!: string;
@prop()
public age?: number;
}
export const UserModel = getModelForClass(User);
@prop decorators replace the Mongoose schema definition. getModelForClass produces a Mongoose model from the class, ready to interact with MongoDB. In larger projects, you'd add indexes, virtual properties, and references to other models using additional Typegoose decorators, all in the same class definition.
Setting Up Integration Tests with mongodb-memory-server
The test setup file starts and stops the in-memory server around the test suite:
import { MongoMemoryServer } from 'mongodb-memory-server';
import mongoose from 'mongoose';
let mongoServer: MongoMemoryServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const uri = mongoServer.getUri();
await mongoose.connect(uri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeAll starts a fresh MongoDB instance and connects Mongoose to it. afterAll tears everything down. Any test file that imports this setup gets a clean database with no leftover data from previous runs. You can also add a beforeEach or afterEach hook to clear collections between individual tests for stronger isolation.
Writing a Service Class
A user service that handles creation and retrieval:
// user.service.ts
import { UserModel } from '../models/user.model';
export class UserService {
async createUser(name: string, email: string, age?: number) {
const existingUser = await UserModel.findOne({ email });
if (existingUser) {
throw new Error('User with this email already exists');
}
const user = new UserModel({ name, email, age });
return user.save();
}
async getUserByEmail(email: string) {
return UserModel.findOne({ email });
}
async getAllUsers() {
return UserModel.find();
}
}
createUser enforces the uniqueness constraint at the application level, not just at the database level, so the error message is specific and testable. The other methods are straightforward queries. All three can be called from tests directly without any HTTP plumbing.
Testing the Service Layer
// user.service.spec.ts
import { UserService } from '../services/user.service';
import { UserModel } from '../models/user.model';
import '../tests/setup'; // Imports the beforeAll and afterAll hooks
describe('UserService', () => {
let userService: UserService;
beforeEach(() => {
userService = new UserService();
});
afterEach(async () => {
// Clear data after each test
await UserModel.deleteMany({});
});
it('should create a user when valid data is provided', async () => {
const name = 'Test User';
const email = 'testuser@example.com';
const age = 25;
const createdUser = await userService.createUser(name, email, age);
expect(createdUser._id).toBeDefined();
expect(createdUser.name).toBe(name);
expect(createdUser.email).toBe(email);
expect(createdUser.age).toBe(age);
});
it('should throw an error if a user with the same email already exists', async () => {
const name = 'Test User';
const email = 'duplicate@example.com';
await userService.createUser(name, email);
await expect(userService.createUser(name, email)).rejects.toThrow(
'User with this email already exists'
);
});
it('should retrieve a user by email', async () => {
const name = 'Jane Doe';
const email = 'janedoe@example.com';
await userService.createUser(name, email);
const user = await userService.getUserByEmail(email);
expect(user).toBeDefined();
expect(user?.email).toBe(email);
});
it('should return all users', async () => {
await userService.createUser('User1', 'user1@example.com');
await userService.createUser('User2', 'user2@example.com');
const allUsers = await userService.getAllUsers();
expect(allUsers.length).toBe(2);
});
});
Each test gets a fresh UserService instance. afterEach clears the collection so tests don't interfere with each other. The assertions are specific: they test actual behavior (duplicate email throws, retrieved user has the right email) rather than just checking that something was returned.
Beyond the Basics: Scaling Your Test Suite
As the application grows, a few adjustments keep the test suite healthy. Use jest.mock() or manual mocks for third-party APIs and services that shouldn't hit the network during tests. Make sure your Typegoose models with virtuals, pre/post hooks, and cross-model references are covered by tests, not just the simple CRUD paths. For very large suites, you can run test files in parallel since each gets its own in-memory server instance. And keep coverage reporting enabled so you know which service methods are actually being exercised.
The Power of Consistency
The real benefit of this stack is uniformity. Every developer on the team runs the same test environment. There are no "works on my machine" failures caused by a local MongoDB instance having leftover data or a different version. The type definitions that Typegoose enforces in production are the same ones used in test data setup. Services are easy to test because they're isolated from the HTTP layer by design.
Investing in this structure early means that adding tests for a new service takes minutes, not hours of setup.
Conclusion
Typegoose, mongodb-memory-server, and the Service Pattern work well together because each solves a distinct problem. Typegoose keeps your data models type-safe and concise. mongodb-memory-server removes database infrastructure as a test dependency. The Service Pattern makes business logic directly testable. Each new service you add follows the same pattern, each new test runs against a clean database, and the whole suite is reproducible in any environment with no external dependencies.