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 TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Expanda o seu negócio com ECOSIRE
Soluções empresariais em ERP, comércio eletrônico, IA, análise e automação.
Artigos Relacionados
Drizzle ORM + Postgres Row-Level Security para Multi-Tenancy 2026
Implemente SaaS multilocatário com Drizzle ORM e Postgres Row-Level Security: esquema, políticas, variáveis de sessão, integração NestJS, padrões reais de produção.
Testes Odoo: TransactionCase, HttpCase, Tags, post_install
Guia prático de testes Odoo: TransactionCase vs HttpCase vs SavepointCase, tags de teste, tempo pós-instalação, testes de tour, simulação, integração de CI.
Integração profunda Shopify-Odoo 2026: estoque, pedidos, sincronização contábil
Arquitete um conector Shopify-Odoo de produção: estoque bidirecional, sincronização de pedidos, integração contábil, multiarmazém, devoluções, processamento idempotente.