Node.js Uygulamaları için Mocha ve Chai ile Test Yazımı
Üretim ortamında bir Node.js uygulaması çöktüğünde ve sebebinin ne olduğunu bulmaya çalışırken geçirdiğiniz o stresli saatleri bilirsiniz. Sonunda fark edersiniz ki bir önceki deployment’ta eklenen “küçük” bir değişiklik her şeyi mahvetmiş. İşte o an aklınıza gelir: “Neden daha iyi testler yazmadım?” Bu yazıda Mocha ve Chai ikilisini kullanarak Node.js uygulamalarınız için sağlam bir test altyapısı kurmayı ele alacağız. Teori değil, gerçek hayatta çalışan yaklaşımlar.
Neden Mocha ve Chai?
JavaScript ekosistemi test araçları açısından oldukça zengin. Jest var, Vitest var, Jasmine var. Ama Mocha ve Chai kombinasyonu özellikle kurumsal projelerde ve microservice mimarilerinde hâlâ çok tercih ediliyor. Bunun birkaç somut nedeni var.
Mocha, test runner olarak son derece esnek bir yapıya sahip. Hangi assertion kütüphanesini kullanacağınıza, hangi reporter’ı çalıştıracağınıza, async testleri nasıl yöneteceğinize siz karar veriyorsunuz. Bu esneklik başlangıçta biraz bunaltıcı gelebilir ama büyük projelerde bu kontrolü elinizde tutmak çok değerli oluyor.
Chai ise bu boşluğu dolduruyor. BDD tarzında expect ve should, TDD tarzında assert API’si sunuyor. Takımınızın hangi stili tercih ettiğine göre seçim yapabiliyorsunuz. İkisini aynı projede bile kullanabilirsiniz, karışmıyor.
Kurulum ve Proje Yapısı
Önce temiz bir Node.js projesi oluşturalım ve gerekli paketleri kuralım:
mkdir node-test-demo && cd node-test-demo
npm init -y
npm install --save-dev mocha chai
npm install --save-dev sinon chai-sinon
npm install --save-dev supertest
sinon mock ve stub işlemleri için, supertest ise HTTP endpoint testleri için kullanacağız. Bunları baştan kurmak alışkanlık edinilmesi gereken bir şey.
Proje dizin yapısını şu şekilde organize etmenizi öneririm:
mkdir -p src test/unit test/integration test/fixtures
touch .mocharc.js
.mocharc.js dosyası Mocha’nın konfigürasyon dosyası. Bu dosyayı oluşturmak birçok kişinin atlayan bir adım ama test suite’iniz büyüdükçe bu dosya olmadan yönetim zorlaşıyor:
cat > .mocharc.js << 'EOF'
module.exports = {
spec: 'test/**/*.test.js',
timeout: 5000,
reporter: 'spec',
exit: true,
recursive: true
};
EOF
package.json içindeki test scriptini güncelleyin:
npm pkg set scripts.test="mocha"
npm pkg set scripts.test:unit="mocha 'test/unit/**/*.test.js'"
npm pkg set scripts.test:integration="mocha 'test/integration/**/*.test.js'"
npm pkg set scripts.coverage="nyc mocha"
İlk Gerçek Senaryo: Kullanıcı Servisi Testi
Teorik örnekler yerine gerçek hayatta karşılaşılan bir senaryo üzerinden gidelim. Bir e-ticaret uygulamasında kullanıcı kaydı ve doğrulama işlemlerini yöneten bir servis yazıyoruz.
Önce servisin kendisi:
cat > src/userService.js << 'EOF'
const crypto = require('crypto');
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
async createUser(userData) {
if (!userData.email || !userData.password) {
throw new Error('Email ve şifre zorunludur');
}
const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
if (!emailRegex.test(userData.email)) {
throw new Error('Geçersiz email formatı');
}
if (userData.password.length < 8) {
throw new Error('Şifre en az 8 karakter olmalıdır');
}
const existingUser = await this.userRepository.findByEmail(userData.email);
if (existingUser) {
throw new Error('Bu email adresi zaten kullanımda');
}
const hashedPassword = crypto
.createHash('sha256')
.update(userData.password)
.digest('hex');
const user = {
id: crypto.randomUUID(),
email: userData.email,
password: hashedPassword,
createdAt: new Date().toISOString(),
role: userData.role || 'user'
};
return this.userRepository.save(user);
}
async validateUser(email, password) {
const user = await this.userRepository.findByEmail(email);
if (!user) {
throw new Error('Kullanıcı bulunamadı');
}
const hashedPassword = crypto
.createHash('sha256')
.update(password)
.digest('hex');
if (user.password !== hashedPassword) {
throw new Error('Hatalı şifre');
}
const { password: _, ...safeUser } = user;
return safeUser;
}
}
module.exports = UserService;
EOF
Şimdi bu servisi test eden unit test dosyasını yazalım:
cat > test/unit/userService.test.js << 'EOF'
const { expect } = require('chai');
const sinon = require('sinon');
const UserService = require('../../src/userService');
describe('UserService', () => {
let userService;
let mockRepository;
beforeEach(() => {
mockRepository = {
findByEmail: sinon.stub(),
save: sinon.stub()
};
userService = new UserService(mockRepository);
});
afterEach(() => {
sinon.restore();
});
describe('#createUser()', () => {
context('başarılı kullanıcı oluşturma', () => {
it('geçerli verilerle kullanıcı oluşturmalı', async () => {
const userData = {
email: '[email protected]',
password: 'Guclu1234'
};
mockRepository.findByEmail.resolves(null);
mockRepository.save.resolves({ id: 'uuid-123', email: userData.email });
const result = await userService.createUser(userData);
expect(result).to.have.property('id');
expect(result.email).to.equal(userData.email);
expect(mockRepository.save.calledOnce).to.be.true;
});
it('varsayılan role değeri "user" olmalı', async () => {
mockRepository.findByEmail.resolves(null);
mockRepository.save.callsFake((user) => Promise.resolve(user));
const result = await userService.createUser({
email: '[email protected]',
password: 'Guclu1234'
});
expect(result.role).to.equal('user');
});
});
context('validasyon hataları', () => {
it('email olmadan hata fırlatmalı', async () => {
try {
await userService.createUser({ password: 'test1234' });
expect.fail('Hata fırlatılmalıydı');
} catch (err) {
expect(err.message).to.equal('Email ve şifre zorunludur');
}
});
it('geçersiz email formatında hata fırlatmalı', async () => {
const createWithBadEmail = () =>
userService.createUser({
email: 'gecersiz-email',
password: 'Guclu1234'
});
await expect(createWithBadEmail()).to.be.rejectedWith(
'Geçersiz email formatı'
);
});
it('kısa şifre için hata fırlatmalı', async () => {
try {
await userService.createUser({
email: '[email protected]',
password: '123'
});
expect.fail('Hata fırlatılmalıydı');
} catch (err) {
expect(err.message).to.equal('Şifre en az 8 karakter olmalıdır');
}
});
it('mevcut email için hata fırlatmalı', async () => {
mockRepository.findByEmail.resolves({ id: 'mevcut-user' });
try {
await userService.createUser({
email: '[email protected]',
password: 'Guclu1234'
});
expect.fail('Hata fırlatılmalıydı');
} catch (err) {
expect(err.message).to.equal('Bu email adresi zaten kullanımda');
}
});
});
});
describe('#validateUser()', () => {
it('doğru credentials ile kullanıcı döndürmeli', async () => {
const crypto = require('crypto');
const password = 'Guclu1234';
const hashedPassword = crypto
.createHash('sha256')
.update(password)
.digest('hex');
mockRepository.findByEmail.resolves({
id: 'uuid-123',
email: '[email protected]',
password: hashedPassword,
role: 'user'
});
const result = await userService.validateUser('[email protected]', password);
expect(result).to.not.have.property('password');
expect(result).to.have.property('id');
});
});
});
EOF
Chai’nin Farklı Assertion Stilleri
Chai’nin güzelliği farklı assertion stillerini desteklemesi. Takım içinde standart belirleyip ona sadık kalmak önemli, ama bu stilleri bilmek gerekiyor.
expect stili (BDD): En yaygın kullanılan, okunması en kolay olan:
# Temel assertion örnekleri
# expect(deger).to.equal(beklenen)
# expect(dizi).to.have.lengthOf(3)
# expect(nesne).to.deep.include({ key: 'value' })
# expect(fonksiyon).to.throw(Error)
# expect(sayi).to.be.above(5).and.below(10)
assert stili (TDD): Klasik xUnit tarzı, Java/C# geçmişi olanlar için tanıdık:
cat > test/unit/assert-style-example.test.js << 'EOF'
const { assert } = require('chai');
describe('Assert stili örnek', () => {
it('temel assert kullanımı', () => {
const hesapla = (a, b) => a + b;
assert.equal(hesapla(2, 3), 5);
assert.isNumber(hesapla(2, 3));
assert.notEqual(hesapla(2, 3), 10);
assert.isTrue(hesapla(1, 1) === 2);
assert.deepEqual({ a: 1 }, { a: 1 });
});
});
EOF
Sinon ile Mock ve Stub Kullanımı
Dependency injection yapılmış uygulamalarda Sinon olmadan hayat çekilmez hale geliyor. Özellikle dış servis çağrıları, veritabanı operasyonları ve zaman bağımlı testlerde Sinon’un gücünü görüyorsunuz.
cat > test/unit/sinon-advanced.test.js << 'EOF'
const { expect } = require('chai');
const sinon = require('sinon');
describe('Sinon Gelişmiş Kullanım', () => {
let clock;
beforeEach(() => {
clock = sinon.useFakeTimers(new Date('2024-01-15T10:00:00Z'));
});
afterEach(() => {
clock.restore();
});
it('zaman bağımlı testlerde fake timer kullanımı', () => {
const SessionManager = require('../../src/sessionManager');
const session = new SessionManager(30);
const isValid = session.isSessionValid();
expect(isValid).to.be.true;
clock.tick(31 * 60 * 1000);
const isExpired = session.isSessionValid();
expect(isExpired).to.be.false;
});
it('spy ile fonksiyon çağrı sayısını doğrulama', () => {
const logger = {
log: sinon.spy(),
error: sinon.spy()
};
const processItems = (items, logger) => {
items.forEach(item => {
if (item.valid) {
logger.log(`İşlendi: ${item.id}`);
} else {
logger.error(`Hatalı item: ${item.id}`);
}
});
};
const items = [
{ id: 1, valid: true },
{ id: 2, valid: false },
{ id: 3, valid: true }
];
processItems(items, logger);
expect(logger.log.callCount).to.equal(2);
expect(logger.error.callCount).to.equal(1);
expect(logger.error.calledWith('Hatalı item: 2')).to.be.true;
});
});
EOF
HTTP Endpoint Testleri
Express uygulamalarında endpoint testleri yazmak için supertest kütüphanesi kullanıyoruz. Bu testler birim testlerden farklı olarak gerçek HTTP request/response döngüsünü test ediyor.
cat > test/integration/api.test.js << 'EOF'
const { expect } = require('chai');
const request = require('supertest');
const sinon = require('sinon');
const createApp = require('../../src/app');
const userRepository = require('../../src/repositories/userRepository');
describe('User API Integration Tests', () => {
let app;
before(() => {
app = createApp();
});
afterEach(() => {
sinon.restore();
});
describe('POST /api/users', () => {
it('200 ve yeni kullanıcı döndürmeli', async () => {
sinon.stub(userRepository, 'findByEmail').resolves(null);
sinon.stub(userRepository, 'save').callsFake((user) =>
Promise.resolve({ ...user, password: undefined })
);
const response = await request(app)
.post('/api/users')
.send({
email: '[email protected]',
password: 'Guclu1234!'
})
.expect('Content-Type', /json/)
.expect(201);
expect(response.body).to.have.property('id');
expect(response.body).to.have.property('email', '[email protected]');
expect(response.body).to.not.have.property('password');
});
it('eksik alan için 400 hatası döndürmeli', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: '[email protected]' })
.expect(400);
expect(response.body).to.have.property('error');
expect(response.body.error).to.include('zorunludur');
});
it('duplicate email için 409 hatası döndürmeli', async () => {
sinon.stub(userRepository, 'findByEmail').resolves({
id: 'mevcut-id',
email: '[email protected]'
});
const response = await request(app)
.post('/api/users')
.send({
email: '[email protected]',
password: 'Guclu1234!'
})
.expect(409);
expect(response.body.error).to.include('kullanımda');
});
});
});
EOF
Test Coverage ile Kalite Ölçümü
Test yazdık ama ne kadarını kapsıyoruz? Bu sorunun cevabı olmadan test suite’iniz sağlam bir güvence vermez. nyc (Istanbul’un yeni versiyonu) ile coverage raporu oluşturalım:
npm install --save-dev nyc
cat > .nycrc << 'EOF'
{
"include": ["src/**/*.js"],
"exclude": ["src/**/*.test.js", "node_modules"],
"reporter": ["text", "html", "lcov"],
"branches": 80,
"lines": 85,
"functions": 85,
"statements": 85,
"check-coverage": true
}
EOF
npx nyc mocha 'test/unit/**/*.test.js'
Coverage eşiklerini projenizin olgunluğuna göre belirleyin. Yeni başlayan bir projede %80 branch coverage zorunluluğu koymak sağlıklı, legacy kod üzerine ekleme yapıyorsanız başlangıçta %50 bile kabul edilebilir ve zamanla artırılabilir.
CI/CD Pipeline’a Entegrasyon
Testleri yazmak kadar önemli olan şey bunları otomatik çalıştırmak. GitHub Actions ile basit ama etkili bir pipeline:
mkdir -p .github/workflows
cat > .github/workflows/test.yml << 'EOF'
name: Node.js Test Suite
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v4
- name: Node.js ${{ matrix.node-version }} Kur
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Bağımlılıkları Yükle
run: npm ci
- name: Unit Testleri Çalıştır
run: npm run test:unit
- name: Integration Testlerini Çalıştır
run: npm run test:integration
- name: Coverage Raporu Oluştur
run: npm run coverage
- name: Coverage Raporunu Yükle
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
EOF
Yaygın Hatalar ve Çözümleri
Yıllarca bu araçlarla çalışırken gördüğüm en sık yapılan hatalardan bahsedelim.
async/await testlerde return veya await unutmak: En sinir bozucu hata budur. Test geçer görünür ama aslında async kısmı test edilmemiştir.
# YANLIŞ - async hatayı yakalamayabilir
it('hata fırlatmalı', () => {
userService.createUser({}).catch(err => {
expect(err.message).to.equal('Email ve şifre zorunludur');
});
});
# DOĞRU - await kullan
it('hata fırlatmalı', async () => {
try {
await userService.createUser({});
expect.fail('Hata fırlatılmalıydı');
} catch (err) {
expect(err.message).to.equal('Email ve şifre zorunludur');
}
});
Her testin bağımsız olmaması: Testler arasında shared state bırakmak test sırası değişince beklenmedik sonuçlar üretir. beforeEach ve afterEach bloklarını disiplinli kullanın.
Çok fazla şeyi tek testte test etmek: “Her test tek bir şeyi test etmeli” kuralı klişe görünebilir ama pratikte gerçekten önemli. Test başarısız olduğunda tam olarak neyin kırıldığını anında görmeniz gerekiyor.
Sonuç
Mocha ve Chai ile test yazmak başlangıçta ek iş yükü gibi hissettiriyor. O PR deadline’ı geldiğinde testleri atlama isteği geliyor. Ama üretimde bir şeyler patladığında ve “bu nasıl oldu ki” diye sorduğunuzda, iyi yazılmış testler hem regresyonu önlüyor hem de hatanın tam olarak nerede olduğunu gösteriyor.
Bu yazıda anlattıklarımı özetleyecek olursam:
- Proje yapısını baştan doğru kur:
test/unit,test/integrationayrımını baştan yapın .mocharc.jsdosyasını kullan: Konfigürasyonu tek yerden yönetin- Sinon ile bağımlılıkları izole et: Gerçek DB veya dış servis çağrıları unit testlerde olmamalı
- Coverage eşiklerini CI’a bağla: Kapsama oranı düşünce pipeline’ı kır
- async testleri dikkatli yaz: await/return eksikliği sinsi hatalara yol açar
- Her test tek bir davranışı doğrulasın: Granüler testler debugging’i kolaylaştırır
Node.js ekosisteminde Jest’in popülaritesi artıyor, bu doğru. Ama Mocha/Chai kombinasyonunun esnekliği ve olgunluğu özellikle mevcut büyük projelerde hâlâ büyük avantaj sağlıyor. Seçtiğiniz araçtan bağımsız olarak, test yazmayı bir alışkanlık haline getirmek her şeyin önünde geliyor.
