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. März 20269 Min. Lesezeit2.0k Wörter|

NestJS mit Vitest testen: Einheiten- und Integrationsmuster

Eine NestJS-Anwendung ohne Tests ist eine Belastung. Sie können kein sicheres Refactoring durchführen, Sie können keine Funktionen hinzufügen, ohne das bestehende Verhalten zu unterbrechen, und Sie können Regressionen nicht abfangen, bevor sie in die Produktion gelangen. Aber schlechte Tests zu schreiben ist fast schlimmer als keine Tests – langsame Tests, die niemand ausführt, brüchige Tests, die an Implementierungsdetails gekoppelt sind, und nachgeahmte Tests, die nicht das wirkliche Verhalten widerspiegeln, untergraben das Vertrauen der Entwickler.

In diesem Leitfaden erfahren Sie, wie Sie mit Vitest schnelle und zuverlässige Tests für NestJS schreiben – dem modernen Jest-Ersatz, der für mehr als 1.300 Tests in weniger als 30 Sekunden ausgeführt wird. Wir decken das gesamte Spektrum ab: reine Unit-Tests mit simulierten Abhängigkeiten, Integrationstests mit einer echten Datenbanktransaktion, Sicherheitstests und die CI-Konfiguration, die dafür sorgt, dass alles bei jedem Commit ausgeführt wird.

Wichtige Erkenntnisse

  • Vitest ist Jest-kompatibel, aber 2-5x schneller – gleiche API, keine Reibungsverluste bei der Migration – Verwenden Sie vi.hoisted() für Scheinfabriken, um zeitliche Totzonenprobleme mit vi.mock() zu vermeiden. – Testverhalten, nicht Implementierung – Assertion auf Ausgaben und Nebenwirkungen, nicht auf internen Methodenaufrufen – Drizzle SELECT Scheinketten müssen mit .limit() enden, INSERT/UPDATE mit .returning()
  • Integrationstests sollten echte Datenbanktransaktionen verwenden, die nach jedem Test zurückgesetzt werden – Verwenden Sie createTestingModule von NestJS für ordnungsgemäße DI-Tests, nicht für die direkte Klasseninstanziierung – Sicherheitstests (Injektion, Authentifizierungsumgehung, Ratenbegrenzung) gehören in ein separates __security__-Verzeichnis
  • Angestrebte Leitungsabdeckung von über 80 % für die Serviceschicht; 100 % für Authentifizierungs- und Abrechnungsmodule

Vitest-Konfiguration

// 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

Unit-Testmuster

Dienst im Test

// 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;
  }
}

Unit-Test

// 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();
    });
  });
});

Dannable Proxy für Drizzle Chain Mocks

Drizzle-Abfragen sind fließende Ketten. Für Integrationstestszenarien, in denen Sie die gesamte Kette simulieren müssen:

// 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' }]));

Integrationstests mit echter Datenbank

// 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');
  });
});

Controller-Tests

// 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);
  });
});

Sicherheitstests

// 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);
  });
});

CI-Pipeline-Konfiguration

# .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

Häufig gestellte Fragen

Warum Vitest statt Jest für NestJS?

Vitest verwendet den schnellen Bundler (esbuild/Rollup) von Vite anstelle von Babel, was eine 2- bis 5-mal schnellere Testausführung ermöglicht. Es verfügt über eine Jest-kompatible API – die meisten Jest-Tests werden ohne Änderungen migriert. Vitest verfügt über native ESM-Unterstützung (wichtig für moderne Pakete), integriertes TypeScript ohne ts-jest und einen besseren Überwachungsmodus. Für NestJS liegt der Hauptvorteil in deutlich schnelleren Feedbackschleifen im Überwachungsmodus.

Warum vi.hoisted() für Scheinfabriken verwenden?

vi.mock()-Aufrufe werden vom Vitest-Transformator an den Anfang der Datei gehoben. Wenn Ihre Scheinfabrik auf eine später in der Datei definierte Variable verweist (z. B. const mockDb = { ... }), wird sie ausgeführt, bevor die Variable initialisiert wird – ein zeitlicher Totzonenfehler. vi.hoisted() erstellt Werte, die ebenfalls angehoben werden, sodass sie verfügbar sind, wenn die Mock-Factory ausgeführt wird.

Wie teste ich NestJS-Guards und Interceptors?

Erstellen Sie einen Integrationstest mit createTestingModule und overrideGuard(), um den Schutz isoliert zu umgehen oder zu testen. Um zu testen, ob ein Wächter tatsächlich nicht autorisierte Anfragen ablehnt, verwenden Sie Supertest für die vollständige Anwendung (app.getHttpServer()) mit ungültigen oder fehlenden Token und bestätigen Sie den korrekten 401/403-Statuscode.

Sollten Drizzle-Scheinketten mit .limit() oder .returning() enden?

Bei SELECT-Abfragen endet die Kette normalerweise mit findMany(), findFirst() oder einem .limit()-Aufruf. Bei INSERT/UPDATE/DELETE mit values().returning() endet die Kette mit .returning(). Verspotten Sie die tiefste Methode in der Kette – unabhängig davon, welcher Dienstcode awaits zuletzt lautet. Das thenable-Proxy-Muster verarbeitet Ketten beliebiger Tiefe.

Wie teste ich geplante NestJS-Aufgaben (@Cron decorators)?

Fügen Sie den Scheduler in Ihr Testmodul ein und rufen Sie die Task-Methode direkt auf – warten Sie nicht auf den Cron-Timer. Verwenden Sie jest.useFakeTimers() (gleiche API in Vitest mit vi.useFakeTimers()), wenn Sie das Timing-Verhalten testen müssen. Um zu testen, ob eine Aufgabe geplant wurde, überprüfen Sie, ob die @nestjs/schedule SchedulerRegistry den erwarteten Job enthält.

Was ist die empfohlene Testverzeichnisstruktur für NestJS?

Platzieren Sie Komponententests gemeinsam mit Quelldateien (contacts.service.spec.ts neben contacts.service.ts). Platzieren Sie Integrationstests in src/modules/__integration__/ und Sicherheitstests in src/modules/__security__/. Durch diese Trennung können Sie während der Entwicklung nur Unit-Tests (vitest run --exclude **/__integration__/**) und alle Tests in CI ausführen.


Nächste Schritte

Eine umfassende Testsuite ist kein Luxus – es ist die Infrastruktur, die es Ihnen ermöglicht, schneller und sicherer zu liefern. Da Vitest 1.300 Tests in weniger als 30 Sekunden durchführt, gibt es keine Entschuldigung, den Testlauf zu überspringen, bevor es losgeht.

ECOSIRE erstellt NestJS-Backends mit vollständiger Testabdeckung vom ersten Tag an – 76 Testdateien, 1.301 Unit- und Integrationstests sowie Sicherheitstestabdeckung für Injektion, Authentifizierungsumgehung und Ratenbegrenzung. [Entdecken Sie unsere Backend-Engineering-Services] (/services), um zu erfahren, wie wir getestete, produktionsreife APIs bereitstellen.

E

Geschrieben von

ECOSIRE Research and Development Team

Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.

Chatten Sie auf WhatsApp