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 Mart 20269 dk okuma1.9k Kelime|

NestJS'yi Vitest ile Test Etme: Birim ve Entegrasyon Modelleri

Testlerin yapılmadığı bir NestJS uygulaması bir sorumluluktur. Güvenle yeniden düzenleme yapamazsınız, mevcut davranışı bozmadan özellikler ekleyemezsiniz ve regresyonları üretime ulaşmadan yakalayamazsınız. Ancak kötü testler yazmak, hiç test yapmamaktan neredeyse daha kötüdür; kimsenin çalıştırmadığı yavaş testler, uygulama ayrıntılarıyla birleştirilmiş kırılgan testler ve gerçek davranışı yansıtmayan alaycı testler, geliştiricinin güvenini aşındırır.

Bu kılavuz, 1.300'den fazla test için 30 saniyeden kısa sürede çalışan modern Jest alternatifi olan Vitest'i kullanarak NestJS için nasıl hızlı, güvenilir testlerin yazılacağını kapsar. Tüm yelpazeyi kapsıyoruz: taklit bağımlılıklara sahip saf birim testleri, gerçek bir veritabanı işlemiyle entegrasyon testleri, güvenlik testleri ve bunların her birinin her işlemde çalışmasını sağlayan CI yapılandırması.

Önemli Çıkarımlar

  • Vitest, Jest uyumludur ancak 2-5 kat daha hızlıdır — aynı API, geçiş sorunu yok
  • vi.mock() ile geçici ölü bölge sorunlarını önlemek amacıyla sahte fabrikalar için vi.hoisted() kullanın
  • Uygulamayı değil davranışı test edin - dahili yöntem çağrılarını değil, çıktıları ve yan etkileri öne çıkarın
  • Drizzle SELECT sahte zincirleri .limit() ile bitmeli, INSERT/UPDATE .returning() ile bitmelidir
  • Entegrasyon testleri, her testten sonra geri alınan gerçek veritabanı işlemlerini kullanmalıdır
  • Doğrudan sınıf örneklemesi için değil, uygun DI testi için NestJS'den createTestingModule kullanın
  • Güvenlik testleri (enjeksiyon, kimlik doğrulama atlama, hız sınırı) ayrı bir __security__ dizinine aittir
  • Hizmet katmanı için %80'den fazla hat kapsamı hedefleyin; Kimlik doğrulama ve faturalandırma modülleri için %100

Vitest Yapılandırması

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

Birim Test Modeli

Hizmet Test Ediliyor

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

Birim Testi

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

Drizzle Chain Mocks için Sonra Kullanılabilir Vekil

Drizzle sorguları akıcı zincirlerdir. Tüm zinciri taklit etmeniz gereken entegrasyon testi senaryoları için:

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

Gerçek Veritabanı ile Entegrasyon Testleri

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

Denetleyici Testleri

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

Güvenlik Testleri

// 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 Ardışık Düzen Yapılandırması

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

Sıkça Sorulan Sorular

NestJS için Neden Jest yerine Vitest?

Vitest, Babel yerine Vite'ın hızlı paketleyicisini (esbuild/Rollup) kullanarak 2-5 kat daha hızlı test yürütme olanağı sağlar. Jest uyumlu bir API'ye sahiptir; çoğu Jest testi hiçbir değişiklik olmadan taşınır. Vitest, yerel ESM desteğine (modern paketler için önemli), ts-jest içermeyen yerleşik TypeScript'e ve daha iyi izleme moduna sahiptir. NestJS için asıl avantaj, izleme modunda önemli ölçüde daha hızlı geri bildirim döngüleridir.

Sahte fabrikalar için neden vi.hoisted() kullanmalısınız?

vi.mock() çağrıları Vitest transformatörü tarafından dosyanın en üstüne çıkarılır. Sahte fabrikanız dosyada daha sonra tanımlanan bir değişkene (const mockDb = { ... } gibi) başvuruyorsa, değişken başlatılmadan önce çalışır; bu geçici ölü bölge hatasıdır. vi.hoisted() aynı zamanda kaldırılan değerler oluşturur, böylece bunlar sahte fabrika çalıştığında kullanılabilir.

NestJS korumalarını ve önleyicilerini nasıl test ederim?

Korumayı tek başına atlamak veya test etmek için createTestingModule ile overrideGuard() kullanarak bir entegrasyon testi oluşturun. Bir korumanın gerçekten yetkisiz istekleri reddettiğini test etmek için, geçersiz veya eksik belirteçlere sahip tam uygulamaya (app.getHttpServer()) karşı Supertest'i kullanın ve doğru 401/403 durum kodunu onaylayın.

Drizzle sahte zincirleri .limit() veya .returning() ile bitmeli mi?

SELECT sorguları için zincir genellikle findMany(), findFirst() veya .limit() çağrısıyla biter. values().returning() ile INSERT/UPDATE/DELETE için zincir .returning() ile biter. awaits hizmet kodu ne olursa olsun, zincirdeki en derin yöntemle alay edin. O zaman uygulanabilir proxy modeli, isteğe bağlı derinlikteki zincirleri yönetir.

Planlanmış NestJS görevlerini (@Cron dekoratörleri) nasıl test ederim?

Zamanlayıcıyı test modülünüze ekleyin ve görev yöntemini doğrudan çağırın; cron zamanlayıcısını beklemeyin. Zamanlama davranışını test etmeniz gerekiyorsa jest.useFakeTimers() (Vitest'te vi.useFakeTimers() ile aynı API) kullanın. Bir görevin zamanlandığını test etmek için @nestjs/schedule SchedulerRegistry'nin beklenen işi içerdiğini doğrulayın.

NestJS için önerilen test dizini yapısı nedir?

Birim testlerini kaynak dosyalarla birlikte konumlandırın (contacts.service.spec.ts, contacts.service.ts yanında). Entegrasyon testlerini src/modules/__integration__/'ye ve güvenlik testlerini src/modules/__security__/'e koyun. Bu ayırma, geliştirme sırasında yalnızca birim testleri (vitest run --exclude **/__integration__/**) ve CI'daki tüm testleri çalıştırmanıza olanak tanır.


Sonraki Adımlar

Kapsamlı bir test paketi lüks değildir; daha hızlı ve güvenle gönderim yapmanızı sağlayan altyapıdır. Vitest'in 30 saniyenin altında 1.300 test gerçekleştirmesi nedeniyle, zorlamadan önce test çalıştırmasını atlamanın hiçbir mazereti yoktur.

ECOSIRE, ilk günden itibaren tam test kapsamıyla (76 test dosyası, 1.301 birim ve entegrasyon testi ve enjeksiyon, kimlik doğrulama bypass'ı ve hız sınırlama için güvenlik testi kapsamı) NestJS arka uçlarını oluşturur. Test edilmiş, üretime hazır API'leri nasıl sunduğumuzu öğrenmek için arka uç mühendislik hizmetlerimizi keşfedin.

E

Yazan

ECOSIRE Research and Development Team

ECOSIRE'da kurumsal düzeyde dijital ürünler geliştiriyor. Odoo entegrasyonları, e-ticaret otomasyonu ve yapay zeka destekli iş çözümleri hakkında içgörüler paylaşıyor.

WhatsApp'ta Sohbet Et