Pengujian unit vs integrasi

Hal yang diuji pada setiap batas sebuah server.

Daftar isi

Pengujian otomatis dibagi menjadi beberapa lapisan yang berbeda pada hal yang diuji, bukan hanya pada ukurannya. Topik ini membahas perbedaan pengujian unit yang menguji satu fungsi dalam isolasi, pengujian integrasi yang menguji beberapa modul yang dirangkai, serta posisi keduanya di dalam piramida pengujian. Setelah membaca topik ini, sebuah berkas uji dapat dikelompokkan sebagai unit atau integrasi berdasarkan dependensi yang dipanggil, sehingga kegagalan dapat ditelusuri pada lapisan yang tepat.

Apa yang diuji pada level unit#

Pengujian unit menguji satu unit kode dalam isolasi, yaitu satu fungsi atau satu modul yang tidak memanggil dependensi eksternal. Unit yang diuji biasanya berupa fungsi murni yang menerima masukan, menghasilkan keluaran, dan tidak menyimpan state di luar panggilan. Asersi pada tes unit membandingkan keluaran fungsi dengan nilai yang diharapkan, dan kegagalan langsung menunjuk ke fungsi yang diuji tanpa perlu menelusuri rantai panggilan.

Berkas validator.js berikut adalah kandidat yang tepat untuk tes unit karena hanya berisi dua fungsi murni yang memvalidasi masukan string dan angka. Tidak ada panggilan ke basis data, jaringan, atau modul lain, sehingga tes dapat berjalan tanpa persiapan fixture.

js validator.js
function validasiNama(nama) {
  if (typeof nama !== 'string') return { ok: false, pesan: 'nama harus berupa string' };
  if (nama.length < 1) return { ok: false, pesan: 'nama tidak boleh kosong' };
  if (nama.length > 64) return { ok: false, pesan: 'nama maksimal 64 karakter' };
  return { ok: true };
}

function validasiUmur(umur) {
  if (typeof umur !== 'number') return { ok: false, pesan: 'umur harus berupa angka' };
  if (!Number.isInteger(umur)) return { ok: false, pesan: 'umur harus bilangan bulat' };
  if (umur < 0 || umur > 150) return { ok: false, pesan: 'umur di luar rentang 0 sampai 150' };
  return { ok: true };
}

module.exports = { validasiNama, validasiUmur };

Berkas uji validator.test.js menguji tiap cabang pada kedua fungsi secara terpisah. Setiap tes memanggil fungsi dengan satu masukan dan membandingkan hasilnya dengan objek { ok, pesan } yang diharapkan. Pemisahan per cabang menjaga agar kegagalan pada kasus empty string tidak menyembunyikan kegagalan pada kasus string too long.

js validator.test.js
// tidak-dijalankan: butuh sibling validator.js di direktori yang sama dengan berkas sumber
const test = require('node:test');
const assert = require('node:assert/strict');
const { validasiNama, validasiUmur } = require('./validator');

test('validasiNama menerima nama yang wajar', () => {
  assert.deepEqual(validasiNama('Ali'), { ok: true });
});

test('validasiNama menolak string kosong', () => {
  const r = validasiNama('');
  assert.equal(r.ok, false);
  assert.match(r.pesan, /tidak boleh kosong/);
});

test('validasiUmur menolak bilangan desimal', () => {
  const r = validasiUmur(25.5);
  assert.equal(r.ok, false);
  assert.match(r.pesan, /bilangan bulat/);
});

test('validasiUmur menerima angka dalam rentang wajar', () => {
  assert.deepEqual(validasiUmur(30), { ok: true });
});
bash
# jalankan dari direktori topik
node --test --test-reporter tap validator.test.js 2>&1 | sed -E '/^[[:space:]]+---$/,/^[[:space:]]+\.\.\.$/d; /^# duration_ms/d'
text Keluaran
TAP version 13
# Subtest: validasiNama menerima nama yang wajar
ok 1 - validasiNama menerima nama yang wajar
# Subtest: validasiNama menolak string kosong
ok 2 - validasiNama menolak string kosong
# Subtest: validasiUmur menolak bilangan desimal
ok 3 - validasiUmur menolak bilangan desimal
# Subtest: validasiUmur menerima angka dalam rentang wajar
ok 4 - validasiUmur menerima angka dalam rentang wajar
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0

Karakteristik tes unit yang patut dicatat: waktu eksekusi sangat singkat karena tidak ada I/O, kegagalan menunjuk langsung ke fungsi yang diuji, dan tes dapat dijalankan berulang tanpa efek samping. Empat tes di atas selesai dalam waktu kurang dari satu milidetik per kasus, sehingga ribuan tes unit dapat dijalankan pada setiap kali penyimpanan berkas tanpa memperlambat penyuntingan.

Apa yang diuji pada level integrasi#

Pengujian integrasi menguji rangkaian beberapa modul yang bekerja sama, termasuk jalur data antar-modul dan kontrak antarmuka yang dipegang tiap modul. Berbeda dari tes unit yang mengisolasi fungsi, tes integrasi membiarkan dependensi nyata terlibat sehingga kesalahan pada antarmuka antar-modul dapat tertangkap. Pada server HTTP, tes integrasi yang umum adalah permintaan ke endpoint yang melibatkan routing, middleware, validasi masukan, dan lapisan akses data.

Berkas format-user.js berikut menyusun nama dan umur menjadi satu string, tetapi memanggil validasiNama dan validasiUmur dari modul validator.js. Tes pada format-user.js termasuk integrasi karena keberhasilannya bergantung pada kontrak validator.js, bukan hanya pada logika penyusunan string.

js format-user.js
// tidak-dijalankan: butuh sibling validator.js di direktori yang sama dengan berkas sumber
const { validasiNama, validasiUmur } = require('./validator');

function formatUser({ nama, umur }) {
  const cekNama = validasiNama(nama);
  if (!cekNama.ok) throw new Error(cekNama.pesan);
  const cekUmur = validasiUmur(umur);
  if (!cekUmur.ok) throw new Error(cekUmur.pesan);
  return `${nama} (${umur} tahun)`;
}

module.exports = { formatUser };
js format-user.test.js
// tidak-dijalankan: butuh sibling format-user.js dan validator.js di direktori yang sama dengan berkas sumber
const test = require('node:test');
const assert = require('node:assert/strict');
const { formatUser } = require('./format-user');

test('formatUser menyusun nama dan umur yang valid', () => {
  assert.equal(formatUser({ nama: 'Ali', umur: 30 }), 'Ali (30 tahun)');
});

test('formatUser melempar error bila nama kosong', () => {
  assert.throws(() => formatUser({ nama: '', umur: 30 }), /tidak boleh kosong/);
});

test('formatUser melempar error bila umur desimal', () => {
  assert.throws(() => formatUser({ nama: 'Ali', umur: 25.5 }), /bilangan bulat/);
});
bash
# jalankan dari direktori topik
node --test --test-reporter tap format-user.test.js 2>&1 | sed -E '/^[[:space:]]+---$/,/^[[:space:]]+\.\.\.$/d; /^# duration_ms/d'
text Keluaran
TAP version 13
# Subtest: formatUser menyusun nama dan umur yang valid
ok 1 - formatUser menyusun nama dan umur yang valid
# Subtest: formatUser melempar error bila nama kosong
ok 2 - formatUser melempar error bila nama kosong
# Subtest: formatUser melempar error bila umur desimal
ok 3 - formatUser melempar error bila umur desimal
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0

Tes integrasi yang menarik perhatian adalah tes kedua dan ketiga, yang memastikan error dari validator.js diteruskan sebagai pengecualian oleh formatUser. Bila suatu hari validator.js diubah agar mengembalikan { ok: false } tetapi tidak lagi memuat pesan, kedua tes tersebut akan gagal karena assert.throws tidak menemukan pola yang diharapkan. Kegagalan semacam ini menunjukkan bahwa kontrak antar-modul telah bergeser, bukan sekadar bahwa logika formatUser rusak.

Piramida pengujian#

Piramida pengujian adalah kerangka yang menyusun lapisan uji berdasarkan jumlah dan kecepatan. Lapisan bawah adalah tes unit yang paling banyak dan paling cepat, lapisan tengah adalah tes integrasi yang lebih sedikit dan lebih lambat, sedangkan lapisan puncak adalah tes end-to-end (E2E) yang paling sedikit dan paling lambat. Bentuk piramida menyatakan proporsi yang sehat, yaitu mayoritas tes berada di lapisan cepat dan murah, sedangkan tes lambat hanya dipakai untuk skenario yang benar-benar memerlukan seluruh tumpukan.

UnitIntegrasiE2Ebanyak tes, cepatsedikit tes, lambat
Piramida pengujian: unit di bawah, integrasi di tengah, E2E di puncak

Lapisan E2E pada puncak memerlukan browser otomatis atau klien HTTP yang menembus seluruh tumpukan aplikasi, termasuk basis data nyata dan reverse proxy. Karena kompleksitas dan kecepatannya, tes E2E tidak dipakai untuk setiap kasus, melainkan untuk skenario kritis seperti pendaftaran pengguna atau alur pembayaran. Lapisan integrasi menutup celah antara unit yang terisolasi dan E2E yang lengkap, sehingga perubahan kecil pada satu modul dapat diverifikasi tanpa menjalankan keseluruhan tumpukan.

Menentukan batas unit dan integrasi#

Batasan antara tes unit dan integrasi ditentukan oleh dependensi yang dipanggil, bukan oleh nama berkas atau letak direktori. Tes yang memanggil satu fungsi murni tanpa require modul lain adalah tes unit. Tes integrasi adalah tes yang memulai server, membaca berkas, atau memanggil fungsi yang mengimpor modul lain. Pemisahan ini penting karena tes integrasi cenderung lebih lambat dan lebih rapuh, sehingga kegagalannya perlu ditelusuri dengan informasi yang berbeda.

Sebuah konvensi yang umum dipakai adalah menamai berkas *.test.js untuk tes unit dan *.integration.test.js untuk tes integrasi. Pola ini memungkinkan perintah node --test '**/*.test.js' dijalankan tanpa argumen tambahan, sementara tes integrasi dijalankan terpisah dengan pola **/*.integration.test.js. Pemisahan ini juga memudahkan pembacaan output TAP karena nama berkas sudah menyatakan lapisannya.

Kesalahan umum#

Menganggap semua tes yang memakai node:test adalah tes unit. Penamaan berkas dengan .test.js tidak otomatis membuat tes menjadi unit. Bila tes memanggil modul yang membuka soket jaringan atau membaca berkas, tes tersebut adalah integrasi dan biayanya lebih tinggi daripada yang terlihat dari namanya.

Menulis tes unit yang mengejek seluruh dependensi hingga tes hanya menguji mock, bukan kode nyata. Tes yang penuh mock lulus tetapi tidak menangkap bug pada kode yang dipanggil. Pertimbangkan untuk memindahkannya ke lapisan integrasi dan biarkan dependensi nyata terlibat.

Mengabaikan lapisan integrasi dan hanya mengandalkan E2E. Tes E2E lambat dan kegagalannya sulit ditelusuri karena melibatkan banyak komponen. Tes integrasi yang fokus pada satu batas modul memberi sinyal yang lebih cepat dan lebih jelas, sehingga kesalahan dapat ditemukan sebelum E2E dijalankan.

Ringkasan#

  • Tes unit menguji satu fungsi murni dalam isolasi tanpa dependensi eksternal, berjalan cepat, dan kegagalannya langsung menunjuk ke fungsi yang diuji.
  • Tes integrasi menguji rangkaian beberapa modul yang bekerja sama, termasuk kontrak antar-modul, dan biasanya melibatkan dependensi nyata.
  • Piramida pengujian menyusun lapisan unit (banyak, cepat) di bawah, integrasi di tengah, dan E2E (sedikit, lambat) di puncak, dengan proporsi yang sehat didominasi oleh tes unit.
  • Batas antara unit dan integrasi ditentukan oleh dependensi yang dipanggil, sehingga pemisahan berkas berdasarkan sufiks .integration.test.js membantu pemilihan subset tes yang dijalankan.

Referensi#