Tiga bagian sebelumnya menghasilkan API yang tampak benar; bagian ini membuktikannya secara berulang. Kita memakai test runner bawaan node:test untuk mengunci perilaku proyek pada tiga lapis: unit test untuk fungsi murni validasi dan penyimpanan memori, unit test untuk penyimpanan JSON dengan folder sementara, dan integration test yang menyalakan server sungguhan lalu berbicara kepadanya lewat fetch — persis pola pada Section Testing. Bagian ini ditutup dengan perapihan titik masuk produksi: pembacaan port dari environment, validasi nilainya, dan penutupan server yang rapi saat proses dihentikan.

Unit Test untuk Domain Tugas #

Fungsi validasiTugas dan buatTugasMemori adalah fungsi biasa dengan masukan-keluaran yang jelas, jadi mereka paling murah diuji. Berkas tests/tugas.test.js menguji aturan-aturan validasi satu per satu, termasuk kasus batasnya:

js tests/tugas.test.js
import { describe, test } from 'node:test';
import assert from 'node:assert';
import { STATUS_VALID, buatTugasMemori, validasiTugas } from '../src/tugas.js';

describe('validasiTugas', () => {
  test('input valid tidak menghasilkan galat', () => {
    assert.deepStrictEqual(validasiTugas({ judul: 'Belajar validasi' }), []);
    assert.deepStrictEqual(validasiTugas({ judul: 'Belajar validasi', status: 'done' }), []);
  });

  test('judul wajib diisi saat pembuatan tugas', () => {
    assert.deepStrictEqual(validasiTugas({}), [
      { field: 'judul', pesan: 'judul wajib diisi' },
    ]);
  });

  test('judul dipangkas sebelum panjangnya dihitung', () => {
    const galat = validasiTugas({ judul: '  ab  ' });
    assert.strictEqual(galat.length, 1);
    assert.strictEqual(galat[0].field, 'judul');
  });

  test('judul yang bukan string ditolak', () => {
    const galat = validasiTugas({ judul: 42 });
    assert.strictEqual(galat.length, 1);
    assert.strictEqual(galat[0].field, 'judul');
  });
  1. Baris 1-3 (impor): describe dan test dari node:test, assert dari node:assert, lalu tiga simbol yang mau diuji dari src/tugas.js.
  2. Baris 7-8 (assertion ketat): deepStrictEqual membandingkan struktur secara dalam — array kosong berarti tidak ada galat. Test pertama sengaja menguji jalur bahagia sebelum jalur galat.
  3. Baris 11-15 (bentuk hasil yang pasti): galat divalidasi sampai isinya, bukan hanya jumlahnya — begitu pesan berubah, test langsung menangkap dan kita sadari telah mengubah kontrak dengan client.

Test Penyimpanan dengan Folder Sementara #

Penyimpanan JSON menyentuh berkas, jadi test-nya butuh folder kerja yang bersih setiap kali berjalan. Fungsi mkdtemp membuat folder unik di direktori sementara sistem, dan afterEach membuangnya lagi — tidak ada sisa di folder proyek:

js tests/penyimpanan.test.js
import { afterEach, beforeEach, describe, test } from 'node:test';
import assert from 'node:assert';
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buatPenyimpananJson } from '../src/penyimpanan.js';

describe('buatPenyimpananJson', () => {
  let folder;

  beforeEach(async () => {
    folder = await mkdtemp(join(tmpdir(), 'tugas-uji-'));
  });

  afterEach(async () => {
    await rm(folder, { recursive: true, force: true });
  });

  test('daftar kosong saat berkas data belum ada', async () => {
    const penyimpanan = buatPenyimpananJson(join(folder, 'tugas.json'));
    assert.deepStrictEqual(await penyimpanan.daftar(), []);
  });

  test('tambah menyimpan tugas ke berkas JSON', async () => {
    const berkas = join(folder, 'tugas.json');
    const penyimpanan = buatPenyimpananJson(berkas);
    const tugas = await penyimpanan.tambah({ judul: 'Belajar uji penyimpanan', status: 'todo' });
    assert.deepStrictEqual(tugas, { id: 1, judul: 'Belajar uji penyimpanan', status: 'todo' });
    assert.deepStrictEqual(JSON.parse(await readFile(berkas, 'utf8')), [tugas]);
  });

  test('id baru selalu di atas id terbesar yang ada di berkas', async () => {
    const berkas = join(folder, 'tugas.json');
    await writeFile(berkas, JSON.stringify([
      { id: 1, judul: 'Tugas satu', status: 'todo' },
      { id: 3, judul: 'Tugas tiga', status: 'done' },
    ]));
    const penyimpanan = buatPenyimpananJson(berkas);
    const tugas = await penyimpanan.tambah({ judul: 'Tugas empat', status: 'todo' });
    assert.strictEqual(tugas.id, 4);
  });
  1. Baris 11-17 (hook): setiap test mendapat folder segar; beforeEach membuatnya, afterEach menghapusnya dengan recursive dan force.
  2. Baris 24-30 (membaca berkas langsung): test tidak hanya percaya kata tambah — berkas dibaca kembali, diurai sebagai JSON, lalu dibandingkan. Klaim “tersimpan ke berkas” dibuktikan dari isi berkasnya sendiri.
  3. Baris 32-41 (id deterministik): berkas dipersiapkan berisi id [1, 3], lalu tambah harus menghasilkan id 4. Inilah bukti aturan id-dari-terbesar pada Bagian 3 berlaku sungguhan, bukan sekadar kebetulan urutan.

Integration Test dengan Server Nyata #

Integration test menguji seluruh rantai sekaligus: routing, parsing body, status code, header, hingga penyimpanan. Server dinyalakan pada port ephemeral lewat listen(0) dan ditutup di afterEach , sehingga setiap test dimulai dari kondisi bersih:

js tests/integrasi.test.js
describe('API tugas (penyimpanan di memori)', () => {
  let server;
  let base;

  beforeEach(async () => {
    server = buatServer({ penyimpanan: buatTugasMemori() });
    await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
    base = `http://127.0.0.1:${server.address().port}`;
  });

  afterEach(async () => {
    server.closeAllConnections();
    await new Promise((resolve) => server.close(resolve));
  });

  test('koleksi awal kosong dengan tipe konten JSON', async () => {
    const respons = await fetch(`${base}/tugas`);
    assert.strictEqual(respons.status, 200);
    assert.strictEqual(respons.headers.get('content-type'), 'application/json');
    assert.deepStrictEqual(await respons.json(), []);
  });

  test('POST membuat tugas lalu GET menampilkannya', async () => {
    const buat = await fetch(`${base}/tugas`, {
      method: 'POST',
      headers,
      body: JSON.stringify({ judul: 'Belajar integration test' }),
    });
    assert.strictEqual(buat.status, 201);
    assert.strictEqual(buat.headers.get('location'), '/tugas/1');
    assert.deepStrictEqual(await buat.json(), {
      id: 1,
      judul: 'Belajar integration test',
      status: 'todo',
    });

    const baca = await fetch(`${base}/tugas`);
    assert.strictEqual(baca.status, 200);
    assert.deepStrictEqual(await baca.json(), [
      { id: 1, judul: 'Belajar integration test', status: 'todo' },
    ]);
  });
  1. Baris 15-19 (beforeEach): server baru dibuat dari pabrik Bagian 1 dengan penyimpanan memori segar. Port 0 meminta sistem operasi memilih port yang sedang bebas — dua suite tidak akan pernah bertabrakan, dan nomor portnya tidak pernah kita tulis di output.
  2. Baris 21-24 (afterEach): closeAllConnections() memutus koneksi keep-alive yang masih terbuka supaya promise close cepat selesai dan proses test tidak menggantung.
  3. Baris 26-31 (kontrak dari luar): test memeriksa Content-Type dan isi body — dua hal yang hanya terbukti dari sisi client.
  4. Baris 33-52 (alur lintas endpoint): POST diverifikasi lewat GET berikutnya; deepStrictEqual hanya lulus bila data sungguh bertahan di antara dua permintaan.

Satu test lagi mengulang skenario utama Bagian 3 pada level HTTP — dua server berbagi satu berkas data:

js tests/integrasi.test.js
describe('API tugas (penyimpanan JSON)', () => {
  let folder;

  afterEach(async () => {
    await rm(folder, { recursive: true, force: true });
  });

  test('data bertahan melewati hidup-mati server', async () => {
    folder = await mkdtemp(join(tmpdir(), 'tugas-api-'));
    const berkas = join(folder, 'tugas.json');

    const serverPertama = buatServer({ berkasData: berkas });
    await new Promise((resolve) => serverPertama.listen(0, '127.0.0.1', resolve));
    const basePertama = `http://127.0.0.1:${serverPertama.address().port}`;

    const buat = await fetch(`${basePertama}/tugas`, {
      method: 'POST',
      headers,
      body: JSON.stringify({ judul: 'Tugas yang bertahan', status: 'doing' }),
    });
    assert.strictEqual(buat.status, 201);

    serverPertama.closeAllConnections();
    await new Promise((resolve) => serverPertama.close(resolve));

    const serverKedua = buatServer({ berkasData: berkas });
    await new Promise((resolve) => serverKedua.listen(0, '127.0.0.1', resolve));
    const baseKedua = `http://127.0.0.1:${serverKedua.address().port}`;

    const baca = await fetch(`${baseKedua}/tugas`);
    assert.strictEqual(baca.status, 200);
    assert.deepStrictEqual(await baca.json(), [
      { id: 1, judul: 'Tugas yang bertahan', status: 'doing' },
    ]);

    serverKedua.closeAllConnections();
    await new Promise((resolve) => serverKedua.close(resolve));
  });
});

Test ini memakai opsi.berkasData (baris 198) untuk mengarahkan penyimpanan JSON ke folder sementara, mematikan server pertama, lalu menyalakan server kedua pada proses yang sama dengan berkas yang sama (baris 212). deepStrictEqual pada baris 218-220 hanya lulus bila data benar-benar bertahan melewati hidup-mati server.

Menjalankan Seluruh Suite #

Node menjalankan semua berkas *.test.js di bawah folder proyek tanpa argumen tambahan:

bash
node --test

Output yang Dihasilkan:

text
▶ API tugas (penyimpanan di memori)
  ✔ koleksi awal kosong dengan tipe konten JSON
  ✔ POST membuat tugas lalu GET menampilkannya
  ✔ query status menyaring koleksi
  ✔ PUT mengganti seluruh isi tugas
  ✔ PATCH mengubah sebagian field saja
  ✔ PATCH tanpa field yang dikenal ditolak 422
  ✔ DELETE menghapus tugas lalu resource tiada
  ✔ judul terlalu pendek dijawab 422 beserta detailnya
  ✔ body yang bukan JSON dijawab 400
  ✔ metode asing pada endpoint dikenal dijawab 405
  ✔ endpoint asing dijawab 404
  ✔ kesalahan tak terduga dijawab 500
✔ API tugas (penyimpanan di memori)
▶ API tugas (penyimpanan JSON)
  ✔ data bertahan melewati hidup-mati server
✔ API tugas (penyimpanan JSON)
▶ buatPenyimpananJson
  ✔ daftar kosong saat berkas data belum ada
  ✔ tambah menyimpan tugas ke berkas JSON
  ✔ id baru selalu di atas id terbesar yang ada di berkas
  ✔ ubah dan hapus menulis ulang berkas
  ✔ instance baru membaca data yang ditulis instance lama
  ✔ penulisan atomik tidak meninggalkan berkas sementara
✔ buatPenyimpananJson
▶ validasiTugas
  ✔ input valid tidak menghasilkan galat
  ✔ judul wajib diisi saat pembuatan tugas
  ✔ judul dipangkas sebelum panjangnya dihitung
  ✔ judul yang bukan string ditolak
  ✔ status harus salah satu nilai yang dikenal
  ✔ body yang bukan object JSON ditolak
  ✔ mode sebagian meminta minimal satu field
✔ validasiTugas
▶ buatTugasMemori
  ✔ tambah memberi id berurutan dari data awal
  ✔ id yang sudah dihapus tidak dipakai ulang
  ✔ ubah menggabungkan field yang dikirim
  ✔ cari dan hapus pada id yang tidak ada mengembalikan undefined
  ✔ salinan dikembalikan sehingga data internal tidak bocor
✔ buatTugasMemori
ℹ tests 31
ℹ suites 5
ℹ pass 31
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Tiga puluh satu test lulus dalam lima suite: validasi, penyimpanan memori, penyimpanan berkas, integration pada memori, dan integration pada berkas JSON. Seperti konvensi Course, angka durasi pada contoh ini disaring agar tayangan buku tetap stabil — di terminal Anda angka itu tetap tampil.

Titik Masuk Produksi dan Perapihan #

Pabrik server dari Bagian 1 akhirnya dipakai oleh src/cli.js — satu berkas kecil yang memutuskan di mana server mendengarkan dan bagaimana ia berhenti:

js src/cli.js
// Titik masuk produksi: nyalakan server pada port dari environment variable.

import { buatServer } from './server.js';

const port = Number(process.env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
  console.error(`PORT tidak valid: ${process.env.PORT}`);
  process.exit(1);
}

const server = buatServer();

server.listen(port, '127.0.0.1', () => {
  console.log(`REST API manajemen tugas siap di http://127.0.0.1:${port}`);
});

function matikan() {
  console.log('Menutup server...');
  server.closeAllConnections();
  server.close(() => process.exit(0));
}

process.on('SIGINT', matikan);
process.on('SIGTERM', matikan);
  1. Baris 5-9 (port dari environment): variabel PORT dibaca dengan nilai bawaan 3000, lalu divalidasi — nilai bukan angka atau di luar rentang 1-65535 menolak menyala dengan kode keluar 1, bukan mendengarkan di port yang tidak jelas.
  2. Baris 13-15 (menyala): berbeda dari test yang memakai port 0, produksi mendengarkan pada port tetap lalu mencetak alamatnya sekali.
  3. Baris 17-24 (berhenti dengan rapi): sinyal SIGINT (Ctrl+C) dan SIGTERM menutup seluruh koneksi terbuka lebih dulu, menunggu close selesai, baru keluar dengan kode 0 — pola graceful shutdown dari Section Deployment dan Operations.

Validasi port bisa dilihat langsung dari terminal:

bash
PORT=abc node src/cli.js

Output yang Dihasilkan:

text
PORT tidak valid: abc
kode keluar: 1

Proyek ini kini lengkap: API tanpa framework dengan routing yang jelas, CRUD yang tervalidasi, penyimpanan yang bertahan, dan suite test yang mengunci semuanya. Pola pabrik server, kontrak penyimpanan, dan test berlapis yang kita latih di sini akan dipakai lagi dalam skala lebih besar — berikutnya dengan database node:sqlite pada Proyek Blog API.