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çinvi.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
createTestingModulekullanı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.
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.
İlgili Makaleler
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.