Ringkasan Utama
Testcontainers menjalankan Postgres asli sekali pakai di Docker untuk setiap test run NestJS, sehingga integration test menguji SQL, migrasi, dan constraint database yang sebenarnya, bukan mock. Anda mendapat port acak yang dipetakan, menerapkan migrasi asli di hook beforeAll Jest, menjalankan TestingModule NestJS terhadapnya, lalu menghancurkan container itu.
Sebagian besar test suite NestJS yang saya warisi melakukan mock pada lapisan repository. Sebuah jest.fn mengembalikan user palsu, service melakukan tugasnya, assertion lolos, semua senang. Lalu produksi melempar pelanggaran unique-constraint yang tidak pernah dimodelkan mock, atau query yang merujuk kolom yang sudah di-rename sebuah migrasi berbulan lalu. Test berwarna hijau karena mereka menguji mock saya, bukan database saya.
Testcontainers memperbaikinya dengan membuat database asli murah untuk dijalankan dan murah untuk dibuang. Ia adalah library open source yang menyediakan instance database, message broker, atau apa pun yang bisa berjalan di container Docker secara ringan dan sekali pakai. Alih-alih memelihara database test bersama atau pengganti in-memory yang berperilaku berbeda dari Postgres, setiap run menyalakan Postgres-nya sendiri, menjalankan migrasi asli, dan membuangnya saat test selesai.
Kenapa mock database menyembunyikan bug nyata
Repository yang di-mock hanya bisa gagal dengan cara yang Anda perintahkan. Ia tidak bisa menegakkan kolom NOT NULL, meng-cascade sebuah delete, menghormati indeks UNIQUE, atau menolak nilai yang melanggar constraint CHECK, karena semua logika itu tidak ada di TypeScript Anda. Logika itu ada di mesin database. Jadi seluruh kelas bug yang akan ditangkap Postgres saat runtime lolos begitu saja dari suite yang di-mock.
Titik buta yang sama mencakup kebenaran SQL. Query yang ditulis tangan, fragmen raw, join yang halus, atau migrasi yang diam-diam menghapus indeks yang salah tidak akan pernah muncul terhadap mock yang mengembalikan apa pun yang Anda hardcode. Terhadap Postgres asli, bug itu gagal dengan keras dan langsung, tepat di tempat yang Anda inginkan: di test run, bukan di channel insiden pukul dua pagi.
Repository yang di-mock vs Testcontainers
Kedua pendekatan punya tempatnya. Mock tetap alat yang tepat untuk unit test murni pada logika bisnis yang tidak berhubungan dengan persistensi. Tapi begitu sebuah test menyatakan apa pun tentang bagaimana data disimpan, di-query, atau dibatasi, mock mengukur hal yang salah. Beginilah cara saya menimbang keduanya untuk lapisan persistensi.
| Aspek | Repository yang di-mock | Testcontainers + Postgres asli |
|---|---|---|
| Kebenaran SQL | Tidak pernah dieksekusi, query salah lolos | Menjalankan SQL asli pada mesin asli |
| Constraint dan migrasi | Tidak ditegakkan sama sekali | UNIQUE, NOT NULL, dan FK semua aktif |
| Kecepatan startup | Instan, tidak perlu Docker | Beberapa detik per run untuk pull dan boot |
| Ketergantungan lingkungan | Tidak ada | Butuh daemon Docker lokal dan di CI |
| Keyakinan pada lapisan data | Rendah, hanya menguji mock | Tinggi, sesuai perilaku produksi |
Satu Postgres sekali pakai per test run
Polanya kecil. Di hook beforeAll Jest, jalankan PostgreSqlContainer dari paket @testcontainers/postgresql, baca detail koneksinya, arahkan konfigurasi NestJS ke sana, bangun TestingModule, dan jalankan migrasi. Testcontainers mengikat port acak yang tersedia di host, sehingga run paralel tidak pernah bentrok. getConnectionUri mengembalikan connection string siap pakai; getHost, getPort, getDatabase, getUsername, dan getPassword mengekspos bagian-bagiannya secara terpisah bila Anda memerlukannya.
import { Test, TestingModule } from "@nestjs/testing";
import {
PostgreSqlContainer,
StartedPostgreSqlContainer,
} from "@testcontainers/postgresql";
import { DataSource } from "typeorm";
import { AppModule } from "../src/app.module";
import { UsersService } from "../src/users/users.service";
describe("UsersService (integration)", () => {
let container: StartedPostgreSqlContainer;
let moduleRef: TestingModule;
let users: UsersService;
beforeAll(async () => {
// Spin up a real, throwaway Postgres for this run.
container = await new PostgreSqlContainer("postgres:16-alpine").start();
// Point the app's config at the random host port Testcontainers mapped.
process.env.DATABASE_URL = container.getConnectionUri();
moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
// Apply the same migrations production runs, before any test executes.
const dataSource = moduleRef.get(DataSource);
await dataSource.runMigrations();
users = moduleRef.get(UsersService);
}, 60_000); // pulling the image on a cold cache can take a while
afterAll(async () => {
await moduleRef?.close();
await container?.stop();
});
it("rejects a duplicate email at the database level", async () => {
await users.create({ email: "a@example.com" });
await expect(
users.create({ email: "a@example.com" }),
).rejects.toThrow(); // fires the real UNIQUE constraint, not a mock
});
});Beri hook beforeAll timeout yang longgar. Run pertama di sebuah mesin harus mem-pull image postgres sebelum bisa boot, yang mudah melampaui timeout default Jest 5 detik. Memberikan timeout per-hook seperti 60000 milidetik menghindari kegagalan membingungkan yang tampak seperti bug di kode Anda padahal hanya pull image yang lambat.
Menjalankan migrasi sebelum test
Container baru dimulai dengan database kosong, jadi migrasi bukan opsional, melainkan langkah setup. Resolve DataSource Anda dari TestingModule dan panggil runMigrations sebelum test apa pun dieksekusi. Ini membuktikan dua hal sekaligus: bahwa skema Anda terpasang bersih dari nol, dan bahwa query Anda berjalan pada bentuk yang persis sama dengan produksi. Migrasi yang gagal terpasang menggagalkan seluruh suite, dan itu tempat yang benar untuk mengetahuinya.
Di antara test Anda punya pilihan: truncate tabel, bungkus tiap test dalam transaksi yang di-rollback, atau terima state yang menumpuk. Saya truncate di beforeEach untuk sebagian besar suite karena sederhana dan bisa diprediksi. Rollback transaksi lebih cepat tapi memaksa setiap test berbagi satu koneksi, yang rusak begitu kode Anda membuka transaksinya sendiri. Pilih isolasi dulu, kepintaran belakangan.
Jangan arahkan Testcontainers ke database yang Anda pedulikan. Container itu memang dirancang sekali pakai, dan seed atau fixture apa pun yang Anda muat hanya hidup untuk run itu. Jangan pernah memakai ulang connection string staging asli sebagai jalan pintas, dan pastikan konfigurasi test Anda tidak bisa tanpa sengaja mengarah ke lingkungan bersama, atau langkah truncate akan dengan senang hati menghapusnya.
Menambahkan Redis dan service lain
Mekanisme yang sama menskala melampaui Postgres. Testcontainers menyediakan modul untuk banyak service, dan apa pun tanpa modul bisa berjalan lewat kelas GenericContainer dengan image eksplisit, port yang diekspos, dan strategi wait. Suite integration tipikal untuk aplikasi NestJS yang memakai cache dan queue mungkin menjalankan:
- Container Postgres lewat PostgreSqlContainer untuk penyimpanan data utama dan migrasi.
- Container Redis untuk perilaku cache dan rate-limit yang memang ingin Anda uji.
- Dependensi lain lewat GenericContainer dengan withExposedPorts dan getMappedPort untuk menjangkaunya.
- Satu file setup bersama agar setiap spec menyalakan stack yang sama sekali, bukan per file.
Trade-off kecepatan CI: Docker-in-CI dan reuse
Satu-satunya biaya nyata adalah Docker. Runner CI Anda butuh daemon Docker yang berfungsi, dan setiap run dingin membayar biaya pull image dan boot container. Dalam praktiknya Anda meng-cache image dasar, menyalakan container sekali per job bukan per file test, dan menjalankan spec secara paralel agar biaya startup tetap teramortisasi ke seluruh suite. Ini terukur lebih lambat daripada run mock saja, dan sepadan demi bug yang ditangkapnya sebelum rilis.
Secara lokal ada jalan pintas. Testcontainers mendukung reuse container: aktifkan dan ia tidak akan menjalankan container baru jika container terkelola dengan konfigurasi yang sama sudah berjalan, menjaga satu container tetap hangat antar-run agar loop edit-test Anda tetap cepat. Reuse dikendalikan oleh withReuse dan variabel lingkungan TESTCONTAINERS_REUSE_ENABLE. Biarkan aktif untuk pengembangan lokal dan nonaktif di CI, tempat container baru yang terisolasi setiap kali justru menjadi intinya.

