Aplikasi yang baru selesai ditulis dan aplikasi yang benar adalah dua hal berbeda — jarak keduanya hanya bisa dipendekkan dengan test otomatis. Pada bagian ini kita mengunci perilaku Blog API lewat dua berkas test: tests/db.test.js menguji lapisan data secara langsung, dan tests/api.test.js menyalakan server sungguhan lalu mengirim permintaan HTTP. Setelah itu kita merapikan pintu masuk produksi src/cli.js dan merenungkan beberapa arah pengembangan berikutnya.

Test Unit untuk Lapisan Data #

Setiap test di tests/db.test.js memanggil pabrik buatDb dengan ':memory:' dan jam beku, sehingga tidak ada keadaan yang bocor antar test:

js tests/db.test.js — pembukaan dan helper
// Test unit lapisan data: semua memakai database :memory:.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buatDb, WAKTU_DEMO } from '../src/db.js';

function dbUji() {
  return buatDb(':memory:', { waktu: () => WAKTU_DEMO });
}

Dua test berikut mewakili dua sifat penting lapisan data. Test pertama membuktikan catatan prototype dari Bagian 1; test kedua memastikan mode berkas benar-benar menyimpan data lintas koneksi:

js tests/db.test.js — prototype baris dan persistensi berkas
test('baris hasil query berprototype null, spread menghasilkan object biasa', () => {
  const db = dbUji();
  const a1 = db.tambahArtikel('Judul', 'Isi.');
  assert.equal(Object.getPrototypeOf(a1), null);
  const salinan = { ...a1 };
  assert.equal(Object.getPrototypeOf(salinan), Object.prototype);
  db.tutup();
});
js tests/db.test.js — persistensi berkas
test('buatDb pada berkas menyimpan data lintas koneksi', () => {
  const folder = mkdtempSync(join(tmpdir(), 'blog-api-uji-'));
  const jalur = join(folder, 'blog.db');
  try {
    let db = buatDb(jalur, { waktu: () => WAKTU_DEMO });
    db.tambahArtikel('Tersimpan', 'Isi artikel.');
    db.tutup();

    db = buatDb(jalur, { waktu: () => WAKTU_DEMO });
    const daftar = db.daftarArtikel();
    assert.equal(daftar.length, 1);
    assert.equal(daftar[0].judul, 'Tersimpan');
    db.tutup();
  } finally {
    rmSync(folder, { recursive: true, force: true });
  }
});

Perhatikan dua hal: assert diimpor dari node:assert/strict sehingga deepEqual membandingkan prototype juga — itulah sebabnya baris hasil query selalu di-spread sebelum dibandingkan dengan object literal. Test persistensi memakai folder sementara dari os.tmpdir() dan membersihkannya di blok finally, jadi tidak ada berkas tersisa walau test gagal di tengah jalan.

Integration Test dengan Port Acak #

Test HTTP tidak dilakukan dengan curl yang diketik manual — kita memakai pola dari Chapter Integration Test Server: server dinyalakan pada port acak, fetch mengirim permintaan sungguhan, lalu semuanya ditutup rapi:

js tests/api.test.js — before dan after
test.before(async () => {
  db = buatDb(':memory:', { waktu: () => WAKTU_DEMO });
  server = buatServer(db);
  await new Promise((selesai) => server.listen(0, '127.0.0.1', selesai));
  url = `http://127.0.0.1:${server.address().port}`;
});

test.after(async () => {
  server.closeAllConnections();
  await new Promise((selesai) => server.close(selesai));
  db.tutup();
});
  1. listen(0, '127.0.0.1', ...) meminta port acak yang kosong; nomor port aslinya dibaca dari server.address().port. Test jadi tidak pernah bertabrakan dengan proses lain.
  2. server.closeAllConnections() memutus koneksi keep-alive yang disimpan fetch. Tanpa panggilan ini, server.close() menunggu socket menganggur hingga batas waktu, dan proses test tampak berhenti tanpa sebab.
  3. db.tutup() menutup DatabaseSync setelah server berhenti, sehingga berkas database (pada mode berkas) selalu ditutup dengan benar.

Menjalankan Seluruh Test #

Dari folder proyek, satu perintah menjalankan semua test:

bash
node --test

Output yang Dihasilkan:

text
✔ GET /artikel pada database kosong mengembalikan array kosong
✔ POST /artikel valid mengembalikan 201 beserta artikel baru
✔ POST /artikel kedua mendapat id berikutnya
✔ GET /artikel mengembalikan seluruh artikel
✔ GET /artikel?q= memfilter berdasarkan judul dan isi
✔ POST /artikel tanpa judul ditolak 400
✔ POST /artikel dengan judul kosong ditolak 400
✔ POST /artikel dengan body bukan JSON ditolak 400
✔ POST /artikel dengan body bukan object ditolak 400
✔ GET /artikel/999 mengembalikan 404
✔ GET /artikel/abc menolak id bukan angka dengan 400
✔ GET /artikel/1 mengembalikan artikel dengan komentar kosong
✔ PATCH /artikel/2 mengubah judul tanpa menyentuh isi
✔ PATCH /artikel/2 tanpa field yang dikenal ditolak 400
✔ PATCH /artikel/999 mengembalikan 404
✔ POST /artikel/1/komentar mengembalikan 201 beserta komentar
✔ POST /artikel/999/komentar mengembalikan 404 lewat foreign key
✔ POST /artikel/1/komentar dengan nama kosong ditolak 400
✔ GET /artikel/1 menyematkan komentar hasil JOIN
✔ GET /artikel/1/komentar memakai batas bawaan 5
✔ GET /artikel/1/komentar membagi halaman sesuai batas
✔ GET /artikel/1/komentar dengan batas tidak valid ditolak 400
✔ GET /artikel/999/komentar mengembalikan 404
✔ DELETE /komentar/1 mengembalikan 204 tanpa body
✔ DELETE /komentar/1 untuk kedua kalinya mengembalikan 404
✔ DELETE /artikel/1 mengembalikan 204 dan menghapus komentarnya
✔ GET /artikel/1 setelah dihapus mengembalikan 404
✔ endpoint yang tidak dikenal mengembalikan 404 JSON
✔ tambahArtikel mengisi id berurutan dan created_at dari jam tersuntik
✔ baris hasil query berprototype null, spread menghasilkan object biasa
✔ daftarArtikel mengembalikan array kosong lalu urut sesuai id
✔ daftarArtikel memfilter kata kunci pada judul maupun isi
✔ cariArtikel tanpa komentar mengembalikan artikel dengan komentar kosong
✔ cariArtikel menggabungkan komentar lewat JOIN dengan urutan id
✔ cariArtikel pada id yang tidak ada mengembalikan undefined
✔ ubahArtikel mengganti sebagian field tanpa menyentuh sisanya
✔ ubahArtikel pada id hilang mengembalikan undefined
✔ hapusArtikel mengembalikan true sekali lalu false
✔ tambahKomentar menolak artikel_id yang tidak ada (foreign key)
✔ menghapus artikel turut menghapus komentarnya (CASCADE)
✔ daftarKomentar membagi halaman dengan LIMIT dan OFFSET
✔ artikelAda membedakan id yang ada dan tidak
✔ buatDb pada berkas menyimpan data lintas koneksi
ℹ tests 43
ℹ suites 0
ℹ pass 43
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Empat puluh tiga test lolos: lima belas di lapisan data dan dua puluh delapan lewat HTTP. Untuk ditampilkan di halaman ini, kolom durasi tiap test sengaja kita saring dari output — angka durasi berubah setiap dijalankan, sementara nama test dan statusnya stabil. Perintah polos node --test di komputer kita akan menampilkan durasi tersebut.

Titik Masuk Produksi #

Selama tiga bagian, server selalu dinyalakan oleh pihak lain — demo dan test. Berkas src/cli.js adalah versi untuk pemakaian sungguhan:

js src/cli.js — konfigurasi dan listen
// Titik masuk produksi: baca konfigurasi dari environment, lalu dengarkan.
import { mkdirSync } from 'node:fs';
import { buatDb } from './db.js';
import { buatServer } from './server.js';

const port = Number(process.env.PORT ?? 3000);
const jalurDb = process.env.DB_PATH ?? 'data/blog.db';

// SQLite tidak membuat folder induk, jadi folder data disiapkan dulu.
if (jalurDb !== ':memory:') {
  mkdirSync('data', { recursive: true });
}

const db = buatDb(jalurDb);
const server = buatServer(db);

server.listen(port, () => {
  console.log(`Blog API berjalan di http://localhost:${port}`);
});

Konfigurasi datang dari environment variable PORT dan DB_PATH dengan nilai bawaan yang masuk akal — pola dua belas faktor yang kita bahas pada Chapter Konfigurasi Aplikasi. Menjalankannya mudah:

bash
node src/cli.js

Setelah server berjalan, endpoint siap diakses di http://localhost:3000/artikel — lewat browser, curl, atau kode klien apa pun. Berkas ini juga memasang handler SIGINT dan SIGTERM yang memanggil closeAllConnections(), menutup server, lalu menutup database sebelum proses berakhir, sehingga saat proses dihentikan pun berkas database ditutup dengan rapi.

Perapihan dan Pengembangan Lanjutan #

Proyek ini sengaja dijaga ramping, tetapi beberapa pintu pengembangan sudah terlihat:

  1. Menyimpan prepared statement — setiap fungsi di db.js memanggil prepare() setiap kali dieksekusi. Untuk API sibuk, statement bisa disiapkan sekali saat buatDb berjalan lalu dipakai ulang; StatementSync memang didesain untuk itu.
  2. Endpoint PUT untuk penggantian penuh — tinggal mewajibkan judul dan isi sekaligus di satu handler.
  3. Autentikasi untuk menulis artikel — pembahasan token dan peran menunggu di Section Authentication dan Authorization.
  4. Rate limiting di sekeliling POST agar API tidak dibanjiri permintaan.
  5. Dokumentasi API berbentuk OpenAPI agar klien lain dapat menjelajah endpoint tanpa membaca kode kita.

Blog API kini berdiri lengkap: skema relasional dijaga foreign key, endpoint artikel dan komentar bekerja dengan status code yang konsisten, JOIN dan pagination menyatukan dua tabel, dan seluruh perilaku terkunci dalam test otomatis. Keterampilan ini akan terpakai lagi — kali ini dengan latar yang berbeda — pada Proyek Background Job Processor.

Referensi resmi: node:test dan node:http .