Ketika pesanan Budi selesai, sistem mengirim email konfirmasi ke [email protected]. Fungsi yang bertugas — sebut saja selesaikanPesanan — berjalan baik, tetapi mengujinya menyulitkan: setiap kali uji berjalan, email sungguhan harus terkirim. Uji pun menjadi lambat karena menunggu jaringan, isinya tidak pasti karena bergantung pada server luar, dan pengembang jadi segan menjalankannya.

Prinsipnya sederhana: unit yang kita uji seharusnya hanya berurusan dengan hal yang bisa kita kendalikan. Bagian dunia luar — pengirim email, penyimpanan berkas, jam sistem — digantikan test double: objek pengganti yang bentuknya sama dengan aslinya, tetapi perilakunya kita kendalikan penuh. Istilah ini diambil dari dunia film, di mana double adalah pemeran pengganti untuk adegan tertentu. Chapter ini mempraktikkan dua cara membuatnya: mock bawaan node:test dan pemeran pengganti tulisan tangan.

Masalah: Dependensi yang Tidak Bisa Dikendalikan #

Bayangkan fungsi selesaikanPesanan ditulis langsung memanggil modul pengirim email di dalam badannya. Saat diuji, kita tidak punya celah: pemanggilan kirim() pasti menembus jaringan. Cara keluar dari kebuntuan itu bernama dependency injection — dependensi tidak dibuat di dalam fungsi, melainkan diterima sebagai parameter:

js
function selesaikanPesanan(pesanan, pengirim) {
  const bukti = pengirim.kirim(
    pesanan.email,
    'Pesanan dikirim',
    `Halo ${pesanan.nama}, pesanan #${pesanan.nomor} sedang dalam perjalanan.`
  );
  return { ...pesanan, status: 'dikirim', buktiKirim: bukti };
}

Dengan bentuk ini, kode produksi mengirim selesaikanPesanan(pesanan, pengirimEmailAsli), sedangkan uji mengirim penggantinya. Tidak ada if (modeUji) di dalam fungsi, tidak ada modul yang perlu ditukar saat berkas dimuat — pemanggil memutuskan dependensi apa yang dipakai. Pola ini sengaja dibuat di chapter ini karena hampir semua teknik berikut bergantung padanya.

Stub dengan t.mock.method #

Berkas uji berikut menggunakan t.mock.method — mock bawaan node:test yang menggantikan satu method pada objek dengan versi pencatat pemanggilan:

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

// Stub pengirim email: antarmukanya sama dengan pengirim asli,
// tetapi tidak menyentuh jaringan sama sekali.
function buatPengirimEmail() {
  return {
    kirim(penerima, judul, isi) {
      throw new Error('pengirim asli tidak boleh dipanggil saat uji');
    },
  };
}

// Kode yang diuji menerima dependensinya sebagai parameter
// (dependency injection), sehingga mudah diganti saat uji.
function selesaikanPesanan(pesanan, pengirim) {
  const bukti = pengirim.kirim(
    pesanan.email,
    'Pesanan dikirim',
    `Halo ${pesanan.nama}, pesanan #${pesanan.nomor} sedang dalam perjalanan.`
  );
  return { ...pesanan, status: 'dikirim', buktiKirim: bukti };
}

test('selesaikanPesanan mengirim satu email ke pembeli', (t) => {
  const pengirim = buatPengirimEmail();
  const stub = t.mock.method(pengirim, 'kirim');
  stub.mock.mockImplementation(() => 'terkirim');

  const hasil = selesaikanPesanan(
    { nomor: 501, nama: 'Budi', email: '[email protected]' },
    pengirim
  );

  assert.strictEqual(hasil.status, 'dikirim');
  assert.strictEqual(stub.mock.callCount(), 1);

  const [penerima, judul, isi] = stub.mock.calls[0].arguments;
  assert.strictEqual(penerima, '[email protected]');
  assert.strictEqual(judul, 'Pesanan dikirim');
  assert.match(isi, /#501/);
});

test('alur gagal tidak mengirim email sama sekali', (t) => {
  const pengirim = buatPengirimEmail();
  const stub = t.mock.method(pengirim, 'kirim');

  const kirimJikaLengkap = (pesanan) => {
    if (!pesanan.email) return { ...pesanan, status: 'ditunda' };
    return selesaikanPesanan(pesanan, pengirim);
  };

  const hasil = kirimJikaLengkap({ nomor: 502, nama: 'Siti', email: '' });

  assert.strictEqual(hasil.status, 'ditunda');
  assert.strictEqual(stub.mock.callCount(), 0);
});

test('fake sederhana mencatat email yang dikirim', () => {
  const terkirim = [];
  const pengirim = {
    kirim(penerima, judul) {
      terkirim.push({ penerima, judul });
      return 'terkirim';
    },
  };

  selesaikanPesanan({ nomor: 503, nama: 'Siti', email: '[email protected]' }, pengirim);
  selesaikanPesanan({ nomor: 504, nama: 'Budi', email: '[email protected]' }, pengirim);

  assert.strictEqual(terkirim.length, 2);
  assert.strictEqual(terkirim[1].penerima, '[email protected]');
});

Output yang Dihasilkan:

text
✔ selesaikanPesanan mengirim satu email ke pembeli (1.105743ms)
✔ alur gagal tidak mengirim email sama sekali (0.181007ms)
✔ fake sederhana mencatat email yang dikirim (0.139811ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 56.149477

Mari kita telaah uji pertamanya:

  1. Baris 25 (test('...', (t) => {...})): parameter t adalah test context — alat-alat milik satu uji tertentu, termasuk t.mock.
  2. Baris 26 (buatPengirimEmail()): pengganti aslinya dibuat terlebih dahulu. Perhatikan bahwa method kirim di dalamnya sengaja melempar galat: bila kode produksi mengaksesnya tanpa mock, uji langsung gagal keras dengan pesan yang jelas.
  3. Baris 27 (t.mock.method(pengirim, 'kirim')): kirim pada objek pengirim digantikan fungsi mock, dan fungsi aslinya disimpan agar bisa dipulihkan. Nilai kembalian berupa mock function yang mencatat segala pemanggilan.
  4. Baris 28 (mockImplementation(() => 'terkirim')): perilaku penggantinya ditentukan — setiap pemanggilan cukup mengembalikan 'terkirim', tanpa jaringan sama sekali.
  5. Baris 35–36 (verifikasi status dan jumlah): hasil fungsinya benar, dan stub.mock.callCount() memastikan pengirim dipanggil tepat satu kali. Inilah jenis klaim yang mustahil dibuat tanpa pengganti: kita menguji apa yang dilakukan fungsi, bukan hanya nilai kembaliannya.
  6. Baris 38–41 (verifikasi argumen): stub.mock.calls[0].arguments menyimpan daftar argumen pemanggilan pertama. Kita memastikan email ditujukan ke [email protected], judulnya benar, dan isi pesan memuat nomor pesanan.

Pemulihan method aslinya otomatis: karena mock dibuat lewat t.mock, test runner mengembalikan pengirim.kirim ke bentuk semula begitu ujinya selesai. Bila mock dibuat tanpa konteks uji — lewat mock.method yang diimpor langsung dari node:test — kita wajib memanggil stub.mock.restore() sendiri di akhir uji.

Memastikan Pemanggilan yang Tidak Terjadi #

Uji kedua menunjukkan arah verifikasi yang berlawanan: memastikan sesuatu tidak dipanggil. Pesanan Siti tanpa alamat email tidak boleh memicu pengiriman, dan buktinya adalah stub.mock.callCount() bernilai 0 (baris 56). Klaim “tidak terjadi” ini sering luput, padahal justru bug tipe ini yang paling mahal — email terkirim ke alamat kosong, uang terpotong dua kali, dan lain-lain.

Catatan hasil uji juga menyimpan detail tambahan yang patut dikenal: setiap entri stub.mock.calls[i] menyimpan arguments (daftar argumen), result (nilai kembalian bila berhasil), dan error (galat bila implementasi mock melempar). Daftar itu terisi berurutan sesuai pemanggilan, sehingga percakapan beruntun antara kode dan dependensinya bisa direkonstruksi dari awal sampai akhir — urutan siapa dipanggil lebih dulu pun menjadi bagian yang bisa diklaim, bukan sekadar diperkirakan.

Apa yang Layak Digantikan #

Test double paling bernilai ketika dipasang pada batas sistem — titik di mana kode kita bertemu dunia yang tidak bisa dikendalikan uji:

  • Jaringan — pengirim email, panggilan API, unduhan berkas. Ganti agar uji cepat dan pasti.
  • Berkas dan database — operasi yang menulis atau membaca penyimpanan. Ganti dengan peta atau array di memori.
  • Waktu — new Date() yang hasilnya bergantung jam dinding. Uji tentang “akhir bulan” atau “hari kerja” butuh jam yang bisa disetel.
  • Angka acak — Math.random() yang membuat hasil berbeda setiap run. Uji butuh kepastian, jadi acaknya digantikan sumber yang tetap.

Sebaliknya, tidak semua yang bisa digantikan memang patut digantikan. Fungsi murni seperti hitungSubtotal tidak butuh double apa pun — menjalankannya lebih murah daripada membuat penggantinya. Menggantikan kode milik sendiri yang sudah murni justru melemahkan uji: yang diuji berubah menjadi tiruan, bukan implementasi.

Stub, Fake, dan Mock #

Tiga uji di berkas tersebut sekaligus memperlihatkan tiga wajah test double:

IstilahCiriDi berkas ini
StubPengganti dengan jawaban tetap; dipakai agar kode yang diuji berjalankirim hasil t.mock.method yang selalu mengembalikan 'terkirim'
FakeImplementasi ringan yang benar-benar bekerja dengan cara sederhanakirim yang menyimpan panggilan ke array terkirim (baris 59–66)
MockPengganti yang disiapkan bersama daftar pemanggilan yang diharapkanPencatatan callCount() dan calls[i].arguments atas method yang digantikan

Batas antar-istilah ini sering tumpang-tindih di literatur, dan yang penting bukan menghafalnya, melainkan memilih kebutuhannya: butuh jawaban tetap → stub; butuh merekonstruksi apa yang terjadi → pencatat pemanggilan (mock.method); butuh perilaku yang menyerupai aslinya untuk alur panjang → fake.

Dengan dependensi eksternal yang bisa diganti, satu-satunya hal yang belum kita uji adalah kode yang menyentuh struktur halaman: pembuatan elemen DOM. Chapter berikutnya mengambil bagian itu dengan bantuan jsdom.