Testando NestJS com Vitest: Padrões de Unidade e Integração
Um aplicativo NestJS sem testes é um risco. Você não pode refatorar com confiança, não pode adicionar recursos sem quebrar o comportamento existente e não pode capturar regressões antes que elas cheguem à produção. Mas escrever testes ruins é quase pior do que nenhum teste: testes lentos que ninguém executa, testes frágeis acoplados a detalhes de implementação e testes simulados que não refletem o comportamento real, todos minam a confiança do desenvolvedor.
Este guia aborda como escrever testes rápidos e confiáveis para NestJS usando Vitest — o substituto moderno do Jest que é executado em menos de 30 segundos para mais de 1.300 testes. Cobrimos todo o espectro: testes de unidade puros com dependências simuladas, testes de integração com uma transação real de banco de dados, testes de segurança e a configuração de CI que faz tudo rodar em cada commit.
Principais conclusões
- Vitest é compatível com Jest, mas 2 a 5x mais rápido — mesma API, sem problemas de migração
- Use
vi.hoisted()para fábricas simuladas para evitar problemas de zona morta temporal comvi.mock()- Teste o comportamento, não a implementação - afirme resultados e efeitos colaterais, não chamadas de métodos internos
- As cadeias simuladas Drizzle SELECT devem terminar com
.limit(), INSERT/UPDATE com.returning()- Os testes de integração devem usar transações reais de banco de dados revertidas após cada teste
- Use
createTestingModuledo NestJS para testes de DI adequados, não para instanciação direta de classe- Os testes de segurança (injeção, desvio de autenticação, limite de taxa) pertencem a um diretório
__security__separado- Meta de cobertura de linha de 80%+ para camada de serviço; 100% para módulos de autenticação e cobrança
Configuração do Vitest
// apps/api/vitest.config.ts
import { defineConfig } from 'vitest/config';
import swc from 'unplugin-swc';
export default defineConfig({
test: {
globals: true,
root: './',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/**',
'dist/**',
'**/*.spec.ts',
'**/*.dto.ts', // DTOs are just type definitions
'**/index.ts', // Barrel exports
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
},
},
reporters: ['verbose'],
maxConcurrency: 10,
pool: 'forks', // Better isolation for database tests
poolOptions: {
forks: { singleFork: false },
},
},
plugins: [
swc.vite({
module: { type: 'es6' },
}),
],
});
# Install
pnpm add -D vitest @vitest/coverage-v8 unplugin-swc @swc/core
# Run
cd apps/api && npx vitest run # All tests once
cd apps/api && npx vitest # Watch mode
cd apps/api && npx vitest run --coverage # With coverage report
Padrão de teste unitário
Serviço em teste
// src/modules/contacts/contacts.service.ts
@Injectable()
export class ContactsService {
constructor(
@InjectDatabase() private db: Database,
private readonly cacheService: CacheService,
) {}
async findAll(organizationId: string, page = 1, limit = 20) {
const cached = await this.cacheService.get(`contacts:${organizationId}:${page}`);
if (cached) return cached;
const result = await this.db.query.contacts.findMany({
where: eq(contacts.organizationId, organizationId),
limit,
offset: (page - 1) * limit,
orderBy: desc(contacts.createdAt),
});
await this.cacheService.set(`contacts:${organizationId}:${page}`, result, 300);
return result;
}
async create(organizationId: string, dto: CreateContactDto) {
const [contact] = await this.db
.insert(contacts)
.values({ ...dto, organizationId })
.returning();
return contact;
}
}
Teste de unidade
// src/modules/contacts/contacts.service.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { ContactsService } from './contacts.service';
import { CacheService } from '../cache/cache.service';
// vi.hoisted() ensures mock factories run before vi.mock() calls
const mockDb = vi.hoisted(() => ({
query: {
contacts: {
findMany: vi.fn(),
},
},
insert: vi.fn(),
}));
const mockCache = vi.hoisted(() => ({
get: vi.fn(),
set: vi.fn(),
}));
vi.mock('@ecosire/db', () => ({ DATABASE_TOKEN: 'DATABASE_TOKEN' }));
describe('ContactsService', () => {
let service: ContactsService;
beforeEach(async () => {
vi.clearAllMocks();
const module = await Test.createTestingModule({
providers: [
ContactsService,
{
provide: 'DATABASE_TOKEN',
useValue: mockDb,
},
{
provide: CacheService,
useValue: mockCache,
},
],
}).compile();
service = module.get<ContactsService>(ContactsService);
});
describe('findAll', () => {
it('returns cached result when cache hit', async () => {
const cached = [{ id: '1', name: 'Alice' }];
mockCache.get.mockResolvedValue(cached);
const result = await service.findAll('org_1');
expect(result).toEqual(cached);
expect(mockDb.query.contacts.findMany).not.toHaveBeenCalled();
});
it('queries database on cache miss and caches result', async () => {
const dbResult = [{ id: '1', name: 'Bob', organizationId: 'org_1' }];
mockCache.get.mockResolvedValue(null);
// Drizzle SELECT chains end with .limit()
mockDb.query.contacts.findMany.mockResolvedValue(dbResult);
const result = await service.findAll('org_1');
expect(result).toEqual(dbResult);
expect(mockCache.set).toHaveBeenCalledWith(
'contacts:org_1:1',
dbResult,
300
);
});
});
describe('create', () => {
it('inserts contact and returns created record', async () => {
const newContact = { id: '2', name: 'Carol', organizationId: 'org_1' };
// Drizzle INSERT chains end with .returning()
const returning = vi.fn().mockResolvedValue([newContact]);
const values = vi.fn().mockReturnValue({ returning });
mockDb.insert.mockReturnValue({ values });
const result = await service.create('org_1', { name: 'Carol' });
expect(result).toEqual(newContact);
expect(mockDb.insert).toHaveBeenCalled();
});
});
});
Proxy entãoable para simulações de cadeia de garoa
As consultas Drizzle são cadeias fluentes. Para cenários de teste de integração onde você precisa simular a cadeia completa:
// test/helpers/drizzle-mock.ts
export function createDrizzleChainMock(resolveValue: unknown) {
const handler: ProxyHandler<object> = {
get(target, prop) {
if (prop === 'then') {
// Make the chain thenable so await works
return (resolve: (v: unknown) => void) => resolve(resolveValue);
}
// Return a proxy that returns itself for any method call
return (..._args: unknown[]) => new Proxy({}, handler);
},
};
return new Proxy({}, handler);
}
// Usage in tests
mockDb.select.mockReturnValue(createDrizzleChainMock([{ id: '1' }]));
Testes de integração com banco de dados real
// src/modules/__integration__/contacts.integration.spec.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import * as schema from '@ecosire/db/schema';
import { ContactsModule } from '../contacts/contacts.module';
import { ContactsService } from '../contacts/contacts.service';
let sql: ReturnType<typeof postgres>;
let db: ReturnType<typeof drizzle>;
let service: ContactsService;
beforeAll(async () => {
// Use a separate test database
const testDbUrl = process.env.TEST_DATABASE_URL ||
'postgres://postgres:postgres@localhost:5433/ecosire_test';
sql = postgres(testDbUrl, { max: 1 });
db = drizzle(sql, { schema });
// Run migrations on test DB
await migrate(db, { migrationsFolder: './drizzle' });
const module = await Test.createTestingModule({
imports: [ContactsModule],
})
.overrideProvider('DATABASE_TOKEN')
.useValue(db)
.compile();
service = module.get<ContactsService>(ContactsService);
});
afterAll(async () => {
await sql.end();
});
beforeEach(async () => {
// Clean up test data between tests
await db.delete(schema.contacts);
});
describe('ContactsService (integration)', () => {
it('creates and retrieves a contact from the real database', async () => {
const created = await service.create('org_test_1', {
name: 'Integration Test User',
email: '[email protected]',
});
expect(created.id).toBeDefined();
expect(created.name).toBe('Integration Test User');
const all = await service.findAll('org_test_1');
expect(all).toHaveLength(1);
expect(all[0].id).toBe(created.id);
});
it('scopes results by organizationId (multi-tenancy)', async () => {
await service.create('org_1', { name: 'Org 1 Contact' });
await service.create('org_2', { name: 'Org 2 Contact' });
const org1Contacts = await service.findAll('org_1');
const org2Contacts = await service.findAll('org_2');
expect(org1Contacts).toHaveLength(1);
expect(org2Contacts).toHaveLength(1);
expect(org1Contacts[0].name).toBe('Org 1 Contact');
});
});
Testes de controlador
// src/modules/contacts/contacts.controller.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { ContactsController } from './contacts.controller';
import { ContactsService } from './contacts.service';
describe('ContactsController', () => {
let controller: ContactsController;
const mockService = {
findAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
};
beforeEach(async () => {
vi.clearAllMocks();
const module = await Test.createTestingModule({
controllers: [ContactsController],
providers: [
{ provide: ContactsService, useValue: mockService },
],
}).compile();
controller = module.get<ContactsController>(ContactsController);
});
it('calls service.findAll with organizationId from authenticated user', async () => {
const mockContacts = [{ id: '1', name: 'Test' }];
mockService.findAll.mockResolvedValue(mockContacts);
const mockUser = { id: 'user_1', organizationId: 'org_1', role: 'admin' };
const result = await controller.findAll(
{ user: mockUser } as AuthenticatedRequest,
1,
20
);
expect(result).toEqual(mockContacts);
expect(mockService.findAll).toHaveBeenCalledWith('org_1', 1, 20);
});
});
Testes de segurança
// src/modules/__security__/injection.security.spec.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
describe('SQL Injection Prevention', () => {
const injectionPayloads = [
"'; DROP TABLE contacts; --",
"' OR '1'='1",
"1; SELECT * FROM users",
"' UNION SELECT * FROM refresh_tokens --",
];
it.each(injectionPayloads)(
'should safely handle SQL injection attempt: %s',
async (payload) => {
const response = await request(app.getHttpServer())
.get('/contacts')
.query({ search: payload })
.set('Authorization', `Bearer ${validToken}`);
// Should return 200 with empty results or 400 for invalid input
// Should NEVER return 500 (which would indicate unhandled SQL error)
expect(response.status).not.toBe(500);
expect(response.body).not.toMatchObject({
message: expect.stringContaining('syntax error'),
});
}
);
});
describe('Authentication Bypass Prevention', () => {
it('should reject requests without authentication', async () => {
const response = await request(app.getHttpServer())
.get('/admin/contacts');
expect(response.status).toBe(401);
});
it('should reject tampered JWT', async () => {
const tamperedToken = validToken.slice(0, -5) + 'xxxxx';
const response = await request(app.getHttpServer())
.get('/contacts')
.set('Authorization', `Bearer ${tamperedToken}`);
expect(response.status).toBe(401);
});
it('should reject expired JWT', async () => {
// Token with exp in the past
const expiredToken = generateTokenWithExpiry(-3600);
const response = await request(app.getHttpServer())
.get('/contacts')
.set('Authorization', `Bearer ${expiredToken}`);
expect(response.status).toBe(401);
});
});
Configuração do pipeline de CI
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build --filter=@ecosire/db --filter=@ecosire/types
- name: Run unit tests
run: cd apps/api && npx vitest run --coverage
env:
NODE_ENV: test
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: apps/api/coverage/lcov.info
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: ecosire_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: cd apps/api && npx vitest run src/modules/__integration__
env:
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/ecosire_test
Perguntas frequentes
Por que Vitest em vez de Jest para NestJS?
Vitest usa o empacotador rápido do Vite (esbuild/Rollup) em vez do Babel, proporcionando uma execução de teste 2 a 5x mais rápida. Possui uma API compatível com Jest – a maioria dos testes Jest migram sem alterações. Vitest tem suporte nativo a ESM (importante para pacotes modernos), TypeScript integrado sem ts-jest e melhor modo de observação. Para NestJS, o principal benefício são ciclos de feedback dramaticamente mais rápidos no modo de observação.
Por que usar vi.hoisted() para fábricas simuladas?
As chamadas vi.mock() são içadas para o topo do arquivo pelo transformador Vitest. Se sua fábrica simulada fizer referência a uma variável definida posteriormente no arquivo (como const mockDb = { ... }), ela será executada antes da variável ser inicializada - um erro de zona morta temporal. vi.hoisted() cria valores que também são içados, para que estejam disponíveis quando a fábrica simulada for executada.
Como faço para testar os protetores e interceptadores NestJS?
Crie um teste de integração usando createTestingModule com overrideGuard() para ignorar ou testar a proteção isoladamente. Para testar se um guarda realmente rejeita solicitações não autorizadas, use o Superteste no aplicativo completo (app.getHttpServer()) com tokens inválidos ou ausentes e afirme o código de status 401/403 correto.
As cadeias simuladas do Drizzle devem terminar com .limit() ou .returning()?
Para consultas SELECT, a cadeia normalmente termina com uma chamada findMany(), findFirst() ou .limit(). Para INSERT/UPDATE/DELETE com values().returning(), a cadeia termina com .returning(). Zombe do método mais profundo da cadeia - qualquer que seja o último código de serviço awaits. O padrão proxy thenable lida com cadeias de profundidade arbitrária.
Como faço para testar tarefas agendadas do NestJS (@Cron decorators)?
Injete o agendador em seu módulo de teste e chame o método de tarefa diretamente – não espere pelo cron timer. Use jest.useFakeTimers() (mesma API no Vitest com vi.useFakeTimers()) se precisar testar o comportamento do tempo. Para testar se uma tarefa foi agendada, verifique se @nestjs/schedule SchedulerRegistry contém o trabalho esperado.
Qual é a estrutura de diretório de teste recomendada para NestJS?
Coloque testes de unidade com arquivos de origem (contacts.service.spec.ts próximo a contacts.service.ts). Coloque testes de integração em src/modules/__integration__/ e testes de segurança em src/modules/__security__/. Essa separação permite executar apenas testes unitários durante o desenvolvimento (vitest run --exclude **/__integration__/**) e todos os testes no CI.
Próximas etapas
Um conjunto de testes abrangente não é um luxo — é a infraestrutura que permite entregar com mais rapidez e confiança. Com o Vitest executando 1.300 testes em menos de 30 segundos, não há desculpa para pular o teste antes de prosseguir.
ECOSIRE cria back-ends NestJS com cobertura completa de testes desde o primeiro dia – 76 arquivos de teste, 1.301 testes unitários e de integração e cobertura de testes de segurança para injeção, desvio de autenticação e limitação de taxa. Explore nossos serviços de engenharia de back-end para saber como fornecemos APIs testadas e prontas para produção.
Escrito por
ECOSIRE Research and Development Team
Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.
Artigos Relacionados
AI + ERP Integration: How AI is Transforming Enterprise Resource Planning
Learn how AI is transforming ERP systems in 2026—from intelligent automation and predictive analytics to natural language interfaces and autonomous operations.
All-in-One vs Best-of-Breed: The Software Stack Decision
All-in-one vs best-of-breed software strategy for 2026: integration complexity, total cost, vendor risk, and when each approach is right for your business.
The API Economy: Building an Integration-First Business
How to leverage the API economy for competitive advantage—building integration-first architecture, monetizing APIs, selecting iPaaS platforms, and creating ecosystem value.