Node.js ile Entegrasyon Testi: Testcontainers Kullanımı
Entegrasyon testleri yazmak, unit testlere kıyasla her zaman biraz daha acı verici olmuştur. Gerçek bir veritabanına bağlanacaksınız, mesaj kuyruğunuz ayakta olacak, Redis’iniz çalışıyor olacak… Peki ya CI/CD pipeline’ınızda bu servisler yoksa? İşte tam bu noktada Testcontainers devreye giriyor ve hayatı ciddi ölçüde kolaylaştırıyor.
Testcontainers, testleriniz sırasında Docker container’ları ayağa kaldırıp testler bitince onları temizleyen bir kütüphane. Node.js ekosisteminde testcontainers paketi olarak mevcut ve son iki yıldır production-ready seviyesine geldi. Ben bu kütüphaneyi ilk kez bir e-ticaret projesinde kullandım; PostgreSQL, Redis ve RabbitMQ’yu aynı anda ayağa kaldırıp gerçek entegrasyon testleri yazabilmek gerçekten oyun değiştirici bir deneyimdi.
Neden Testcontainers?
Klasik yaklaşımların sıkıntılarını biliyorsunuzdur. Mock’lamak bazen işe yarıyor ama veritabanı sorgularınızın gerçekten doğru çalışıp çalışmadığını bilemiyorsunuz. docker-compose ile test ortamı kurmak ise CI’da her zaman sorunsuz gitmiyor, servisler hazır olmadan testler başlayabiliyor, cleanup unutuluyor, portlar çakışıyor.
Testcontainers şu avantajları getiriyor:
- İzolasyon: Her test suite’i kendi container’ında çalışır, paralel testlerde port çakışması olmaz
- Otomatik cleanup: Test bittikten sonra container’lar silinir, sisteminiz kirletilmez
- CI uyumluluğu: Docker kurulu olan her ortamda çalışır, ek konfigürasyon gerektirmez
- Gerçekçilik: Gerçek PostgreSQL, gerçek Redis, gerçek Kafka ile test ediyorsunuz
- Programatik kontrol: Container’ı koddan yönetiyorsunuz, ayrı bir YAML dosyasına bağımlı değilsiniz
Kurulum ve Temel Yapı
Önce projeyi hazırlayalım. Node.js 18+ ve Docker kurulu olduğunu varsayıyorum.
mkdir testcontainers-demo && cd testcontainers-demo
npm init -y
npm install --save-dev testcontainers jest @types/jest
npm install pg redis amqplib
npm install --save-dev @jest/globals ts-jest typescript
jest.config.js dosyasını oluşturun:
cat > jest.config.js << 'EOF'
module.exports = {
testEnvironment: 'node',
testTimeout: 60000,
verbose: true,
detectOpenHandles: true,
forceExit: true
};
EOF
Burada testTimeout: 60000 kritik. Container’ların ayağa kalkması 10-30 saniye sürebilir, varsayılan 5 saniyelik timeout bu süreyi karşılamaz. Özellikle ilk çalıştırmada Docker image’ı pull etmesi gerekiyorsa bu süre daha da uzayabilir.
PostgreSQL ile İlk Entegrasyon Testi
En yaygın kullanım senaryosu olan PostgreSQL ile başlayalım. Diyelim ki bir kullanıcı repository’niz var ve bunu gerçek bir veritabanıyla test etmek istiyorsunuz.
// src/userRepository.js
const { Pool } = require('pg');
class UserRepository {
constructor(connectionConfig) {
this.pool = new Pool(connectionConfig);
}
async initialize() {
await this.pool.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`);
}
async createUser(email, username) {
const result = await this.pool.query(
'INSERT INTO users (email, username) VALUES ($1, $2) RETURNING *',
[email, username]
);
return result.rows[0];
}
async findByEmail(email) {
const result = await this.pool.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
return result.rows[0] || null;
}
async close() {
await this.pool.end();
}
}
module.exports = UserRepository;
Şimdi bu repository için Testcontainers kullanan entegrasyon testini yazalım:
// tests/userRepository.test.js
const { PostgreSqlContainer } = require('testcontainers');
const UserRepository = require('../src/userRepository');
describe('UserRepository Entegrasyon Testleri', () => {
let container;
let userRepo;
beforeAll(async () => {
// PostgreSQL container'ı ayağa kaldır
container = await new PostgreSqlContainer('postgres:15-alpine')
.withDatabase('testdb')
.withUsername('testuser')
.withPassword('testpass')
.start();
userRepo = new UserRepository({
host: container.getHost(),
port: container.getMappedPort(5432),
database: container.getDatabase(),
user: container.getUsername(),
password: container.getPassword(),
});
await userRepo.initialize();
});
afterAll(async () => {
await userRepo.close();
await container.stop();
});
beforeEach(async () => {
// Her test öncesi tabloyu temizle
await userRepo.pool.query('DELETE FROM users');
});
test('yeni kullanıcı oluşturulabilmeli', async () => {
const user = await userRepo.createUser('[email protected]', 'ali_yilmaz');
expect(user.id).toBeDefined();
expect(user.email).toBe('[email protected]');
expect(user.username).toBe('ali_yilmaz');
expect(user.created_at).toBeDefined();
});
test('email ile kullanıcı bulunabilmeli', async () => {
await userRepo.createUser('[email protected]', 'ayse_demir');
const found = await userRepo.findByEmail('[email protected]');
expect(found).not.toBeNull();
expect(found.username).toBe('ayse_demir');
});
test('duplicate email hata fırlatmalı', async () => {
await userRepo.createUser('[email protected]', 'mehmet_k');
await expect(
userRepo.createUser('[email protected]', 'mehmet_k2')
).rejects.toThrow();
});
});
Testi çalıştırmak için:
npx jest tests/userRepository.test.js --verbose
Çıktıda şunu göreceksiniz: Container pull ediliyor, başlatılıyor, testler çalışıyor, container temizleniyor. Temiz, bağımsız, tekrar edilebilir.
Redis ile Önbellek Testleri
Şimdi biraz daha gerçekçi bir senaryo. Çoğu projede Redis kullanıyorsunuz ve cache mantığınızın gerçekten çalışıp çalışmadığını test etmek istiyorsunuz.
// src/cacheService.js
const redis = require('redis');
class CacheService {
constructor(redisUrl) {
this.client = redis.createClient({ url: redisUrl });
}
async connect() {
await this.client.connect();
}
async set(key, value, ttlSeconds = 300) {
await this.client.setEx(key, ttlSeconds, JSON.stringify(value));
}
async get(key) {
const data = await this.client.get(key);
return data ? JSON.parse(data) : null;
}
async invalidate(pattern) {
const keys = await this.client.keys(pattern);
if (keys.length > 0) {
await this.client.del(keys);
}
return keys.length;
}
async disconnect() {
await this.client.quit();
}
}
module.exports = CacheService;
// tests/cacheService.test.js
const { GenericContainer, Wait } = require('testcontainers');
const CacheService = require('../src/cacheService');
describe('CacheService Entegrasyon Testleri', () => {
let container;
let cache;
beforeAll(async () => {
container = await new GenericContainer('redis:7-alpine')
.withExposedPorts(6379)
.withWaitStrategy(Wait.forLogMessage('Ready to accept connections'))
.start();
const redisUrl = `redis://${container.getHost()}:${container.getMappedPort(6379)}`;
cache = new CacheService(redisUrl);
await cache.connect();
});
afterAll(async () => {
await cache.disconnect();
await container.stop();
});
test('değer cache'e yazılıp okunabilmeli', async () => {
const data = { userId: 1, name: 'Ali', role: 'admin' };
await cache.set('user:1', data, 60);
const retrieved = await cache.get('user:1');
expect(retrieved).toEqual(data);
});
test('TTL süresi geçen key null dönmeli', async () => {
await cache.set('short-lived', { value: 'test' }, 1);
await new Promise(resolve => setTimeout(resolve, 1500));
const result = await cache.get('short-lived');
expect(result).toBeNull();
});
test('pattern ile invalidation çalışmalı', async () => {
await cache.set('product:1', { name: 'Laptop' });
await cache.set('product:2', { name: 'Mouse' });
await cache.set('user:1', { name: 'Ali' });
const deletedCount = await cache.invalidate('product:*');
expect(deletedCount).toBe(2);
expect(await cache.get('product:1')).toBeNull();
expect(await cache.get('user:1')).not.toBeNull();
});
});
Wait.forLogMessage stratejisine dikkat edin. Redis container’ı başladığında log’da “Ready to accept connections” mesajı görünmeden bağlantı denemek hatalara yol açar. Testcontainers’ın wait strategy sistemi bu problemi zarif biçimde çözüyor.
Çoklu Container: Gerçek Dünya Senaryosu
Gerçek projelerde tek servis yetmiyor. Bir sipariş servisi düşünün: PostgreSQL’e yazıyor, Redis’e cache’liyor, RabbitMQ’ya event gönderiyor. Bunların hepsini birlikte test etmek istiyorsunuz.
// tests/orderService.integration.test.js
const { PostgreSqlContainer, GenericContainer, Network, Wait } = require('testcontainers');
const { Pool } = require('pg');
const redis = require('redis');
const amqplib = require('amqplib');
describe('OrderService Tam Entegrasyon Testi', () => {
let pgContainer, redisContainer, rabbitContainer;
let pgPool, redisClient, amqpConnection, amqpChannel;
beforeAll(async () => {
// Tüm container'ları paralel başlat - süreyi önemli ölçüde kısaltır
[pgContainer, redisContainer, rabbitContainer] = await Promise.all([
new PostgreSqlContainer('postgres:15-alpine')
.withDatabase('orders_test')
.start(),
new GenericContainer('redis:7-alpine')
.withExposedPorts(6379)
.withWaitStrategy(Wait.forLogMessage('Ready to accept connections'))
.start(),
new GenericContainer('rabbitmq:3.12-management-alpine')
.withExposedPorts(5672)
.withWaitStrategy(Wait.forLogMessage('Server startup complete'))
.start()
]);
// Bağlantıları kur
pgPool = new Pool({
host: pgContainer.getHost(),
port: pgContainer.getMappedPort(5432),
database: pgContainer.getDatabase(),
user: pgContainer.getUsername(),
password: pgContainer.getPassword(),
});
redisClient = redis.createClient({
url: `redis://${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}`
});
await redisClient.connect();
amqpConnection = await amqplib.connect(
`amqp://${rabbitContainer.getHost()}:${rabbitContainer.getMappedPort(5672)}`
);
amqpChannel = await amqpConnection.createChannel();
// Schema hazırla
await pgPool.query(`
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_email VARCHAR(255) NOT NULL,
total DECIMAL(10,2) NOT NULL,
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW()
)
`);
await amqpChannel.assertQueue('order.created', { durable: false });
});
afterAll(async () => {
await redisClient.quit();
await amqpChannel.close();
await amqpConnection.close();
await pgPool.end();
await Promise.all([
pgContainer.stop(),
redisContainer.stop(),
rabbitContainer.stop()
]);
});
test('sipariş oluşturulunca DB'ye yazılmalı, cache'e alınmalı, event gönderilmeli', async () => {
// Sipariş oluştur
const result = await pgPool.query(
`INSERT INTO orders (customer_email, total) VALUES ($1, $2) RETURNING *`,
['[email protected]', 299.99]
);
const order = result.rows[0];
// Cache'e yaz
await redisClient.setEx(`order:${order.id}`, 300, JSON.stringify(order));
// Event gönder
amqpChannel.sendToQueue(
'order.created',
Buffer.from(JSON.stringify({ orderId: order.id, email: order.customer_email }))
);
// Doğrulamalar
const cachedOrder = JSON.parse(await redisClient.get(`order:${order.id}`));
expect(cachedOrder.customer_email).toBe('[email protected]');
const message = await new Promise(resolve => {
amqpChannel.consume('order.created', (msg) => {
amqpChannel.ack(msg);
resolve(JSON.parse(msg.content.toString()));
}, { noAck: false });
});
expect(message.email).toBe('[email protected]');
expect(message.orderId).toBe(order.id);
});
});
Promise.all ile paralel container başlatmak çok önemli. Sıralı başlatmak yerine paralel başlatınca test sürenizi neredeyse üçte bire indirebiliyorsunuz.
Reusable Container Pattern
Her test dosyası için container’ı baştan başlatmak zaman kaybı. globalSetup ile container’ı bir kez başlatıp suite boyunca kullanabilirsiniz.
// jest.globalSetup.js
const { PostgreSqlContainer } = require('testcontainers');
module.exports = async () => {
const container = await new PostgreSqlContainer('postgres:15-alpine')
.withDatabase('testdb')
.withReuse()
.start();
process.env.TEST_DB_HOST = container.getHost();
process.env.TEST_DB_PORT = container.getMappedPort(5432).toString();
process.env.TEST_DB_NAME = container.getDatabase();
process.env.TEST_DB_USER = container.getUsername();
process.env.TEST_DB_PASS = container.getPassword();
// Container referansını global'e ekle
global.__PG_CONTAINER__ = container;
};
// jest.globalTeardown.js
module.exports = async () => {
if (global.__PG_CONTAINER__) {
await global.__PG_CONTAINER__.stop();
}
};
# jest.config.js'e ekleyin
module.exports = {
testEnvironment: 'node',
testTimeout: 60000,
globalSetup: './jest.globalSetup.js',
globalTeardown: './jest.globalTeardown.js',
verbose: true
};
.withReuse() özelliği özellikle development sırasında çok işe yarıyor. Container zaten ayaktaysa yeniden başlatmıyor, mevcut olanı kullanıyor. Bu sayede ilk çalıştırmadan sonraki test çalıştırmalarınız çok daha hızlı oluyor.
CI/CD Pipeline Entegrasyonu
GitHub Actions’da Testcontainers kullanımı için özel bir şey yapmanıza gerek yok, Docker zaten mevcut. Ama bazı ince ayarlar işinizi kolaylaştırır:
# .github/workflows/test.yml
name: Entegrasyon Testleri
on: [push, pull_request]
jobs:
integration-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Node.js Kur
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Bağımlılıkları Yükle
run: npm ci
- name: Docker Image'larını Önceden Çek
run: |
docker pull postgres:15-alpine
docker pull redis:7-alpine
- name: Entegrasyon Testlerini Çalıştır
run: npm test
env:
TESTCONTAINERS_RYUK_DISABLED: false
DOCKER_HOST: unix:///var/run/docker.sock
TESTCONTAINERS_RYUK_DISABLED değişkeni Ryuk adlı cleanup container’ını kontrol ediyor. Ryuk, testcontainers’ın arka planda çalıştırdığı ve container’ları temizleyen bir daemon. CI ortamında genellikle etkin bırakmak doğru.
Docker image’larını önceden çekmek (docker pull) test sürenizi önemli ölçüde kısaltır. Özellikle CI ortamında her çalıştırmada image’ı pull etmek gereksiz yük oluşturur.
Sık Karşılaşılan Sorunlar ve Çözümleri
Container başlamıyor, timeout hatası alıyorum. testTimeout değerini artırın ve withWaitStrategy kullandığınızdan emin olun. Bazı image’lar için port’un açık olmasını beklemek yeterli:
.withWaitStrategy(Wait.forListeningPorts())
Testler paralel çalışınca çakışıyor. Her test suite’i kendi container’ını kullanmalı ya da beforeEach ile tabloları temizlemelisiniz. Paralel testlerde aynı container’ı paylaşıyorsanız, schema-per-test pattern’ini düşünün: her test için ayrı bir PostgreSQL schema oluşturun.
Docker bulunamıyor hatası. CI ortamında DOCKER_HOST environment variable’ını doğru set ettiğinizden emin olun. Podman kullanıyorsanız DOCKER_HOST=unix:///run/user/1000/podman/podman.sock şeklinde ayarlayın.
Memory sıkıntısı yaşıyorum. Container’lara resource limit koyabilirsiniz:
container = await new GenericContainer('redis:7-alpine')
.withExposedPorts(6379)
.withResourcesQuota({ memory: 512 * 1024 * 1024 }) // 512MB
.start();
Sonuç
Testcontainers, entegrasyon testlerindeki en büyük acı noktalarından birini, yani servis bağımlılıklarını çözüyor. Mock’ların yetmediği yerlerde, gerçek davranışı test etmeniz gereken senaryolarda bu araç gerçekten değer katıyor.
Pratikte birkaç tavsiyem var: Küçük başlayın, önce en kritik servisinizi Testcontainers ile test edin. PostgreSQL veya Redis genellikle iyi bir başlangıç noktası. Promise.all ile paralel container başlatmayı alışkanlık haline getirin, test süreleriniz belirgin şekilde düşer. withReuse() özelliğini local development’ta kullanın ama CI’da dikkatli olun, bazen kirli state sorunlarına yol açabiliyor.
En önemlisi: Testcontainers’ı benimsediğinizde “gerçekten test ettik mi?” sorusu kafanızdan gidiyor. Veritabanı constraint’leriniz, query optimizasyonlarınız, transaction mantığınız, bunların hepsi gerçek bir ortamda doğrulanmış oluyor. Bu güven, production’da karşılaşabileceğiniz sürprizleri önemli ölçüde azaltıyor.
