Testar aplicações TypeScript com MongoDB pode virar um pesadelo rapidamente. Você precisa de um banco de dados real para testes de integração, mas bancos reais trazem dependências de ambiente, poluição de dados entre execuções e complexidade de configuração no CI. Três ferramentas resolvem esse problema de forma limpa: Typegoose para modelos Mongoose tipados, mongodb-memory-server para bancos de dados efêmeros nos testes, e o Service Pattern para manter a lógica de negócio testável. Este post mostra como elas se encaixam.
Por que Typegoose?
Typegoose encapsula modelos Mongoose com classes TypeScript. O Mongoose padrão exige que você defina schemas separadamente de qualquer interface TypeScript que esteja usando, o que significa duas representações da mesma estrutura que podem divergir. Typegoose colapsa isso em uma única classe. As propriedades são estritamente tipadas, índices e referências são definidos com decorators ao lado dos campos que afetam, e o modelo resultante pode ser usado diretamente com o Mongoose.
Para testes especificamente, o benefício é que seus dados de teste têm as mesmas garantias de tipo que o seu código de produção. Você detecta incompatibilidades de estrutura em tempo de compilação, e não em um teste falhando às 2h da manhã.
Por que mongodb-memory-server?
Quando testes de integração acessam uma instância real de MongoDB externa, alguns problemas aparecem:
Os testes ficam dependentes do ambiente. Dois desenvolvedores rodando testes simultaneamente podem corromper os dados um do outro. Pipelines de CI precisam de configuração extra para provisionar e desligar bancos de dados.
O mongodb-memory-server inicia um processo MongoDB fresco em memória para cada execução de testes. É mais rápido do que uma conexão de rede, cada execução começa limpa e não há nada a provisionar. Sem containers Docker, sem instalação local do MongoDB.
A combinação de Typegoose e mongodb-memory-server significa que você obtém comportamento real de banco de dados (queries reais, aplicação real de índices, hooks de ciclo de vida reais do Mongoose) sem nenhuma sobrecarga de infraestrutura.
Introduzindo o Service Pattern
Controllers e route handlers tendem a acumular lógica de negócio ao longo do tempo. Um handler de rota com dez linhas vira cinquenta, depois cem, e agora é impossível testar em isolamento porque depende de toda a stack HTTP.
O Service Pattern move a lógica de negócio para classes de serviço dedicadas. Route handlers ficam finos, chamando serviços e formatando respostas. Os serviços contêm a lógica real e podem ser instanciados e testados diretamente, sem precisar subir um servidor.
A testabilidade é o principal benefício. Uma classe de serviço isolada recebe argumentos simples e retorna resultados simples. Testes dessa classe não precisam de supertest, um servidor rodando, ou qualquer maquinaria HTTP.
Preparando o Terreno: Estrutura do Projeto
Um layout de pastas típico que mantém as responsabilidades separadas:
src/
models/
user.model.ts
services/
user.service.ts
controllers/
user.controller.ts
tests/
setup.ts
user.service.spec.ts
Models definem formatos de dados. Services contêm a lógica. Controllers tratam o HTTP. Testes ficam ao lado do código que testam. Cada pasta tem uma responsabilidade, o que torna óbvio onde um novo trecho de código pertence conforme a aplicação cresce.
Passo a Passo: Criando um Modelo Typegoose
Abaixo está um exemplo simplificado de um modelo Typegoose para a entidade "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);
Os decorators @prop substituem a definição de schema do Mongoose. getModelForClass produz um modelo Mongoose a partir da classe, pronto para interagir com o MongoDB. Em projetos maiores, você adicionaria índices, propriedades virtuais e referências a outros modelos usando decorators adicionais do Typegoose, todos na mesma definição de classe.
Configurando Testes de Integração com mongodb-memory-server
O arquivo de setup dos testes inicia e para o servidor em memória ao redor da suite de testes:
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 uma instância fresca de MongoDB e conecta o Mongoose a ela. afterAll encerra tudo. Qualquer arquivo de teste que importar esse setup recebe um banco limpo sem dados remanescentes de execuções anteriores. Você também pode adicionar um hook beforeEach ou afterEach para limpar coleções entre testes individuais para maior isolamento.
Escrevendo uma Classe de Serviço
Um serviço de usuário que trata criação e recuperação:
// 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 a restrição de unicidade na camada de aplicação, não apenas no banco de dados, portanto a mensagem de erro é específica e testável. Os outros métodos são queries diretas. Os três podem ser chamados de testes diretamente sem nenhuma plumagem HTTP.
Testando a Camada de Serviço
// user.service.spec.ts
import { UserService } from '../services/user.service';
import { UserModel } from '../models/user.model';
import '../tests/setup'; // Importa os hooks beforeAll e afterAll
describe('UserService', () => {
let userService: UserService;
beforeEach(() => {
userService = new UserService();
});
afterEach(async () => {
// Limpa os dados após cada teste
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 teste recebe uma instância fresca de UserService. afterEach limpa a coleção para que os testes não interfiram uns com os outros. As asserções são específicas: testam o comportamento real (email duplicado lança erro, o usuário recuperado tem o e-mail correto) em vez de apenas verificar que algo foi retornado.
Além do Básico: Escalando sua Suite de Testes
Conforme a aplicação cresce, alguns ajustes mantêm a suite de testes saudável. Use jest.mock() ou mocks manuais para APIs e serviços de terceiros que não devem acessar a rede durante os testes. Certifique-se de que seus modelos Typegoose com virtuals, hooks pre/post e referências entre modelos estejam cobertos por testes, não apenas os caminhos simples de CRUD. Para suites muito grandes, você pode rodar arquivos de teste em paralelo, já que cada um recebe sua própria instância do servidor em memória. E mantenha o relatório de cobertura ativado para saber quais métodos de serviço estão sendo realmente exercitados.
O Poder da Consistência
O benefício real dessa stack é a uniformidade. Todo desenvolvedor da equipe roda o mesmo ambiente de testes. Não há falhas do tipo "funciona na minha máquina" causadas por uma instância local do MongoDB com dados remanescentes ou uma versão diferente. As definições de tipo que o Typegoose aplica em produção são as mesmas usadas na configuração dos dados de teste. Os serviços são fáceis de testar porque estão isolados da camada HTTP por design.
Investir nessa estrutura cedo significa que adicionar testes para um novo serviço leva minutos, não horas de configuração.
Conclusão
Typegoose, mongodb-memory-server e o Service Pattern funcionam bem juntos porque cada um resolve um problema distinto. Typegoose mantém seus modelos de dados type-safe e concisos. mongodb-memory-server remove a infraestrutura de banco de dados como dependência de teste. O Service Pattern torna a lógica de negócio diretamente testável. Cada novo serviço adicionado segue o mesmo padrão, cada novo teste roda contra um banco limpo, e toda a suite é reproduzível em qualquer ambiente sem dependências externas.