Prueba de NestJS con Vitest: patrones de unidad y de integración
Una aplicación NestJS sin pruebas es una responsabilidad. No se puede refactorizar con confianza, no se pueden agregar características sin romper el comportamiento existente y no se pueden detectar regresiones antes de que lleguen a producción. Pero escribir pruebas malas es casi peor que no realizar ninguna prueba: pruebas lentas que nadie ejecuta, pruebas frágiles junto con detalles de implementación y pruebas simuladas que no reflejan el comportamiento real, todas ellas erosionan la confianza de los desarrolladores.
Esta guía explica cómo escribir pruebas rápidas y confiables para NestJS usando Vitest, el reemplazo moderno de Jest que se ejecuta en menos de 30 segundos para más de 1300 pruebas. Cubrimos todo el espectro: pruebas unitarias puras con dependencias simuladas, pruebas de integración con una transacción de base de datos real, pruebas de seguridad y la configuración de CI que hace que todo se ejecute en cada confirmación.
Conclusiones clave
- Vitest es compatible con Jest pero entre 2 y 5 veces más rápido: la misma API, sin fricciones de migración
- Utilice
vi.hoisted()para fábricas simuladas para evitar problemas de zona muerta temporal convi.mock()- Comportamiento de prueba, no implementación: afirmación sobre resultados y efectos secundarios, no llamadas a métodos internos
- Las cadenas simuladas de Drizzle SELECT deben terminar con
.limit(), INSERTAR/ACTUALIZAR con.returning()- Las pruebas de integración deben utilizar transacciones de bases de datos reales revertidas después de cada prueba.
- Utilice
createTestingModulede NestJS para realizar pruebas DI adecuadas, no para crear instancias de clases directas- Las pruebas de seguridad (inyección, omisión de autenticación, límite de velocidad) pertenecen a un directorio
__security__separado- Objetivo de cobertura de línea superior al 80 % para la capa de servicio; 100% para módulos de autenticación y facturación
Configuración de 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
Patrón de prueba unitaria
Servicio bajo prueba
// 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;
}
}
Prueba unitaria
// 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 entoncesable para simulacros de cadena de llovizna
Las consultas sobre llovizna son cadenas fluidas. Para escenarios de prueba de integración en los que necesitas simular la cadena 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' }]));
Pruebas de integración con Real Database
// 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');
});
});
Pruebas del 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);
});
});
Pruebas de seguridad
// 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);
});
});
Configuración de canalización 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
Preguntas frecuentes
¿Por qué Vitest en lugar de Jest para NestJS?
Vitest utiliza el paquete rápido de Vite (esbuild/Rollup) en lugar de Babel, lo que proporciona una ejecución de pruebas entre 2 y 5 veces más rápida. Tiene una API compatible con Jest: la mayoría de las pruebas de Jest migran sin cambios. Vitest tiene soporte nativo para ESM (importante para paquetes modernos), TypeScript integrado sin ts-jest y un mejor modo de visualización. Para NestJS, el principal beneficio son los ciclos de retroalimentación dramáticamente más rápidos en el modo de vigilancia.
¿Por qué utilizar vi.hoisted() para fábricas simuladas?
El transformador Vitest eleva las llamadas vi.mock() a la parte superior del archivo. Si su fábrica simulada hace referencia a una variable definida más adelante en el archivo (como const mockDb = { ... }), se ejecuta antes de que se inicialice la variable: un error de zona muerta temporal. vi.hoisted() crea valores que también se elevan, para que estén disponibles cuando se ejecuta la fábrica simulada.
¿Cómo pruebo los guardias e interceptores de NestJS?
Cree una prueba de integración usando createTestingModule con overrideGuard() para omitir o probar la protección de forma aislada. Para probar que un guardia realmente rechaza solicitudes no autorizadas, use Supertest contra la aplicación completa (app.getHttpServer()) con tokens no válidos o faltantes, y afirme el código de estado 401/403 correcto.
¿Las cadenas simuladas de Drizzle deberían terminar con .limit() o .returning()?
Para consultas SELECT, la cadena normalmente termina con una llamada findMany(), findFirst() o .limit(). Para INSERTAR/ACTUALIZAR/ELIMINAR con values().returning(), la cadena termina con .returning(). Simule el método más profundo de la cadena, cualquiera que sea el último código de servicio awaits. El patrón de proxy entonces manejable maneja cadenas de profundidad arbitraria.
¿Cómo pruebo las tareas programadas de NestJS (@Cron decorators)?
Inyecte el programador en su módulo de prueba y llame al método de tarea directamente; no espere el temporizador cron. Utilice jest.useFakeTimers() (la misma API en Vitest con vi.useFakeTimers()) si necesita probar el comportamiento de sincronización. Para probar que se programó una tarea, verifique que @nestjs/schedule SchedulerRegistry contenga el trabajo esperado.
¿Cuál es la estructura de directorios de prueba recomendada para NestJS?
Coubique las pruebas unitarias con los archivos fuente (contacts.service.spec.ts junto a contacts.service.ts). Poner pruebas de integración en src/modules/__integration__/ y pruebas de seguridad en src/modules/__security__/. Esta separación le permite ejecutar solo pruebas unitarias durante el desarrollo (vitest run --exclude **/__integration__/**) y todas las pruebas en CI.
Próximos pasos
Un conjunto de pruebas completo no es un lujo: es la infraestructura que le permite realizar envíos más rápido y con confianza. Con Vitest ejecutando 1.300 pruebas en menos de 30 segundos, no hay excusa para saltarse la prueba antes de seguir adelante.
ECOSIRE crea backends de NestJS con cobertura de prueba completa desde el primer día: 76 archivos de prueba, 1301 pruebas unitarias y de integración, y cobertura de pruebas de seguridad para inyección, omisión de autenticación y limitación de velocidad. Explore nuestros servicios de ingeniería backend para saber cómo ofrecemos API probadas y listas para producción.
Escrito por
ECOSIRE Research and Development Team
Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.
Artículos 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.