Fungsi buatKartuProduk di toko Budi menyusun kartu produk dari article, judul, harga, dan tombol — persis kode yang selama ini kita jalankan di browser. Untuk mengujinya lewat node:test, kita butuh document yang punya createElement, querySelector, dan addEventListener, padahal Node tidak memiliki DOM sama sekali. Menjalankan browser sungguhan hanya demi menguji satu fungsi render terasa berlebihan, dan pemeriksaan manual di browser tidak bisa dimasukkan ke rangkaian uji otomatis.

Jalan tengahnya bernama jsdom: implementasi DOM standar web yang ditulis murni dalam JavaScript dan berjalan di Node. jsdom memparsing teks HTML menjadi pohon elemen, mendukung event, dan menyediakan API seperti document.querySelector — cukup setia untuk menguji logika pembentukan struktur. Chapter ini merangkai jsdom dengan node:test: fungsi render dijalankan ke DOM tiruan, lalu hasil strukturnya diperiksa dengan asersi biasa.

Menyiapkan DOM Tiruan #

jsdom dipakai lewat paket npm, bukan bawaan Node. Pasang sebagai dependensi pengembangan di proyek kita:

bash
npm install --save-dev jsdom

Setelah terpasang, objek JSDOM menerima teks HTML dan mengembalikan tiruan jendela lengkap dengan document-nya:

js
import { JSDOM } from 'jsdom';

const dom = new JSDOM('<!DOCTYPE html><html><body><div id="app"></div></body></html>');
const document = dom.window.document;

console.log(typeof document.createElement);
console.log(document.querySelector('#app') !== null);

Output yang Dihasilkan:

text
function
true

Dua baris pemeriksaan itu menegaskan hal yang kita butuhkan: document tiruan memiliki createElement berupa fungsi, dan elemen #app dari teks HTML benar-benar bisa dicari. Dari titik ini, kode yang biasanya berjalan di browser dapat dieksekusi di Node tanpa diubah.

Menguji Hasil Render #

Berkas uji berikut merender kartu produk ke dalam wadah #app, lalu memeriksa strukturnya persis seperti cara kita membaca DOM di browser:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';

const dom = new JSDOM('<!DOCTYPE html><html><body><div id="app"></div></body></html>');
const document = dom.window.document;

function buatKartuProduk(produk) {
  const kartu = document.createElement('article');
  kartu.className = 'kartu';
  kartu.dataset.id = String(produk.id);

  const nama = document.createElement('h3');
  nama.textContent = produk.nama;

  const harga = document.createElement('p');
  harga.className = 'harga';
  harga.textContent = `Rp${produk.harga.toLocaleString('id-ID')}`;

  const tombol = document.createElement('button');
  tombol.textContent = 'Tambah ke keranjang';
  tombol.addEventListener('click', () => {
    tombol.textContent = 'Ditambahkan ✓';
  });

  kartu.append(nama, harga, tombol);
  return kartu;
}

test('kartu produk dirender ke dalam wadah #app', () => {
  const wadah = document.querySelector('#app');
  wadah.append(buatKartuProduk({ id: 7, nama: 'Buku Algoritma', harga: 99000 }));

  const kartu = wadah.querySelector('article.kartu');
  assert.strictEqual(kartu.dataset.id, '7');

  const nama = kartu.querySelector('h3');
  assert.strictEqual(nama.textContent, 'Buku Algoritma');

  const harga = kartu.querySelector('.harga');
  assert.strictEqual(harga.textContent, 'Rp99.000');

  const tombol = kartu.querySelector('button');
  assert.strictEqual(tombol.textContent, 'Tambah ke keranjang');
});

test('klik tombol mengubah teksnya', () => {
  const kartu = buatKartuProduk({ id: 8, nama: 'Pulpen Gel', harga: 12000 });
  const tombol = kartu.querySelector('button');
  tombol.click();
  assert.strictEqual(tombol.textContent, 'Ditambahkan ✓');
});

Output yang Dihasilkan:

text
✔ kartu produk dirender ke dalam wadah #app (23.199272ms)
✔ klik tombol mengubah teksnya (1.80871ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 539.564249

Mari kita telaah baris-baris pentingnya:

  1. Baris 3 (import { JSDOM } from 'jsdom'): satu-satunya dependensi luar di seluruh berkas; sisanya modul bawaan Node.
  2. Baris 5–6 (instansiasi DOM): tiruan dibuat satu kali di tingkat modul, lalu dom.window.document diikat ke nama document — sehingga isi buatKartuProduk terlihat persis seperti kode browser.
  3. Baris 9–11 (elemen akar kartu): createElement('article') membuat elemen nyata di mata jsdom; kartu.dataset.id = String(produk.id) menulis atribut data-id, dan nilai atribut memang selalu berupa string.
  4. Baris 18 (teks harga): toLocaleString('id-ID') sudah kita pelajari di section Intl — jsdom tidak menggantikannya karena format angka adalah pekerjaan Node, bukan DOM.
  5. Baris 22–24 (pemasang event): addEventListener didukung penuh; handler mengubah teks tombol saat diklik. Event inilah yang dipancing uji kedua.
  6. Baris 31–32 (render ke wadah): kartu tidak hanya dibuat — ia di-append ke elemen #app, sehingga uji membuktikan fungsi render bekerja terhadap dokumen sungguhan, bukan pada elemen yang menggantung tanpa induk.
  7. Baris 34–35 (pencarian dari atas): wadah.querySelector('article.kartu') menemukan kartu hasil render lewat selector gabungan tag dan class — bukti struktur tersusun benar.
  8. Baris 37–44 (pemeriksaan turunan): querySelector('h3'), querySelector('.harga'), dan querySelector('button') bekerja di dalam kartu, memastikan teks nama, format harga, dan label tombol semuanya benar.

Perhatikan juga duration_ms yang lompat ke lebih dari lima ratus milidetik — jauh lebih besar daripada uji murni logika. Waktu itu habis untuk memuat paket jsdom dan membangun tiruannya sekali di awal. Uji DOM tetap tergolong cepat, tetapi angka ini mengingatkan bahwa setiap dependensi membawa ongkosnya sendiri.

Menguji Interaksi: Memancing Event #

Uji kedua memperlihatkan pola yang membuat uji DOM terasa hidup: pancing event, lalu periksa akibatnya. Method tombol.click() milik jsdom menjalankan alur event yang sama dengan klik pengguna — handler yang terpasang lewat addEventListener dieksekusi, teks tombol berubah, dan asersi memastikan perubahannya.

Event lain dipancing lewat dispatchEvent. Contoh berikut meniru kolom kupon yang hurufnya otomatis dibesarkan saat diketik:

js
import { JSDOM } from 'jsdom';

const dom = new JSDOM('<!DOCTYPE html><body><input id="kupon"></body>');
const document = dom.window.document;

const kolom = document.querySelector('#kupon');
kolom.addEventListener('input', () => {
  kolom.value = kolom.value.toUpperCase();
});

kolom.value = 'hemat10';
kolom.dispatchEvent(new dom.window.Event('input'));

console.log(kolom.value);

Output yang Dihasilkan:

text
HEMAT10

Perhatikan dua detailnya. Event dibuat dari dom.window.Event — bukan Event global Node, karena konstruksi event harus berasal dari jendela yang sama dengan elemennya. Dan nilai kolom diisi manual lewat kolom.value = 'hemat10' sebelum event dipancarkan, karena jsdom tidak memiliki keyboard sungguhan: yang diuji adalah reaksi handler terhadap event, bukan penekanan tombolnya.

Batas jsdom #

jsdom meniru struktur dan event, bukan mesin render. Beberapa hal yang paling sering dijumpai:

  • Layout tidak dihitung. getBoundingClientRect() mengembalikan nilai nol semua, karena tidak ada jendela sungguhan yang menggambar apa pun; offsetWidth dan teman-temannya juga tidak berguna.
  • CSS tidak diproses untuk keputusan. Uji yang bergantung pada “elemen terlihat setelah class aktif” tidak bisa dinilai dari jsdom.
  • API jendela tertentu tidak ada atau terbatas, misalnya fitur scroll, seleksi lanjutan, dan sebagian API penyimpanan.

Konsekuensinya sederhana: uji yang tepat untuk jsdom adalah pertanyaan berbentuk “apakah struktur dan reaksinya benar”, bukan “apakah tampilannya benar”. Bila perilaku yang ingin dipastikan bersifat visual atau lintas halaman, itu tugas lapisan berikutnya: otomasi browser sungguhan dengan Playwright — topik chapter setelah integration testing. Untuk menutup lapisan ini, kita perlu satu kelas uji lagi: memastikan beberapa unit bekerja sama dengan benar.