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.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir