I'm Samuel FajreldinesI am a specialist in the entire JavaScript and TypeScript ecosystem (including Node.js, React, Angular and Vue.js) I am expert in AI and in creating AI integrated solutions I am expert in DevOps and Serverless Architecture (AWS, Google Cloud and Azure) I am expert in PHP and its frameworks (such as Codeigniter and Laravel). |
Samuel FajreldinesI 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.
|
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.
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:
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.
When testing features that require database interactions, developers often rely on external MongoDB instances. While this can work, it introduces complexities:
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:
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.
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.
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.
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
models
folder contains our Typegoose model definitions.services
folder encapsulates business logic in service classes.controllers
folder holds Express controllers or route handlers.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.
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:
@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.
Integration tests verify that various parts of your application work correctly together. Using mongodb-memory-server, you can:
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();
});
beforeAll
), a new MongoMemoryServer is spun up and the URI (provided by the library) is used to connect via Mongoose.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.
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();
}
}
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.
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);
});
});
UserService
.UserModel.deleteMany({})
.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.
As applications grow, so does the need for comprehensive testing. Here are a few tips to elevate your testing strategy further:
jest.mock()
or custom mocking solutions to fake external responses during tests.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:
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.
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.
About Me
Since I was a child, I've always wanted to be an inventor. As I grew up, I specialized in information systems, an area which I fell in love with and live around it. I am a full-stack developer and work a lot with devops, i.e., I'm a kind of "jack-of-all-trades" in IT. Wherever there is something cool or new, you'll find me exploring and learning... I am passionate about life, family, and sports. I believe that true happiness can only be achieved by balancing these pillars. I am always looking for new challenges and learning opportunities, and would love to connect with other technology professionals to explore possibilities for collaboration. If you are looking for a dedicated and committed full-stack developer with a passion for excellence, please feel free to contact me. It would be a pleasure to talk with you! |
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