Semua test pada chapter-chapter sebelumnya menguji satu function dalam isolasi: masukan masuk, keluaran diperiksa, selesai. Server HTTP bukan satu function — server adalah gabungan routing, parser JSON, penulisan status, dan penyimpanan data yang bekerja bersama, dan kekeliruan paling sering justru bersembunyi di sambungan antar bagian tersebut. Integration test menutup celah itu dengan cara paling jujur: menyalakan server sungguhan, mengirim permintaan HTTP sungguhan lewat fetch, lalu memeriksa responsnya. Pada chapter ini kita membangun pola lengkapnya di atas node:test — server uji pada port ephemeral, hook untuk menyalakan dan mematikan server, serta assertion terhadap status dan body respons.
Dari Unit ke Integration #
Piramida testing pada Chapter Konsep Testing menempatkan integration test di tengah: lebih lambat dari unit test, tetapi membuktikan hal yang tidak bisa dibuktikan unit test — bahwa komponen benar-benar bekerja sama. Tiga sambungan ini adalah contoh tempat bug sering bersembunyi tanpa pernah tersentung unit test murni:
| Sambungan | Kekeliruan yang mungkin terjadi |
|---|---|
| Routing | Handler benar, tetapi tidak pernah dipanggil karena pola URL atau req.method salah dibaca |
| Body parsing | Handler benar, tetapi body JSON tidak pernah dikumpulkan atau di-parse |
| Status dan header | Logika benar, tetapi client menerima kode status keliru tanpa Content-Type yang tepat |
Untuk menguji ketiganya sekaligus, kita menyiapkan API daftar tugas yang sangat kecil namun utuh: satu endpoint GET untuk membaca koleksi, satu endpoint POST untuk menambah tugas, plus penanganan validasi dan 404. Seluruh perilakunya akan diperiksa dari luar sebagaimana client melihatnya.
Server yang Siap Diuji #
Inilah server yang menjadi bahan uji kita:
import http from 'node:http';
function kirimJson(res, kode, data) {
res.writeHead(kode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
export function buatServer() {
const daftarTugas = [];
return http.createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/api/tugas') {
kirimJson(res, 200, daftarTugas);
return;
}
if (req.method === 'POST' && req.url === '/api/tugas') {
let body = '';
for await (const potongan of req) {
body += potongan;
}
const tugas = JSON.parse(body);
if (!tugas.judul) {
kirimJson(res, 400, { pesan: 'judul wajib diisi' });
return;
}
tugas.id = daftarTugas.length + 1;
daftarTugas.push(tugas);
kirimJson(res, 201, tugas);
return;
}
kirimJson(res, 404, { pesan: 'endpoint tidak ditemukan' });
});
}Mari kita bedah bagian-bagian pentingnya:
- Baris 3-6 (
kirimJson): helper kecil yang menulis headerContent-Type: application/jsonlalu mengirim body hasilJSON.stringify. Semua cabang respons memakai helper ini sehingga bentuk respons konsisten di seluruh endpoint. - Baris 8-9 (
buatServerdandaftarTugas): ini adalah factory — setiap pemanggilanbuatServer()mengembalikan instancehttp.Serverbaru dengan arraydaftarTugaskosong miliknya sendiri. State penyimpanan hidup di dalam closure, bukan di variabel global. - Baris 11: callback
createServerditandaiasyncsehingga kita bebas memakaifor awaitdi dalamnya. - Baris 12-15 (GET): mencocokkan method dan URL persis, lalu menjawab
200berisi seluruh koleksi. - Baris 18-21 (mengumpulkan body): object
reqadalah async iterable yang memancarkan potongan byte; perulanganfor awaitmerangkainya menjadi stringbody. - Baris 22-26: body di-parse menjadi object, lalu validasi menolak tugas tanpa
juduldengan status400— cabang error ini penting untuk diuji, bukan hanya jalur sukses. - Baris 27-29: server memberi
idberdasarkan panjang koleksi, menyimpan tugas, lalu menjawab201berisi tugas yang barusan dibuat. - Baris 33: semua permintaan lain jatuh ke respons
404.
Mengapa berbentuk factory, bukan sekadar export default http.createServer(...)? Karena modul yang diekspor langsung akan menyala begitu diimpor — efek samping yang membuat test tidak bisa mengatur kapan server hidup, dan setiap berkas test yang mengimpornya berbagi state yang sama. Factory memberi kendali penuh kepada test: kita yang memutuskan kapan server dibuat dan dalam kondisi apa.
Menyalakan Server di Dalam Test #
Sekarang bagian intinya: berkas test yang menyalakan server sungguhan dan berbicara kepadanya lewat HTTP. Kita memakai hook beforeEach dan afterEach
yang dikenalkan pada Chapter Test Runner Bawaan node:test:
import { test, describe, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import { buatServer } from './server-tugas.mjs';
describe('API tugas', () => {
let server;
let urlDasar;
beforeEach(async () => {
server = buatServer();
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
urlDasar = `http://127.0.0.1:${server.address().port}`;
});
afterEach(() => new Promise((resolve) => server.close(resolve)));
test('GET koleksi kosong', async () => {
const respons = await fetch(`${urlDasar}/api/tugas`);
assert.strictEqual(respons.status, 200);
assert.deepStrictEqual(await respons.json(), []);
});
test('POST menambah tugas', async () => {
const respons = await fetch(`${urlDasar}/api/tugas`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ judul: 'Belajar integration test' }),
});
assert.strictEqual(respons.status, 201);
assert.deepStrictEqual(await respons.json(), { id: 1, judul: 'Belajar integration test' });
});
test('POST tanpa judul ditolak', async () => {
const respons = await fetch(`${urlDasar}/api/tugas`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
assert.strictEqual(respons.status, 400);
assert.deepStrictEqual(await respons.json(), { pesan: 'judul wajib diisi' });
});
test('endpoint tak dikenal 404', async () => {
const respons = await fetch(`${urlDasar}/api/hilang`);
assert.strictEqual(respons.status, 404);
assert.deepStrictEqual(await respons.json(), { pesan: 'endpoint tidak ditemukan' });
});
});Empat keputusan pada berkas ini membentuk pola yang bisa kita pakai ulang di proyek apa pun:
- Baris 9-13 (
beforeEach): setiap test mendapat server segar hasilbuatServer()— koleksi kosong, tanpa sisa data dari test sebelumnya. Prinsip independent dari FIRST tetap terjaga meskipun yang diuji adalah server utuh. - Baris 10 (
listen(0, ...)): port0meminta sistem operasi memilih port yang sedang tidak dipakai — nomornya tidak pernah kita tulis di kode, sehingga dua suite tidak akan pernah bertabrakan port. Host127.0.0.1memastikan lalu lintas uji tidak keluar dari mesin kita. - Baris 10 juga (
await new Promise(...)): promise hanya diselesaikan oleh callbacklisten, jadi baris berikutnya berjalan setelah server sungguh siap menerima koneksi — bukan sedetik sebelumnya. - Baris 15 (
afterEach):server.close()mengembalikan promise lewat callback; hook mengembalikannya agar runner menunggu server benar-benar tertutup sebelum melanjutkan. Tanpa ini, koneksi yang masih terbuka dapat menahan event loop dan membuat proses test menggantung.
Setiap test lalu berperan sebagai client: fetch mengirim permintaan nyata, dan assertion memeriksa respons.status serta body hasil respons.json() — persis seperti janji kita di awal, dari luar sebagaimana client melihatnya. Saatnya menjalankannya:
node --test server-tugas.test.mjsOutput yang Dihasilkan:
▶ API tugas
✔ GET koleksi kosong
✔ POST menambah tugas
✔ POST tanpa judul ditolak
✔ endpoint tak dikenal 404
✔ API tugas
ℹ tests 4
ℹ suites 1
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0Keempat test lulus — koleksi kosong terbaca, POST menyimpan dan mengembalikan tugas ber-id, validasi menolak judul kosong, dan endpoint asing menjawab 404. Sesuai konvensi Section ini, angka durasi pada output telah disaring agar contoh di buku tetap stabil.
Menguji Alur Lintas Endpoint #
Test sebelumnya memeriksa satu permintaan per kasus. Integration test juga tempat yang tepat untuk menguji alur: rangkaian permintaan yang menggambarkan pemakaian nyata. Berkas berikut menambah tugas lewat POST, lalu memastikan tugas itu benar-benar tersimpan dengan membaca ulang lewat GET:
import { test, before, after } from 'node:test';
import assert from 'node:assert';
import { buatServer } from './server-tugas.mjs';
let server;
let urlDasar;
before(async () => {
server = buatServer();
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
urlDasar = `http://127.0.0.1:${server.address().port}`;
});
after(() => new Promise((resolve) => server.close(resolve)));
test('tugas yang ditambah lewat POST terlihat lewat GET', async () => {
const kirim = await fetch(`${urlDasar}/api/tugas`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ judul: 'Menulis dokumentasi' }),
});
assert.strictEqual(kirim.status, 201);
const baca = await fetch(`${urlDasar}/api/tugas`);
assert.strictEqual(baca.status, 200);
assert.deepStrictEqual(await baca.json(), [
{ id: 1, judul: 'Menulis dokumentasi' },
]);
});Dua perbedaan dari berkas pertama perlu diperhatikan:
- Baris 8-14 (
beforedanafter): server dinyalakan sekali untuk seluruh berkas, bukan sekali per test. Hookbeforeberjalan sebelum test pertama danaftersetelah test terakhir — lebih cepat karena biayalistendanclosedibayar satu kali saja. - Baris 16-29: satu test kini memuat dua permintaan berurutan yang saling bergantung —
POSTterlebih dahulu, hasilnya diverifikasi lewatGET. PernyataandeepStrictEqualpada baris 26-28 hanya bisa lulus jika data benar-benar bertahan di memori server di antara kedua permintaan; itulah yang dibuktikan integration test dan tidak bisa dibuktikan unit test per-handler.
node --test alur.test.mjsOutput yang Dihasilkan:
✔ tugas yang ditambah lewat POST terlihat lewat GET
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0Menjalankan Seluruh Suite #
Dua berkas test kita kini lengkap. Seperti biasa, node --test tanpa argumen menemukan keduanya secara otomatis:
node --testOutput yang Dihasilkan:
✔ tugas yang ditambah lewat POST terlihat lewat GET
▶ API tugas
✔ GET koleksi kosong
✔ POST menambah tugas
✔ POST tanpa judul ditolak
✔ endpoint tak dikenal 404
✔ API tugas
ℹ tests 5
ℹ suites 1
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0Lima test dari dua berkas lulus semua: empat kasus terisolasi dari server-tugas.test.mjs, ditambah satu alur lintas endpoint dari alur.test.mjs. Perintah yang sama inilah yang dipasang di pipeline CI — kode keluar bukan nol begitu ada test gagal, tanpa perlu konfigurasi tambahan apa pun karena server uji sudah menjadi bagian dari test itu sendiri, bukan layanan terpisah yang harus dinyalakan lebih dulu.
Ke Mana Setelah Ini #
Kita kini memiliki pola lengkap untuk menguji server HTTP: factory server, port ephemeral, hook hidup-mati, dan assertion dari sudut pandang client. Peralatan node:test telah membawa kita dari test pertama sampai integration test — namun ada dunia runner lain di ekosistem JavaScript yang menawarkan kenyamanan berbeda. Chapter penutup Section ini memperkenalkan Vitest, test runner dengan API mirip Jest yang populer di proyek modern, pada Testing dengan Vitest.