Integration Testing NestJS dengan Testcontainers dan Postgres

Foto oleh Ian Taylor on Unsplash
Repository yang di-mock hanya gagal dengan cara yang Anda perintahkan. Ia tidak bisa menegakkan constraint UNIQUE, NOT NULL, atau foreign key, menjalankan SQL asli, atau menerapkan migrasi, karena logika itu ada di Postgres, bukan TypeScript. Testcontainers menjalankan Postgres asli sekali pakai sehingga integration test menangkap bug SQL dan constraint yang diloloskan mock secara diam-diam.
Impor PostgreSqlContainer dari paket testcontainers postgresql, panggil start() pada PostgreSqlContainer baru di dalam beforeAll, dan baca getConnectionUri() untuk mengarahkan konfigurasi NestJS ke port acak yang dipetakan. Beri hook timeout lebih panjang, misalnya 60000 md, karena run pertama harus mem-pull image sebelum bisa boot.
Container baru dimulai kosong, jadi migrasi adalah langkah setup, bukan opsional. Bangun TestingModule NestJS, resolve DataSource dengan moduleRef.get, dan panggil runMigrations() di beforeAll sebelum test apa pun berjalan. Ini membuktikan skema terpasang bersih dari nol dan query berjalan pada bentuk yang persis sama dengan produksi.
Ya, ia butuh daemon Docker yang berfungsi dan membayar biaya dingin untuk pull image serta boot container. Cache image dasar, nyalakan container sekali per job bukan per file test, dan jalankan spec secara paralel agar biaya startup teramortisasi. Ini lebih lambat daripada run mock saja tapi menangkap bug database nyata sebelum rilis.
Reuse menjaga container tetap hangat antar-run: ia tidak akan menjalankan container baru jika container terkelola dengan konfigurasi yang sama sudah berjalan. Ini dikendalikan oleh withReuse dan variabel lingkungan TESTCONTAINERS_REUSE_ENABLE. Biarkan aktif secara lokal untuk loop edit-test yang cepat, dan nonaktif di CI tempat container baru yang terisolasi setiap kali menjadi intinya.

Foto oleh Ian Taylor on Unsplash
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.
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.
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 |
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: "[email protected]" });
await expect(
users.create({ email: "[email protected]" }),
).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.
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.
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:
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.