Masalah Akurasi pada Mock dan In-Memory Database

Pengujian integrasi lapisan data sering kali mengandalkan mocking atau in-memory database seperti SQLite dan H2. Pendekatan ini cepat, tetapi mengorbankan akurasi verifikasi sistem. In-memory database tidak mereplikasi dialek, tipe data native, maupun perilaku konkurensi dari engine produksi seperti PostgreSQL atau MySQL.

Perbedaan kapabilitas ini memicu dua kegagalan fatal:

  • False Positives: Pengujian lolos di CI, tetapi gagal saat rilis produksi karena perbedaan fungsi native (misalnya tipe data JSONB, operator regex, atau ekspresi window functions yang tidak didukung SQLite/H2).
  • False Negatives & Flaky Tests: Inkonsistensi penanganan locking transaksi, isolation levels, serta urutan eksekusi multi-threading antara engine in-memory dan database produksi.

Melakukan mock terhadap interface query builder atau driver database (seperti sqlmock) juga hanya memverifikasi bahwa query dipanggil, bukan membuktikan bahwa sintaks SQL valid dan menghasilkan data yang benar dari skema relasional.

Arsitektur Solusi: Testcontainers

Testcontainers memecahkan masalah perbedaan lingkungan dengan mengotomatisasi lifecycle container Docker langsung dari kode pengujian. Mesin pengujian menjalankan database nyata dengan versi yang identik dengan lingkungan produksi. Komponen Ryuk dari Testcontainers bertugas menghapus container dan network segera setelah proses test runner selesai, mencegah kebocoran resource (zombie containers).

Manajemen Lifecycle: Per-Test vs Shared Container

Pola manajemen container menentukan kecepatan eksekusi seluruh test suite.

1. Per-Test Container (Isolated)

Container baru dibuat untuk setiap method test. Pendekatan ini menjamin isolasi mutlak, namun memiliki overhead tinggi karena inisialisasi Docker engine membutuhkan 2 hingga 6 detik per test case. Pola ini tidak disarankan untuk test suite berskala menengah ke atas.

2. Shared Container per Test Suite (Singleton Pattern)

Satu container diinisialisasi sekali untuk seluruh suite pengujian dan diakses bersama. Keuntungan utamanya adalah efisiensi waktu eksekusi. Konsekuensinya, data hasil eksekusi satu test dapat mencemari test lain jika tidak dibersihkan dengan benar.

3. Container Reuse untuk Pengembangan Lokal

Testcontainers menyediakan fitur reuse untuk pengembang lokal. Dengan mengaktifkan konfigurasi withReuse(true) dan flag environment TESTCONTAINERS_REUSE_ENABLE=true pada berkas ~/.testcontainers.properties, container yang sama tetap menyala antar sesi test runner lokal, memangkas overhead startup hingga mendekati 0 detik.

Strategi Isolasi Data: Truncate vs Transaksi Rollback

Menggunakan rollback transaksi di akhir tiap test sering kali gagal ketika kode aplikasi menggunakan transaksi internal, background worker, atau multiple thread pool. Strategi yang paling reliabel adalah:

  1. Jalankan migrasi skema database (DDL) satu kali saat container pertama kali menyala.
  2. Jalankan pembersihan data (DML) via perintah TRUNCATE pada hook afterEach atau teardown tiap test case.
-- Eksekusi truncate cepat tanpa merusak skema DDL
TRUNCATE TABLE users, orders, order_items RESTART IDENTITY CASCADE;

Perintah RESTART IDENTITY CASCADE mengembalikan nilai auto-increment sequence ke angka awal dan membersihkan tabel relasi turunan secara instan tanpa perlu menjalankan migrasi ulang yang lambat.

Implementasi Kode: PostgreSQL + Testcontainers (TypeScript)

Berikut implementasi uji integrasi repository menggunakan library @testcontainers/postgresql dan driver pg:

import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';

describe('UserRepository Integration Tests', () => {
  let container: StartedPostgreSqlContainer;
  let pool: Pool;

  // Inisialisasi shared container per test suite
  beforeAll(async () => {
    container = await new PostgreSqlContainer('postgres:16-alpine')
      .withDatabase('test_db')
      .withUsername('test_user')
      .withPassword('test_pass')
      .start();

    pool = new Pool({
      connectionString: container.getConnectionUri(),
    });

    // Jalankan migrasi skema satu kali
    await pool.query(`
      CREATE TABLE users (
        id SERIAL PRIMARY KEY,
        email VARCHAR(255) UNIQUE NOT NULL,
        metadata JSONB DEFAULT '{}'::jsonb
      );
    `);
  }, 30000); // Alokasi timeout untuk pull image dan startup Docker

  // Hentikan container dan connection pool setelah seluruh test selesai
  afterAll(async () => {
    if (pool) await pool.end();
    if (container) await container.stop();
  });

  // Isolasi data: Bersihkan data setelah setiap skenario pengujian
  afterEach(async () => {
    await pool.query('TRUNCATE TABLE users RESTART IDENTITY CASCADE;');
  });

  it('harus berhasil menyimpan dan membaca metadata JSONB', async () => {
    const email = '[email protected]';
    const metadata = { role: 'admin', active: true };

    await pool.query(
      'INSERT INTO users (email, metadata) VALUES ($1, $2)',
      [email, JSON.stringify(metadata)]
    );

    // Query native PostgreSQL menggunakan operator JSONB ->>
    const result = await pool.query(
      "SELECT email, metadata->>'role' AS role FROM users WHERE email = $1",
      [email]
    );

    expect(result.rowCount).toBe(1);
    expect(result.rows[0].role).toBe('admin');
  });

  it('harus memicu error duplikasi unique constraint', async () => {
    const email = '[email protected]';

    await pool.query('INSERT INTO users (email) VALUES ($1)', [email]);

    await expect(
      pool.query('INSERT INTO users (email) VALUES ($1)', [email])
    ).rejects.toThrow(/duplicate key value violates unique constraint/);
  });
});

Trade-Off: Waktu Pipeline CI vs Integritas Sistem

Penggunaan Testcontainers membutuhkan trade-off yang jelas antara runtime eksekusi dan tingkat kepercayaan kode:

  • Overhead Cold Start CI: Container engine harus mengunduh image Docker (contoh: postgres:16-alpine berukuran ~150MB). Hal ini menambah durasi pipeline sekitar 15–40 detik pada runner tanpa cache.
  • Kebutuhan Resource: Host runner CI harus mendukung Docker daemon (Docker-in-Docker atau akses ke /var/run/docker.sock) dan memiliki alokasi RAM yang cukup untuk menjalankan database instance bersamaan dengan compiler/test runner.

Strategi Mitigasi Performa di CI:

  1. Layer Caching: Simpan image Docker pada container cache runner CI untuk menghindari pulling berulang dari Docker Hub.
  2. Test Pyramid Separation: Pisahkan unit test murni dari integration test. Jalankan unit test terlebih dahulu sebagai fast feedback gate, lalu jalankan integration test Testcontainers pada stage berikutnya.
Gunakan Testcontainers untuk menguji repository, query relasional kompleks, transaction boundary, dan antrean pesan. Hindari penggunaannya untuk menguji domain logic murni yang tidak membutuhkan I/O data.