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 março de 202610 min de leitura2.1k Palavras|

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 com vi.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 createTestingModule do 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.

E

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.

Converse no WhatsApp