Samuel Fajreldines

I am a specialist in the entire JavaScript and TypeScript ecosystem.

I am expert in AI and in creating AI integrated solutions.

I am expert in DevOps and Serverless Architecture

I am expert in PHP and its frameworks.

+55 (51) 99226-5039 samuelfajreldines@gmail.com

Elevate Your Testing Strategy with Typegoose and mongodb-memory-server

Developers building complex TypeScript applications often need a robust approach to testing data models and business logic, especially when dealing with MongoDB. Test suites that encompass integration, unit, and functional layers ensure higher confidence in code quality. However, juggling multiple tools and libraries can get complicated if not carefully structured. This post explores how to leverage three powerful elements—Typegoose, mongodb-memory-server, and the Service Pattern—to build effective tests that are both maintainable and scalable. By combining strongly typed data models, an in-memory database for testing, and a well-organized service layer, projects can evolve quickly without sacrificing stability or reliability.

Why Typegoose?

Typegoose is a library that wraps Mongoose models with TypeScript classes, making it simpler to write type-safe schemas. Traditional Mongoose usage often requires developers to define schemas in an untyped way, leading to potential mismatches between the schema definition and the actual code. Typegoose alleviates these mismatches by binding Mongoose to TypeScript decorators and classes, offering:

  1. Strong typing: Each property in the class is strictly typed, helping catch issues at compile time.
  2. Built-in decorators: Define indexes, references, and model options directly alongside properties.
  3. Cleaner code: The standard approach of maintaining separate interfaces and schema definitions is replaced by a unified class structure.

For modern TypeScript projects, consistent typing across server logic is invaluable. When setting up integration tests, these type definitions also become extremely helpful, as test suites often rely on these classes to generate or manipulate test data. Knowing that your model definitions are consistent from development to test reduces friction and speeds up debugging.

Why mongodb-memory-server?

When testing features that require database interactions, developers often rely on external MongoDB instances. While this can work, it introduces complexities:

  • Tests become environment-dependent.
  • Multiple developers may collide if using the same testing database.
  • Continuous Integration (CI) pipelines may need extra setup steps.

To solve these issues, mongodb-memory-server spins up a fresh, ephemeral MongoDB server in memory. Each test run initializes a clean database, eliminating data pollution or environment-related issues. This approach offers:

  1. Speed: In-memory operations are typically faster than network calls.
  2. Isolation: Each test run has a pristine database state, mitigating side effects across test suites.
  3. Simplicity: No external Docker containers or persistent local database instances are required.

By combining Typegoose (for strict typed classes) with mongodb-memory-server (for ephemeral databases), developers can simulate real-world database operations without the overhead of managing infrastructure. This potent combination streamlines the testing process and can significantly enhance productivity.

Introducing the Service Pattern

The Service Pattern is widely recognized in software engineering for separating business logic from the rest of the application. Most commonly, controllers or route handlers become bloated with business rules, database queries, and external API calls. By isolating all meaningful operations in a dedicated service layer, you can keep your route handlers (or controllers) slim and your logic testable.

Key benefits of the Service Pattern:

  • Reusability: Shared business operations can be invoked consistently from multiple routes or controllers.
  • Maintainability: By grouping related logic together, you reduce code duplication and avoid scattered concerns across different files.
  • Testability: Services can be tested independently, without requiring the presence of an entire HTTP server or external frameworks.

When tests focus on an isolated service class rather than a tangled web of route handlers, it becomes easier to write focused assertions. Additionally, the improved organization helps to expand test coverage over time without introducing complexity.

Setting the Stage: Project Structure

Before setting up tests, let’s imagine a project structure designed with clarity in mind. A typical folder organization might look like this:

src/
  models/
    user.model.ts
  services/
    user.service.ts
  controllers/
    user.controller.ts
  tests/
    setup.ts
    user.service.spec.ts
  1. The models folder contains our Typegoose model definitions.
  2. The services folder encapsulates business logic in service classes.
  3. The controllers folder holds Express controllers or route handlers.
  4. The tests folder contains test configurations and test files.

By segregating models, services, and controllers, each layer’s responsibilities remain clear. This architecture fosters code hygiene and reduces confusion 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);

Here’s what happens:

  • We use @prop decorators to define each property’s requirements.
  • UserModel is created by passing the User class to getModelForClass, making it ready for interaction with the MongoDB database.

In actual projects, you can add indexes, references to other models, or advanced schema options using Typegoose decorators. This pattern simplifies the entire Mongoose schema definition process while still offering a robust type system.

Setting Up Integration Tests with mongodb-memory-server

Integration tests verify that various parts of your application work correctly together. Using mongodb-memory-server, you can:

  1. Start an in-memory database on your local machine or in a CI environment.
  2. Connect your application logic (Typegoose models and services) to this ephemeral database.
  3. Run queries and perform tests just as you would with a real database.
  4. Shut down the in-memory server, ending the test with zero dependencies or leftover data.

Below is a sketch of how to configure mongodb-memory-server in a test setup file (e.g., tests/setup.ts):

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();
});

Explanation:

  • Before all tests (beforeAll), a new MongoMemoryServer is spun up and the URI (provided by the library) is used to connect via Mongoose.
  • After all tests (afterAll), we disconnect from Mongoose and stop the server.

This ensures each test file that uses setup.ts begins with a fresh, in-memory database. Optionally, you can also add hooks to clean up data after each test, guaranteeing isolation between individual test cases.

Writing a Service Class

Next, consider a simple user service that encapsulates business logic around creating and retrieving users:

// 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();
  }
}

How it works:

  • createUser checks if a user with the provided email already exists. If yes, it throws an error. If not, it creates a new user document and saves it.
  • getUserByEmail retrieves a single user by email.
  • getAllUsers returns all user documents in the database.

By isolating these operations in a single class, any code that needs user-related functionality can rely on this service layer. This approach mirrors the separation of concerns, ensuring that the same business logic can be tested thoroughly and consistently, even if you later decide to change your web framework or user interface.

Testing the Service Layer

With our service and in-memory database ready, we can now write integration tests focusing on the service logic:

// 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);
  });
});

Key points in this suite:

  1. Each test creates a fresh instance of UserService.
  2. Data is cleared out after each test using UserModel.deleteMany({}).
  3. Tests focus on the behavior of the service methods: creation, error handling, and retrieval.

This style ensures that each test scenario is isolated and does not pollute subsequent tests. The ephemeral nature of mongodb-memory-server means the test environment is guaranteed to be consistent, no matter how many times the suite is run or in which environment it executes.

Beyond the Basics: Scaling Your Test Suite

As applications grow, so does the need for comprehensive testing. Here are a few tips to elevate your testing strategy further:

  1. Custom Mocks for External Services: If your service interacts with third-party APIs, use libraries like jest.mock() or custom mocking solutions to fake external responses during tests.
  2. Advanced Model Features: Typegoose supports sophisticated schema definitions, such as virtuals, hooks, or references to other models. Ensure your tests cover these advanced features to validate real-world usage.
  3. Performance Optimization: Mongodb-memory-server is efficient, but for very large test suites, you can consider running your test environment in parallel or optimizing data seeding.
  4. Coverage Reporting: Incorporate coverage tools like Istanbul/nyc to ensure critical parts of your code (especially in the service layer) are thoroughly tested.

The Power of Consistency and Maintainability

One of the greatest advantages of using Typegoose, mongodb-memory-server, and the Service Pattern together is consistency across both development and testing. Here’s why:

  • By committing to Typegoose, code remains type-safe, making it harder to introduce subtle bugs.
  • Ephemeral databases remove the annoyance of environment setup, promoting a uniform testing approach across teams.
  • Services encapsulate business logic, making tests more straightforward and reducing dependency on route handlers or other controllers.

This combination allows developers to focus on writing relevant test scenarios rather than debugging environment issues or grappling with unstructured code. When you invest in a robust test environment early on, you future-proof your application against maintenance nightmares, effectively reducing the stress of scaling both the codebase and the team.

Conclusion

Effective, maintainable tests are indispensable in any modern enterprise-grade or mission-critical software system. By fusing Typegoose, mongodb-memory-server, and the Service Pattern, you create a clean, modular approach that scales along with your project. Typegoose enforces type safety and simplifies model definitions, mongodb-memory-server provides a quick and isolated environment for integration tests, and the Service Pattern organizes logic in a way that simplifies both testing and future refactoring.

This approach not only bolsters reliability but also boosts developer productivity. Each commit can be validated against controlled, repeatable tests that highlight logic mistakes before they ever reach production. Integrating these concepts into your workflow ensures that as your TypeScript application grows in scope and complexity, the testing infrastructure remains a solid foundation that helps catch issues early, fosters continuous delivery, and preserves engineering velocity.

Staying consistent with best practices around testing will also provide long-term benefits. Automated, repeatable, and thorough tests are invaluable for code maintainers trying to keep pace with evolving business requirements. When test suites are easy to run and easy to expand, quality control becomes not just a box to check but a strength that differentiates a well-engineered application from the rest. By taking advantage of Typegoose, mongodb-memory-server, and the Service Pattern today, you set your project on the path to sustainable success.


Resume

Experience

  • SecurityScoreCard

    Nov. 2023 - Present

    New York, United States

    Senior Software Engineer

    I joined SecurityScorecard, a leading organization with over 400 employees, as a Senior Full Stack Software Engineer. My role spans across developing new systems, maintaining and refactoring legacy solutions, and ensuring they meet the company's high standards of performance, scalability, and reliability.

    I work across the entire stack, contributing to both frontend and backend development while also collaborating directly on infrastructure-related tasks, leveraging cloud computing technologies to optimize and scale our systems. This broad scope of responsibilities allows me to ensure seamless integration between user-facing applications and underlying systems architecture.

    Additionally, I collaborate closely with diverse teams across the organization, aligning technical implementation with strategic business objectives. Through my work, I aim to deliver innovative and robust solutions that enhance SecurityScorecard's offerings and support its mission to provide world-class cybersecurity insights.

    Technologies Used:

    Node.js Terraform React Typescript AWS Playwright and Cypress