Probar aplicaciones TypeScript respaldadas por MongoDB se complica rápidamente. Necesitas una base de datos real para las pruebas de integración, pero las bases de datos reales introducen dependencias de entorno, contaminación de datos entre ejecuciones y complejidad de configuración en CI. Tres herramientas resuelven este problema de forma limpia: Typegoose para modelos Mongoose tipados, mongodb-memory-server para bases de datos efímeras en pruebas, y el Service Pattern para mantener la lógica de negocio testeable. Este post muestra cómo encajan entre sí.

¿Por qué Typegoose?

Typegoose envuelve modelos Mongoose con clases TypeScript. El Mongoose estándar requiere que definas los schemas por separado de cualquier interfaz TypeScript que estés usando, lo que implica dos representaciones de la misma estructura que pueden divergir. Typegoose las colapsa en una sola clase. Las propiedades están estrictamente tipadas, los índices y referencias se definen con decorators junto a los campos que afectan, y el modelo resultante se puede usar directamente con Mongoose.

Para las pruebas en particular, el beneficio es que tus datos de prueba tienen las mismas garantías de tipos que tu código de producción. Detectas incompatibilidades de estructura en tiempo de compilación, no en una prueba fallida a las 2am.

¿Por qué mongodb-memory-server?

Cuando las pruebas de integración golpean una instancia de MongoDB externa real, aparecen varios problemas:

Las pruebas se vuelven dependientes del entorno. Dos desarrolladores ejecutando pruebas simultáneamente pueden corromper los datos del otro. Los pipelines de CI necesitan configuración adicional para provisionar y desmantelar bases de datos.

mongodb-memory-server inicia un proceso MongoDB fresco en memoria para cada ejecución de pruebas. Es más rápido que una conexión de red, cada ejecución comienza limpia y no hay nada que provisionar. Sin contenedores Docker, sin instalación local de MongoDB.

La combinación de Typegoose y mongodb-memory-server significa que obtienes comportamiento real de base de datos (consultas reales, aplicación real de índices, hooks de ciclo de vida reales de Mongoose) sin ninguna sobrecarga de infraestructura.

Introduciendo el Service Pattern

Los controllers y route handlers tienden a acumular lógica de negocio con el tiempo. Un handler de ruta que tenía diez líneas se convierte en cincuenta, luego en cien, y ahora es imposible probarlo en aislamiento porque depende de toda la stack HTTP.

El Service Pattern mueve la lógica de negocio a clases de servicio dedicadas. Los route handlers se mantienen delgados, llamando a los servicios y formateando las respuestas. Los servicios contienen la lógica real y pueden instanciarse y probarse directamente, sin necesidad de levantar un servidor.

La testeabilidad es el principal beneficio. Una clase de servicio aislada recibe argumentos simples y devuelve resultados simples. Las pruebas de esa clase no necesitan supertest, un servidor en ejecución ni ninguna maquinaria HTTP.

Preparando el Escenario: Estructura del Proyecto

Un layout de carpetas típico que mantiene las responsabilidades separadas:

src/
  models/
    user.model.ts
  services/
    user.service.ts
  controllers/
    user.controller.ts
  tests/
    setup.ts
    user.service.spec.ts

Los models definen las estructuras de datos. Los services contienen la lógica. Los controllers manejan el HTTP. Las pruebas viven junto al código que prueban. Cada carpeta tiene un único trabajo, lo que hace evidente dónde pertenece un nuevo fragmento de código a medida que crece la aplicación.

Paso a Paso: Creando un Modelo Typegoose

A continuación, un ejemplo simplificado de un modelo Typegoose para la entidad "User":

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

Los decorators @prop reemplazan la definición del schema de Mongoose. getModelForClass produce un modelo Mongoose a partir de la clase, listo para interactuar con MongoDB. En proyectos más grandes, añadirías índices, propiedades virtuales y referencias a otros modelos usando decorators adicionales de Typegoose, todo en la misma definición de clase.

Configurando Pruebas de Integración con mongodb-memory-server

El archivo de setup de pruebas inicia y detiene el servidor en memoria alrededor de la suite de pruebas:

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 inicia una instancia fresca de MongoDB y conecta Mongoose a ella. afterAll desmantela todo. Cualquier archivo de prueba que importe este setup obtiene una base de datos limpia sin datos residuales de ejecuciones anteriores. También puedes agregar un hook beforeEach o afterEach para limpiar las colecciones entre pruebas individuales para mayor aislamiento.

Escribiendo una Clase de Servicio

Un servicio de usuario que maneja la creación y recuperación:

// 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 aplica la restricción de unicidad a nivel de aplicación, no solo a nivel de base de datos, por lo que el mensaje de error es específico y testeable. Los otros métodos son consultas directas. Los tres pueden llamarse desde pruebas directamente sin ninguna plomería HTTP.

Probando la Capa de Servicio

// user.service.spec.ts
import { UserService } from '../services/user.service';
import { UserModel } from '../models/user.model';
import '../tests/setup'; // Importa los hooks beforeAll y afterAll

describe('UserService', () => {
  let userService: UserService;

  beforeEach(() => {
    userService = new UserService();
  });

  afterEach(async () => {
    // Limpiar datos después de cada prueba
    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);
  });
});

Cada prueba obtiene una instancia fresca de UserService. afterEach limpia la colección para que las pruebas no interfieran entre sí. Las aserciones son específicas: prueban el comportamiento real (el email duplicado lanza error, el usuario recuperado tiene el email correcto) en lugar de simplemente verificar que algo fue devuelto.

Más Allá de lo Básico: Escalando tu Suite de Pruebas

A medida que la aplicación crece, algunos ajustes mantienen la suite de pruebas saludable. Usa jest.mock() o mocks manuales para APIs y servicios de terceros que no deben acceder a la red durante las pruebas. Asegúrate de que tus modelos Typegoose con virtuals, hooks pre/post y referencias entre modelos estén cubiertos por pruebas, no solo los caminos simples de CRUD. Para suites muy grandes, puedes ejecutar archivos de prueba en paralelo ya que cada uno obtiene su propia instancia del servidor en memoria. Y mantén el reporte de cobertura habilitado para saber qué métodos de servicio se están ejercitando realmente.

El Poder de la Consistencia

El beneficio real de esta stack es la uniformidad. Cada desarrollador del equipo ejecuta el mismo entorno de pruebas. No hay fallos de "funciona en mi máquina" causados por una instancia local de MongoDB con datos residuales o una versión diferente. Las definiciones de tipos que Typegoose aplica en producción son las mismas que se usan en la configuración de datos de prueba. Los servicios son fáciles de probar porque están aislados de la capa HTTP por diseño.

Invertir en esta estructura temprano significa que agregar pruebas para un nuevo servicio toma minutos, no horas de configuración.

Conclusión

Typegoose, mongodb-memory-server y el Service Pattern funcionan bien juntos porque cada uno resuelve un problema distinto. Typegoose mantiene tus modelos de datos type-safe y concisos. mongodb-memory-server elimina la infraestructura de base de datos como dependencia de prueba. El Service Pattern hace que la lógica de negocio sea directamente testeable. Cada nuevo servicio que añadas sigue el mismo patrón, cada nueva prueba corre contra una base de datos limpia, y toda la suite es reproducible en cualquier entorno sin dependencias externas.