Node.js adalah runtime untuk kode asynchronous — hampir setiap proyek nyata dipenuhi promise, timer, dan callback. Kabar baiknya: test runner node:test dirancang untuk dunia itu sejak awal, sehingga menguji kode async nyaris tidak membutuhkan trik. Kabar yang perlu diseriusi: beberapa jebakan klasik — lupa await, callback yang tidak pernah dipanggil, timer yang membuat suite lambat — hanya akan tampak jika kita paham bagaimana runner menunggu kode kita selesai. Pada Chapter ini kita menguji promise yang terpenuhi maupun ditolak, membungkus API callback, mempercepat waktu dengan fake timer, dan memasang batas waktu agar test yang menggantung tertangkap.

Test Asynchronous Adalah Promise #

Aturan dasarnya satu kalimat: jika callback test mengembalikan promise, runner menunggu promise tersebut selesai sebelum menilai lulus atau gagal. Karena itu bentuk paling alami dari test async adalah function async — setiap await di dalamnya ikut ditunggu, dan error yang terlempar setelah await langsung menandai test gagal. Mari kita uji module kecil yang menirukan pengambilan data dengan jeda 10 milidetik:

js
import { setTimeout as tidur } from 'node:timers/promises';

export async function ambilProfil(id) {
  await tidur(10);
  if (id <= 0) {
    throw new Error('id tidak valid');
  }
  return { id, nama: 'Budi', kota: 'Bandung' };
}
js
import { test } from 'node:test';
import assert from 'node:assert';
import { ambilProfil } from './ambil-data.mjs';

test('mengambil profil pengguna', async () => {
  const profil = await ambilProfil(7);
  assert.deepStrictEqual(profil, { id: 7, nama: 'Budi', kota: 'Bandung' });
});

test('id tidak valid ditolak dengan error', async () => {
  await assert.rejects(ambilProfil(-1), { message: 'id tidak valid' });
});
bash
node --test ambil-data.test.mjs

Output yang Dihasilkan:

text
✔ mengambil profil pengguna
✔ id tidak valid ditolak dengan error
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Empat bagian pada berkas test tersebut bekerja sama menunggu dunia asynchronous:

  1. Baris 5 (async () => {): kata kunci async membuat callback test otomatis mengembalikan promise; runner menunggu promise itu sampai selesai.
  2. Baris 6: await ambilProfil(7) menunggu jeda 10 milidetik di dalam ambil-data.mjs — pada titik ini runner bersabar, bukan menandai test selesai.
  3. Baris 7: assertion berjalan setelah data sungguhan tiba, sehingga yang dibandingkan adalah hasil akhir, bukan promise-nya.
  4. Baris 11: penolakan diperiksa dengan assert.rejects yang mengembalikan promise — wajib di-await agar kegagalan tercatat sebagai kegagalan test, bukan penolakan yang mengambang.

Jebakan Klasik: Lupa await #

Kesalahan paling umum dalam test async adalah lupa menulis await sebelum pemanggilan promise. Test berikut sengaja memuat kesalahan itu — tugasPenting() ditolak setelah 5 milidetik, tetapi test tidak menunggunya:

js
import { test } from 'node:test';
import assert from 'node:assert';

function tugasPenting() {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error('gagal menyimpan data')), 5);
  });
}

test('menjalankan tugas penting', () => {
  const janji = tugasPenting();
  assert.ok(janji instanceof Promise);
});
bash
node --test lupa-await.test.mjs
echo "kode keluar: $?"

Output yang Dihasilkan:

text
✔ menjalankan tugas penting
ℹ Error: Test "menjalankan tugas penting" at lupa-await.test.mjs:10:1 generated asynchronous activity after the test ended. This activity created the error "Error: gagal menyimpan data" and would have caused the test to fail, but instead triggered an unhandledRejection event.
✖ lupa-await.test.mjs
ℹ tests 2
ℹ suites 0
ℹ pass 1
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

✖ failing tests:

test at lupa-await.test.mjs:1:1
✖ lupa-await.test.mjs
  'test failed'
kode keluar: 1

Perhatikan keanehan pada output: test itu sendiri bertanda ✔, tetapi berkas keseluruhan dinyatakan gagal dengan kode keluar 1. Runner versi modern mendeteksi aktivitas asynchronous yang masih hidup setelah test berakhir — penolakan gagal menyimpan data muncul setelah test selesai, memicu unhandledRejection, lalu runner menandai berkas test tersebut gagal. Penyelamat ini bagus, tetapi jangan bersandar padanya.

Menguji API Gaya Callback #

Banyak module lama — dan beberapa API Node.js hingga hari ini — memakai pola callback (error, hasil) alih-alih promise. Test runner tidak menyediakan mekanisme done khusus ala framework generasi lama; cara yang dianjurkan adalah membungkus callback menjadi promise, sama seperti pola util.promisify yang kita kenal pada Section Asynchronous Runtime. Dibungkus sekali di dalam test, seluruh peralatan async/await langsung tersedia:

js
export function bacaKonfigurasi(callback) {
  setTimeout(() => {
    callback(null, { host: '127.0.0.1', port: 3000, mode: 'produksi' });
  }, 10);
}

export function simpanKonfigurasi(data, callback) {
  if (!data.host) {
    setTimeout(() => callback(new Error('host wajib diisi')), 10);
    return;
  }
  setTimeout(() => callback(null, 'tersimpan'), 10);
}
js
import { test } from 'node:test';
import assert from 'node:assert';
import { bacaKonfigurasi, simpanKonfigurasi } from './baca-konfigurasi.mjs';

test('membaca konfigurasi lewat callback', async () => {
  const hasil = await new Promise((resolve, reject) => {
    bacaKonfigurasi((error, data) => (error ? reject(error) : resolve(data)));
  });
  assert.deepStrictEqual(hasil, {
    host: '127.0.0.1',
    port: 3000,
    mode: 'produksi',
  });
});

test('konfigurasi tanpa host ditolak', async () => {
  const penolakan = new Promise((resolve, reject) => {
    simpanKonfigurasi({ port: 3000 }, (error, status) =>
      error ? reject(error) : resolve(status)
    );
  });
  await assert.rejects(penolakan, { message: 'host wajib diisi' });
});
bash
node --test baca-konfigurasi.test.mjs

Output yang Dihasilkan:

text
✔ membaca konfigurasi lewat callback
✔ konfigurasi tanpa host ditolak
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Pembungkusan promise pada kedua test layak dibedah karena polanya berulang di mana-mana:

  1. Baris 6-8 (sukses): new Promise menerima callback dengan resolve dan reject; callback asli diarahkan — error membawa promise ke reject, data membawanya ke resolve.
  2. Baris 17-21 (kegagalan): promise yang sama-sama dibungkus, lalu diperiksa dengan assert.rejects — penolakan dari callback lama diperlakukan persis seperti penolakan promise modern.

Mengendalikan Waktu dengan Fake Timer #

Menunggu timer sungguhan membuat suite lambat dan rapuh: test yang menunggu 30 menit jelas mustahil, dan test yang menunggu 3 detik pun terasa berat dijalankan ratusan kali. node:test menyertakan fake timer lewat mock.timers: timer global diganti versi palsu yang maju hanya saat kita memerintahkan tick(). Jam Date juga bisa dibekukan pada titik waktu tertentu:

js
import { test, mock } from 'node:test';
import assert from 'node:assert';

test('menunggu pesan terjadwal tanpa menunggu sungguhan', () => {
  mock.timers.enable({ apis: ['setTimeout'] });

  const terkirim = [];
  setTimeout(() => terkirim.push('pengingat pagi'), 8 * 60 * 60 * 1000);
  setTimeout(() => terkirim.push('laporan sore'), 17 * 60 * 60 * 1000);

  assert.strictEqual(terkirim.length, 0);
  mock.timers.tick(8 * 60 * 60 * 1000);
  assert.deepStrictEqual(terkirim, ['pengingat pagi']);
  mock.timers.tick(9 * 60 * 60 * 1000);
  assert.deepStrictEqual(terkirim, ['pengingat pagi', 'laporan sore']);

  mock.timers.reset();
});

test('jam palsu membekukan waktu', () => {
  mock.timers.enable({ apis: ['Date'] });
  mock.timers.setTime(new Date(2026, 0, 15, 8, 0, 0).getTime());

  assert.strictEqual(new Date().getHours(), 8);
  assert.strictEqual(new Date().getDate(), 15);

  mock.timers.reset();
});
bash
node --test timer-palsu.test.mjs

Output yang Dihasilkan:

text
✔ menunggu pesan terjadwal tanpa menunggu sungguhan
✔ jam palsu membekukan waktu
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Test pertama menjalankan logika penjadwalan delapan jam tanpa menunggu satu milidetik pun sungguhan. Urutan kerjanya:

  1. Baris 6: mock.timers.enable({ apis: ['setTimeout'] }) mengganti setTimeout global dengan versi palsu; hanya API yang disebut yang diganti, sisanya tetap asli.
  2. Baris 8-9: dua pesan dijadwalkan pada jam ke-8 dan ke-17 — dengan timer palsu, penjadwalan ini hanya dicatat, tidak menunggu.
  3. Baris 11-15: tick() memajukan waktu palsu; setiap tick menjalankan semua callback yang batas waktunya terlampaui, sehingga kita bisa memeriksa isi terkirim pada titik-titik waktu tertentu.
  4. Baris 21-28 (jam palsu): enable({ apis: ['Date'] }) mengganti class Date; setTime() membekukan waktu pada 15 Januari 2026 pukul 08:00, lalu new Date() membaca jam yang beku itu.
  5. Baris 29 dan 16: mock.timers.reset() mengembalikan semua timer dan jam ke perilaku asli — wajib dipanggil agar test berikutnya tidak mewarisi dunia palsu.

Batas Waktu Test #

Test yang menggantung — misalnya menunggu respons yang tidak pernah datang — akan menahan seluruh suite selamanya. Opsi timeout pada deklarasi test memberi batas: melewatinya membatalkan test dan menandai berkas gagal. Berkas berikut mensimulasikan layanan yang lambat dengan jeda 1500 milidetik sementara batasnya hanya 100 milidetik:

js
import { test } from 'node:test';

test('layanan lambat harus selesai di bawah 100 milidetik', { timeout: 100 }, async () => {
  await new Promise((resolve) => setTimeout(resolve, 1500));
});
bash
node --test layanan-lambat.test.mjs
echo "kode keluar: $?"

Output yang Dihasilkan:

text
✖ layanan lambat harus selesai di bawah 100 milidetik
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 0
ℹ cancelled 1
ℹ skipped 0
ℹ todo 0

✖ failing tests:

test at layanan-lambat.test.mjs:3:1
✖ layanan lambat harus selesai di bawah 100 milidetik
  'test timed out after 100ms'
kode keluar: 1

Test tersebut dilaporkan sebagai cancelled — bukan fail — dengan pesan test timed out after 100ms, dan kode keluar tetap 1 sehingga pipeline CI berhenti. Tanpa opsi timeout, batas bawaannya tidak terbatas: test yang menggantung akan menggantung pula seluruh proses node --test. Karena itu biasakan memasang timeout pada test yang menyentuh jaringan atau proses eksternal, sambil tetap menjaga jeda yang wajar — batas 100 milidetik pada contoh di atas memang sengaja ketat untuk memicu pembatalan.

Ke Mana Setelah Ini #

Kita kini dapat menguji kode asynchronous dengan aman: menunggu promise lewat async/await, menangkap penolakan dengan assert.rejects, membungkus callback menjadi promise, mempercepat timer, dan memasang batas waktu. Satu rintangan tersisa pada kode nyata: dependensi eksternal — jaringan, berkas, layanan pihak ketiga — yang membuat test lambat atau tidak menentu. Chapter Mocking mengajarkan cara menggantikan dependensi tersebut dengan panggung palsu dari node:test.