Testing NestJS with Vitest: Unit and Integration Patterns

Complete guide to testing NestJS applications with Vitest. Covers unit tests, integration tests, mock patterns, database testing with Drizzle, and CI pipeline configuration.

E
ECOSIRE Research and Development Team
|19 de marzo de 202610 min de lectura2.1k Palabras|

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 con vi.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 createTestingModule de 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.

E

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.

Chatea en whatsapp