Vitest ile Modern JavaScript Unit Test Yazımı
Uzun süredir Jest kullanıyorsanız ve “neden bu kadar yavaş?” diye sormaya başladıysanız, bu yazı tam size göre. Vitest, özellikle Vite tabanlı projelerde ama sadece onlarda değil, modern JavaScript test dünyasında ciddi bir yer edindi. Ben de son bir yıldır hem Node.js servislerinde hem de React projelerinde Vitest’e geçiş yaptım; bu yazıda gerçekten işe yarayan şeyleri ve dikkat etmeniz gereken noktaları paylaşacağım.
Vitest Neden Var?
Jest zaten var, Mocha zaten var, neden başka bir şey öğrenelim? Haklı soru. Ama şunu söyleyeyim: eğer projenizde Vite kullanıyorsanız veya ESM (ECMAScript Modules) ile çalışıyorsanız, Jest ile hayat gerçekten zorlaşıyor. transform ayarları, Babel konfigürasyonları, moduleNameMapper eziyetleri… Bunlarla vakit harcamak yerine test yazmak istiyorsunuz.
Vitest, Vite’ın altyapısını kullanır. Bu ne demek? Aynı vite.config.ts dosyası, aynı plugin ekosistemi, aynı alias tanımları. Sıfırdan bir test konfigürasyonu kurmak yerine zaten var olan yapının üzerine oturuyorsunuz. Ayrıca Jest API’si ile büyük ölçüde uyumlu olduğu için geçiş sancısı minimal.
Performans meselesine gelince: Vitest, testleri paralel ve izole worker thread’lerde çalıştırır. Gerçek dünya ölçümünde 200 testlik bir suite’i Jest’te 18-20 saniyede çalıştırırken Vitest ile 4-6 saniyeye indiğini gördüm. Bu rakam projeye göre değişir elbette ama yön doğru.
Kurulum ve İlk Konfigürasyon
Saf bir Node.js projesinde başlayalım. Vite zorunlu değil, sadece vitest paketi yeterli.
npm install --save-dev vitest
# ya da
pnpm add -D vitest
Eğer TypeScript kullanıyorsanız (kullanmalısınız):
npm install --save-dev vitest @vitest/coverage-v8 typescript
Şimdi vitest.config.ts dosyası oluşturalım. Bu dosya yoksa Vitest, vite.config.ts içindeki test bloğuna bakıyor. Ben ayrı dosya tutmayı tercih ediyorum, konfigürasyonlar karışmıyor:
# vitest.config.ts
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: ['node_modules/', 'dist/', '**/*.d.ts'],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80
}
},
include: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
setupFiles: ['./src/test/setup.ts'],
alias: {
'@': path.resolve(__dirname, './src')
}
}
})
globals: true dediğimizde describe, it, expect gibi fonksiyonları her dosyada import etmek zorunda kalmıyorsunuz. Ama TypeScript için tsconfig.json‘a şunu eklemeniz gerekiyor:
# tsconfig.json - compilerOptions içine
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}
package.json‘a script’leri ekleyelim:
# package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}
vitest komutu watch modunda çalışır, dosya değişikliğinde testleri yeniden koşar. CI ortamında vitest run kullanın, watch modunda beklemez.
İlk Gerçek Test: Servis Katmanı
Teoriden çıkıp gerçek bir örneğe girelim. Bir e-ticaret projesinde sipariş hesaplama servisini test edeceğiz.
# src/services/orderCalculator.ts
export interface OrderItem {
productId: string
quantity: number
unitPrice: number
category: 'electronics' | 'clothing' | 'food'
}
export interface DiscountRule {
minAmount: number
discountPercent: number
}
export function calculateOrderTotal(
items: OrderItem[],
discountRules: DiscountRule[] = []
): {
subtotal: number
discount: number
tax: number
total: number
} {
if (!items || items.length === 0) {
throw new Error('Sipariş en az bir ürün içermelidir')
}
const subtotal = items.reduce((sum, item) => {
if (item.quantity <= 0) throw new Error(`Geçersiz miktar: ${item.productId}`)
return sum + item.quantity * item.unitPrice
}, 0)
const applicableDiscount = discountRules
.filter(rule => subtotal >= rule.minAmount)
.sort((a, b) => b.discountPercent - a.discountPercent)[0]
const discount = applicableDiscount
? (subtotal * applicableDiscount.discountPercent) / 100
: 0
const taxableAmount = subtotal - discount
const taxRate = items.some(i => i.category === 'food') ? 0.08 : 0.18
const tax = taxableAmount * taxRate
return {
subtotal: Math.round(subtotal * 100) / 100,
discount: Math.round(discount * 100) / 100,
tax: Math.round(tax * 100) / 100,
total: Math.round((taxableAmount + tax) * 100) / 100
}
}
Şimdi bu servisi test edelim:
# src/services/orderCalculator.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { calculateOrderTotal, OrderItem, DiscountRule } from './orderCalculator'
describe('calculateOrderTotal', () => {
let sampleItems: OrderItem[]
let discountRules: DiscountRule[]
beforeEach(() => {
sampleItems = [
{ productId: 'P001', quantity: 2, unitPrice: 150, category: 'electronics' },
{ productId: 'P002', quantity: 1, unitPrice: 75, category: 'electronics' }
]
discountRules = [
{ minAmount: 300, discountPercent: 5 },
{ minAmount: 500, discountPercent: 10 },
{ minAmount: 1000, discountPercent: 15 }
]
})
describe('temel hesaplama', () => {
it('doğru ara toplamı hesaplamalı', () => {
const result = calculateOrderTotal(sampleItems)
// 2 * 150 + 1 * 75 = 375
expect(result.subtotal).toBe(375)
})
it('indirim kuralı yoksa indirim sıfır olmalı', () => {
const result = calculateOrderTotal(sampleItems)
expect(result.discount).toBe(0)
})
it('elektronik ürünlerde %18 KDV uygulamalı', () => {
const result = calculateOrderTotal(sampleItems)
expect(result.tax).toBe(67.5) // 375 * 0.18
})
})
describe('indirim kuralları', () => {
it('minimum tutarı karşılayan en yüksek indirimi uygulamalı', () => {
const result = calculateOrderTotal(sampleItems, discountRules)
// Subtotal 375, sadece %5 kuralı geçerli
expect(result.discount).toBe(18.75)
})
it('birden fazla kural varsa en yükseğini seçmeli', () => {
const bigOrder: OrderItem[] = [
{ productId: 'P001', quantity: 5, unitPrice: 250, category: 'electronics' }
]
const result = calculateOrderTotal(bigOrder, discountRules)
// Subtotal 1250, %15 kuralı geçerli
expect(result.discount).toBe(187.5)
})
})
describe('gıda ürünleri KDV', () => {
it('gıda içeren siparişlerde %8 KDV uygulamalı', () => {
const foodItems: OrderItem[] = [
{ productId: 'F001', quantity: 3, unitPrice: 40, category: 'food' }
]
const result = calculateOrderTotal(foodItems)
// 120 * 0.08 = 9.6
expect(result.tax).toBe(9.6)
})
})
describe('hata durumları', () => {
it('boş sipariş listesinde hata fırlatmalı', () => {
expect(() => calculateOrderTotal([])).toThrow('Sipariş en az bir ürün içermelidir')
})
it('negatif miktar girildiğinde hata fırlatmalı', () => {
const invalidItems: OrderItem[] = [
{ productId: 'P001', quantity: -1, unitPrice: 100, category: 'clothing' }
]
expect(() => calculateOrderTotal(invalidItems)).toThrow('Geçersiz miktar')
})
})
})
Mock, Spy ve Fake: Farkları ve Kullanım Yerleri
Bu üç kavramı karıştırmak test yazımının en yaygın sorunlarından biri. Vitest’teki kullanımlarına bakalım.
Mock: Bir modülün veya fonksiyonun tamamen sahte versiyonu. Gerçek implementasyonu çalışmaz.
Spy: Gerçek implementasyon çalışmaya devam eder, sadece çağrıları izlenir.
Fake: Gerçek implementasyona benzer ama test için basitleştirilmiş bir versiyon (örneğin in-memory veritabanı).
Pratik bir senaryo: Dış bir API’ye istek atan bir servis:
# src/services/userNotificationService.ts
import { EmailClient } from '../clients/emailClient'
import { UserRepository } from '../repositories/userRepository'
export class UserNotificationService {
constructor(
private emailClient: EmailClient,
private userRepo: UserRepository
) {}
async sendWelcomeEmail(userId: string): Promise<boolean> {
const user = await this.userRepo.findById(userId)
if (!user) throw new Error(`Kullanıcı bulunamadı: ${userId}`)
if (!user.emailVerified) return false
await this.emailClient.send({
to: user.email,
subject: 'Hoş geldiniz!',
template: 'welcome',
data: { name: user.name }
})
await this.userRepo.updateLastContactDate(userId, new Date())
return true
}
}
Bu servisi test etmek için dış bağımlılıkları mock’lamak gerekiyor:
# src/services/userNotificationService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { UserNotificationService } from './userNotificationService'
import type { EmailClient } from '../clients/emailClient'
import type { UserRepository } from '../repositories/userRepository'
describe('UserNotificationService', () => {
let service: UserNotificationService
let mockEmailClient: EmailClient
let mockUserRepo: UserRepository
beforeEach(() => {
// vi.fn() ile mock oluşturuyoruz
mockEmailClient = {
send: vi.fn().mockResolvedValue({ messageId: 'test-123' })
} as unknown as EmailClient
mockUserRepo = {
findById: vi.fn(),
updateLastContactDate: vi.fn().mockResolvedValue(undefined)
} as unknown as UserRepository
service = new UserNotificationService(mockEmailClient, mockUserRepo)
})
it('onaylı kullanıcıya email göndermeli ve true döndürmeli', async () => {
vi.mocked(mockUserRepo.findById).mockResolvedValue({
id: 'user-1',
name: 'Ahmet Yılmaz',
email: '[email protected]',
emailVerified: true
})
const result = await service.sendWelcomeEmail('user-1')
expect(result).toBe(true)
expect(mockEmailClient.send).toHaveBeenCalledOnce()
expect(mockEmailClient.send).toHaveBeenCalledWith(
expect.objectContaining({
to: '[email protected]',
template: 'welcome'
})
)
})
it('emaili onaylanmamış kullanıcıya email göndermemeli', async () => {
vi.mocked(mockUserRepo.findById).mockResolvedValue({
id: 'user-2',
name: 'Mehmet Kaya',
email: '[email protected]',
emailVerified: false
})
const result = await service.sendWelcomeEmail('user-2')
expect(result).toBe(false)
expect(mockEmailClient.send).not.toHaveBeenCalled()
})
it('son iletişim tarihini güncellemiş olmalı', async () => {
vi.mocked(mockUserRepo.findById).mockResolvedValue({
id: 'user-1',
name: 'Ayşe Demir',
email: '[email protected]',
emailVerified: true
})
await service.sendWelcomeEmail('user-1')
expect(mockUserRepo.updateLastContactDate).toHaveBeenCalledWith(
'user-1',
expect.any(Date)
)
})
})
Modül Mock’lama: vi.mock() ile Çalışmak
Bazen bir sınıf değil, bir modülün tamamını mock’lamanız gerekiyor. Vitest’te bu biraz farklı çalışıyor, hoisting mekanizmasını anlamak önemli:
# src/utils/dateHelper.test.ts
import { describe, it, expect, vi, afterEach } from 'vitest'
// vi.mock çağrısı dosyanın en üstüne hoist edilir,
// import'lardan önce çalışır
vi.mock('../clients/httpClient', () => ({
httpClient: {
get: vi.fn(),
post: vi.fn()
}
}))
import { httpClient } from '../clients/httpClient'
import { fetchUserProfile } from './userProfileService'
describe('fetchUserProfile', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('kullanıcı profilini başarıyla getirmeli', async () => {
vi.mocked(httpClient.get).mockResolvedValueOnce({
data: {
id: '123',
username: 'testuser',
role: 'admin'
},
status: 200
})
const profile = await fetchUserProfile('123')
expect(profile.username).toBe('testuser')
expect(httpClient.get).toHaveBeenCalledWith('/api/users/123')
})
it('404 durumunda null dönmeli', async () => {
vi.mocked(httpClient.get).mockRejectedValueOnce({
response: { status: 404 }
})
const profile = await fetchUserProfile('nonexistent')
expect(profile).toBeNull()
})
})
vi.clearAllMocks() vs vi.resetAllMocks() vs vi.restoreAllMocks() farkına dikkat edin:
- vi.clearAllMocks(): Çağrı geçmişini ve sonuçları temizler, implementasyonu korur
- vi.resetAllMocks(): Hem geçmişi hem implementasyonu sıfırlar
- vi.restoreAllMocks():
vi.spyOnile oluşturulan spy’ları orijinal haline döndürür
Zaman Kontrolü: Fake Timer’lar
Zamanlama içeren kodları test etmek her zaman acı vericidir. setTimeout, setInterval, Date.now() gibi şeylerle uğraşmak… Vitest’in fake timer desteği burada çok işe yarıyor:
# src/utils/retryMechanism.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { retryWithBackoff } from './retryMechanism'
describe('retryWithBackoff', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('başarısız olduğunda exponential backoff ile retry atmalı', async () => {
let callCount = 0
const unstableOperation = vi.fn().mockImplementation(async () => {
callCount++
if (callCount < 3) throw new Error('Geçici hata')
return { success: true }
})
const resultPromise = retryWithBackoff(unstableOperation, {
maxRetries: 3,
initialDelay: 1000
})
// 1. deneme başarısız, 1000ms bekle
await vi.advanceTimersByTimeAsync(1000)
// 2. deneme başarısız, 2000ms bekle (exponential)
await vi.advanceTimersByTimeAsync(2000)
// 3. deneme başarılı
const result = await resultPromise
expect(result).toEqual({ success: true })
expect(unstableOperation).toHaveBeenCalledTimes(3)
})
it('max retry aşıldığında son hatayı fırlatmalı', async () => {
const alwaysFails = vi.fn().mockRejectedValue(new Error('Kalıcı hata'))
const resultPromise = retryWithBackoff(alwaysFails, {
maxRetries: 2,
initialDelay: 500
})
await vi.runAllTimersAsync()
await expect(resultPromise).rejects.toThrow('Kalıcı hata')
expect(alwaysFails).toHaveBeenCalledTimes(3) // ilk deneme + 2 retry
})
})
Test Coverage ve Thresholds
Coverage raporları güzel ama anlamlı kullanılmazsa sadece yeşil renk görmek için yazılan testlere yol açar. Ben coverage’ı bir bariyer olarak değil, kör nokta bulucu olarak kullanıyorum.
# Coverage raporu almak için
npx vitest run --coverage
# HTML raporu src/coverage/index.html altında oluşur
# CI için lcov formatını kullanın
vitest.config.ts içindeki threshold ayarı önemli. %80 line coverage makul bir başlangıç noktası. Ama şunu da söyleyeyim: kritik iş mantığı içeren bir modülde %95 altı beni rahatsız eder, yardımcı bir utility fonksiyonunda %70 kabul edilebilir.
Branch coverage’a özellikle dikkat edin. if/else, switch, ternary operatörler, kısa devre değerlendirmeleri (&&, ||) hepsini kapsaması gerekiyor. Line coverage yüksek ama branch coverage düşük bir test suite’i gerçekte çok daha kırılgandır.
Snapshot Testing: Ne Zaman Kullanmalı?
Snapshot testleri çok sık yanlış kullanılıyor. DOM output veya büyük nesne yapıları için işe yarıyor ama “ne döneceğini bilmiyorum, snapshot alayım” yaklaşımıyla kullanılırsa hiçbir değeri olmayan, sürekli güncellenen testler ortaya çıkıyor.
# src/utils/reportGenerator.test.ts
import { describe, it, expect } from 'vitest'
import { generateErrorReport } from './reportGenerator'
describe('generateErrorReport', () => {
it('hata raporunun yapısı değişmemeli', () => {
const errors = [
{ code: 'E001', message: 'Bağlantı hatası', timestamp: new Date('2024-01-15') },
{ code: 'E002', message: 'Zaman aşımı', timestamp: new Date('2024-01-15') }
]
const report = generateErrorReport(errors, 'production')
// Büyük ve karmaşık nesne yapıları için snapshot mantıklı
expect(report).toMatchSnapshot()
})
it('inline snapshot - küçük output için daha okunabilir', () => {
const summary = generateErrorReport([], 'test')
expect(summary.errorCount).toMatchInlineSnapshot(`0`)
})
})
Snapshot’ları güncellemek için: npx vitest --update-snapshots. Ama bunu otomatik olarak yapmadan önce değişikliğin gerçekten beklenen bir şey olduğundan emin olun.
Concurrent Testler: Dikkatli Kullanın
Vitest, describe.concurrent ve it.concurrent ile testleri paralel çalıştırabilir. Bu çekici görünüyor ama ortak state paylaşan testlerde felakete davetiye çıkarır.
# Doğru kullanım: bağımsız, izole testler
describe('bağımsız API endpoint testleri', () => {
it.concurrent('GET /users endpoint testi', async () => {
// Her test kendi mock'ını kullanıyor, paylaşım yok
const result = await fetchUsers({ page: 1 })
expect(result.data).toBeDefined()
})
it.concurrent('GET /products endpoint testi', async () => {
const result = await fetchProducts({ category: 'electronics' })
expect(result.total).toBeGreaterThan(0)
})
})
Eğer testler arasında paylaşılan bir in-memory store, global değişken veya mock state varsa concurrent kullanmayın. Race condition kaynaklı flaky testler debugging için en çok zaman çalan problemler arasında.
Vitest UI: Görsel Debug Aracı
npm install --save-dev @vitest/ui
npx vitest --ui
Vitest UI, tarayıcıda çalışan görsel bir test runner açıyor. Hangi testlerin pass/fail olduğunu, coverage’ı, her testin süresini görebiliyorsunuz. CI’da kullanılmaz ama lokal geliştirmede, özellikle bir test suite’i debug ederken çok işe yarıyor. “Bu test neden 3 saniye sürüyor?” sorusunu anında cevaplıyor.
Sonuç
Vitest’e geçişte benim için en büyük kazanım performans oldu ama asıl değeri ESM desteği ve sıfır konfigürasyonla çalışabilmesi. Eğer Vite kullanıyorsanız geçiş için gerçekten bir engel yok. Kullanmıyorsanız bile pure Node.js projelerinde Jest’in yerini aldığında çok memnun kalacaksınız.
Birkaç pratik öneri ile bitirelim: Test dosyalarını kaynak dosyalarla aynı dizinde tutun, ayrı __tests__ klasörü açmayın. İsim olarak .test.ts uzantısını tutarlı kullanın. Mock’ları test dosyası içinde tanımlayın, paylaşılan mock fabrikalarına hızlı geçmeyin. Coverage’ı anlamlı kullanın, rakam için test yazmayın. Ve her commit öncesi vitest run çalıştırmayı alışkanlık haline getirin; CI’da kırmızı görmek yerine lokalde yakalayın.
Test yazmak çoğu zaman kod yazmaktan daha uzun sürer, bu normaldir. Ama iyi yazılmış bir test suite’i ilerleyen dönemde refactoring güvenceniz ve yeni geliştiricilerin canlı dokümantasyonu olur. Vitest bunu kolaylaştırıyor, gerisi sizde.
