Chapter Debugging Container berakhir pada kesadaran yang tidak nyaman: seluruh kejelian logs, exec, dan inspect tetap datang setelah kegagalan terjadi — kita memburu galat yang sudah berumur dan menambal kode yang sudah terlanjur salah. Test adalah cara bergerak sebelum kegagalan lahir, dan container membuatnya lebih kuat dari yang terlihat: test yang berjalan di dalam image yang sama dengan produksi tidak lagi bisa dielusi dengan kalimat “di mesin saya jalannya baik-baik saja”, karena mesin tempatnya berjalan adalah image itu sendiri. Manfaat kedua menunggu di ujung jalan — pipeline CI kelak menjalankan perintah yang persis sama pada image yang persis sama, sehingga apa pun yang kita yakini di laptop akan tetap benar di server build. Chapter ini membangun dua lapis test untuk katalog-web: unit test lewat docker compose run, lalu integration test dengan PostgreSQL sekali pakai yang hidup di atas tmpfs.

Unit Test Tanpa Meninggalkan Workflow #

Unit test menguji fungsi murni tanpa menyentuh jaringan atau database, dan katalog-web kini punya subjek uji yang layak: Budi mengekstrak fungsi format harga ke modul src/rupiah.js agar bisa diuji terpisah dari server. Berkas test/unit/harga.test.js memakai node --test, test runner bawaan Node.js 22 yang tidak menuntuh satu dependensi eksternal pun:

js
const { test } = require("node:test");
const assert = require("node:assert");
const { formatRupiah } = require("../../src/rupiah");

test("memformat harga ke rupiah penuh", () => {
  assert.equal(formatRupiah(85000), "Rp85.000");
});

test("menolak harga negatif", () => {
  assert.throws(() => formatRupiah(-1000), /harga tidak boleh negatif/);
});

Skrip npm test sudah tertanam pada manifest sejak Chapter Development dan Production Image, dan menjalankannya dalam container hanya menuntut satu perintah yang kita kenal dari Chapter Menjalankan dan Mengelola Stack:

bash
$ docker compose run --rm web npm test

Output yang Dihasilkan:

text
> [email protected] test
> node --test test/unit/

▶ test/unit/harga.test.js (2)
  ✔ memformat harga ke rupiah penuh
  ✔ menolak harga negatif
└─ pass 2 - fail 0

ℹ tests 2
ℹ suites 1
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 387

Mari kita telaah mengapa run — bukan exec — adalah kendaraan yang tepat untuk test:

  1. run menciptakan container baru dari cetakbiru service web. Environment dari env_file dan environment, network project, bind mount source code, serta volume node_modules dari Chapter Development Dependencies semuanya diterapkan — test melihat kode dan dependensi persis seperti yang dilihat aplikasi yang sedang berjalan.
  2. exec akan menempel ke container hidup yang sama. Keadaan container lama belum tentu mencerminkan hasil build terbaru, dan proses test yang berat mengganggu aplikasi yang sedang melayani permintaan; run menjaga keduanya bersih.
  3. Port host tidak diterbitkan pada run. Perilaku yang kita catat sebagai pengecualian penting pada Chapter Menjalankan dan Mengelola Stack justru menguntungkan di sini — test tidak memerlukan pintu host, dan dua sesi test paralel tidak akan saling merebut port.
  4. --rm membuang bekasnya seketika. Tanpa flag ini, container katalog-web-run-... berstatus Exited menumpuk di docker compose ps -a.
  5. --no-deps tersedia bila stack sedang mati. Secara bawaan run juga menyalakan dependensi service — db akan dibangunkan dan menunggu gerbang service_healthy lebih dulu; untuk unit test yang tidak menyentuh database, docker compose run --rm --no-deps web npm test melewati seluruh upacara itu.
Aspekdocker compose rundocker compose exec
Container yang dipakaibaru, diciptakan dari cetakbiru serviceyang sedang berjalan
Isi yang terlihathasil build dan suntingan termutakhirkeadaan container lama
Port hosttidak diterbitkanmilik container yang hidup
Cocok untuktugas sekali jalan, test, migrasiintip cepat pada container hidup

Exit Code sebagai Kontrak #

Sesi test yang selalu hijau belum membuktikan apa-apa sampai kita menyaksikan kegagalannya bekerja. Budi merefactor formatRupiah agar memakai toLocaleString, dan satu ekspektasi tidak lagi terpenuhi:

bash
$ docker compose run --rm web npm test

Output yang Dihasilkan (dipotong):

text
> [email protected] test
> node --test test/unit/

▶ test/unit/harga.test.js (2)
  ✔ menolak harga negatif
  ✖ memformat harga ke rupiah penuh
    ---
    error: 'Rp85.00' == 'Rp85.000'
    operator: '=='
    expected: 'Rp85.000'
    actual: 'Rp85.00'
    ...
  └─ fail 1 - pass 1

ℹ tests 2
ℹ suites 1
ℹ pass 1
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 419

Perintah berikutnya hanya satu karakter, tetapi itulah seluruh kontraknya:

bash
$ echo $?

Output yang Dihasilkan:

text
1

Mari kita telaah rantai tanggung jawab di balik angka 1 tersebut:

  1. node --test keluar dengan kode 1 karena ada test gagal. Test runner bertindak sebagai wasit yang menerjemahkan “dua ekspektasi, satu pecah” menjadi satu angka.
  2. npm test meneruskan kode itu. npm tidak menelan kegagalan skrip — kode keluar skrip menjadi kode keluar npm.
  3. docker compose run meneruskannya ke shell host. CLI menunggu container selesai lalu menutup dirinya dengan kode yang sama, persis perilaku docker run mode terpasang yang kita bedah pada Chapter Memahami Exit Code — $? di host membaca kegagalan yang lahir di dalam container.
  4. Inilah titik tangan mesin mengambil alih. Pipeline CI kelak tidak membaca tulisan merah di layar; ia membaca angka ini dan memutuskan build lulus atau gugur — materi Section CI/CD dan Automation.

Integration Test dengan Database Sekali Pakai #

Integration test menguji hal yang tidak bisa dijawab unit test: apakah aplikasi sungguhan sanggup bicara dengan PostgreSQL sungguhan. Tuntutannya ada tiga — database yang hidup, data yang selalu mulai bersih, dan kecepatan yang membuatnya layak dijalankan berkali-kali sehari. Compose menjawab ketiganya lewat satu profile bernama test dengan dua service tambahan pada compose.yaml:

yaml
  db-test:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: katalog
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: katalog
    tmpfs:
      - /var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U katalog -d katalog"]
      interval: 2s
      timeout: 3s
      retries: 15
    profiles: ["test"]

  test:
    build:
      context: .
      target: dev
    image: katalog-web:dev
    environment:
      DATABASE_URL: postgres://katalog:${POSTGRES_PASSWORD}@db-test:5432/katalog
    depends_on:
      db-test:
        condition: service_healthy
    command: npm run test:integration
    profiles: ["test"]

Manifest menambahkan satu skrip, dan folder test/integration/ mendapat penghuninya — test yang membuat skemanya sendiri sebelum menguji:

json
{
  "scripts": {
    "dev": "nodemon src/server.js",
    "test": "node --test test/unit/",
    "test:integration": "node --test test/integration/"
  }
}
js
const { test, before, after } = require("node:test");
const assert = require("node:assert");
const { Pool } = require("pg");

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

before(async () => {
  await pool.query(`CREATE TABLE IF NOT EXISTS produk (
    id SERIAL PRIMARY KEY, nama TEXT NOT NULL, harga INTEGER NOT NULL)`);
  await pool.query("TRUNCATE produk RESTART IDENTITY");
  await pool.query("INSERT INTO produk (nama, harga) VALUES ('Kopi Arabika Gayo', 85000)");
  console.log("migrasi uji: tabel produk siap di db-test");
});

test("daftar produk terbaca dari database", async () => {
  const hasil = await pool.query("SELECT nama, harga FROM produk ORDER BY id");
  assert.equal(hasil.rows.length, 1);
  assert.equal(hasil.rows[0].nama, "Kopi Arabika Gayo");
  assert.equal(hasil.rows[0].harga, 85000);
});

after(async () => {
  await pool.end();
});

Sesi integration test dinyalakan dengan satu perintah yang menyebut profilnya secara eksplisit:

bash
$ docker compose --profile test run --rm test

Output yang Dihasilkan:

text
[+] Running 1/1
 ✔ Container katalog-db-test-1  Healthy
> [email protected] test:integration
> node --test test/integration/

ℹ migrasi uji: tabel produk siap di db-test
▶ test/integration/produk.test.js (1)
  ✔ daftar produk terbaca dari database
└─ pass 1 - fail 0

ℹ tests 1
ℹ suites 1
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1846

Mari kita telaah lima keputusan yang membuat rangkaian ini bekerja:

  1. tmpfs pada /var/lib/postgresql/data menghapus disk dari pemandangan. Cluster database uji hidup sepenuhnya di memori — tanpa volume, tanpa tulis disk, tanpa satu megabita pun yang menetap setelah sesi berakhir; inilah use case cache dan data fana yang kita petakan pada Chapter tmpfs Mount, kini dipakai untuk kecepatan murni: initdb di atas RAM selesai dalam hitungan detik.
  2. Reset antar sesi dikerjakan oleh siklus hidup container, bukan skrip pembersih. run menciptakan container test baru setiap dipanggil, dan begitu db-test dihentikan lalu dibuat ulang — atau dijalankan ulang setelah down — tmpfs-nya terlahir kosong kembali sehingga entry point postgres menjalankan initdb dari awal. Database uji selalu mulai dari nol tanpa satu perintah DELETE pun.
  3. Healthcheck sengaja lebih rapat dari kebiasaan stack utama. interval: 2s dengan retries: 15 memberi gerbang yang tanggap — probe pg_isready memeriksa setiap dua detik dengan anggaran kesabaran 30 detik untuk mesin lambat — pola yang kita bangun pada Chapter Healthcheck dan Service Readiness, dikencangkan karena sesi uji tidak boleh menghangur menunggu.
  4. DATABASE_URL menunjuk db-test, bukan db. Nama service yang berbeda membuat jalur uji sepenuhnya terpisah dari jalur development; volume katalog_db-data tempat data kerja kita tidak pernah tersentuh oleh TRUNCATE milik berkas uji.
  5. profiles: ["test"] menjaga keduanya tidur pada hari biasa. docker compose up -d tidak pernah menyalakan pasangan ini; kebetulan menyebut nama service test secara langsung pun mengaktifkan profilnya otomatis — perilaku yang kita buktikan pada Chapter Profiles — sehingga perintah eksplisit di atas sebenarnya boleh dipersingkat menjadi docker compose run --rm test.

Sebuah pembedaan perlu ditegaskan agar dua wajah pengujian tidak tertukar. Stage test pada Dockerfile dari Chapter Development dan Production Image menjalankan unit test saat build sebagai gerbang kelahiran image produksi; service test pada compose menjalankan integration test sebagai container hidup dengan database sungguhan. Keduanya saling melengkapi: gerbang build menolak image yang cacat, sesi integration menolak asumsi yang cacat. Satu kebiasaan terakhir dari pola run yang perlu dibawa: perintah ini tidak membangun ulang image — bila berkas test di host lebih baru daripada image katalog-web:dev, jalankan docker compose build test lebih dulu, kebiasaan --build yang kita paku sejak Chapter Image, Build, dan Pull.

Sesi uji ditutup dengan membenahi korselnya: docker compose rm -sf db-test menghentikan dan membuang container database uji tanpa menyentuh stack utama, dan karena tmpfs tidak meninggalkan volume, tidak ada satu objek data pun yang perlu dibersihkan sesudahnya.

Peta Jenis Test dan Wadahnya #

Dua lapis yang kita bangun hari ini menempati dua baris pertama dari peta yang lebih besar:

Jenis testYang dibuktikanKebutuhan containerPerintah khas
Unit testfungsi murni berperilaku benarimage dev saja, tanpa service laindocker compose run --rm web npm test
Integration testaplikasi dan database bekerjasamaimage dev + db-test di atas tmpfsdocker compose --profile test run --rm test
End-to-endseluruh stack melayani dari pintu proxystack penuh + skenario HTTP dari luarcurl terhadap stack staging — Chapter Mengelola Environment Development

Baris ketiga sengaja ditinggalkan sebagai jembatan: end-to-end test membutuhkan environment yang menyerupai produksi — image dari registry, port 80, data yang bukan milik siapa pun — dan environment seperti itu harus dikelola dengan kesengajaan tersendiri. Manfaat terbesarnya baru terasa ketika laptop Budi, laptop Siti, dan mesin CI menjalankan kombinasi yang berbeda dari perangkat yang sama: profile yang berbeda, berkas lokal yang berbeda, port yang berbeda, tag image yang berbeda. Merapikan seluruh kombinasi itu menjadi peta yang bisa dibaca seluruh tim adalah pekerjaan chapter penutup section ini.

Referensi #