Kenapa ini penting
Bayangkan kamu montir yang baru mengganti kampas rem mobil pelanggan. Kamu tidak akan langsung menyerahkan kunci sambil berkata "harusnya sih aman". Kamu pasti mencobanya dulu: injak rem di tempat, jalan pelan, rem mendadak. Testing adalah kebiasaan yang sama untuk kode: membuktikan bahwa kode bekerja, bukan berharap.
Selama ini kamu mungkin menguji dengan cara manual: buka browser, isi form, klik tombol, lihat Console. Cara itu baik untuk awal, tapi ada tiga masalah. Pertama, lambat: kalau ada 30 skenario, kamu harus mengulang 30 kali setiap kali mengubah kode. Kedua, mudah lupa: kamu mengubah fungsi format harga untuk halaman keranjang, lalu tidak sadar halaman invoice ikut rusak. Kesalahan seperti ini disebut regresi, yaitu fitur yang tadinya jalan menjadi rusak karena perubahan lain. Ketiga, tidak bisa dibagikan: rekan timmu tidak tahu skenario apa saja yang sudah kamu cek.
Test otomatis menyelesaikan ketiganya. Kamu menulis skenario sekali dalam bentuk kode, lalu menjalankannya dalam hitungan detik, kapan pun, oleh siapa pun, termasuk oleh server CI setiap kali ada commit. Bonusnya, test yang baik juga berfungsi sebagai dokumentasi hidup: membaca it('melempar error untuk input bukan angka') langsung memberi tahu perilaku fungsi itu.
MDN Curriculum membagi testing menjadi beberapa jenis: functional (fitur berfungsi), compatibility (jalan di berbagai browser), usability (mudah dipakai, biasanya diuji manual dengan 5-10 pengguna sungguhan), performance, security, dan accessibility. Modul ini fokus ke functional test yang bisa diotomatisasi: unit test dan test DOM. Modul berikutnya membahas end-to-end dan compatibility.
Konsep inti
Piramida test
Tidak semua test sama. Ada yang cepat dan sempit, ada yang lambat tapi menyeluruh. Piramida test menggambarkan komposisi yang sehat:
/\
/E2E\ sedikit, lambat, mahal, tapi paling mirip pengguna asli
/------\
/integrasi\ sedang, beberapa bagian bekerja bersama (mis. form + validasi + DOM)
/------------\
/ unit test \ banyak, cepat (milidetik), murah, fokus satu fungsi
/----------------\
| Jenis | Apa yang diuji | Kecepatan | Contoh alat |
|---|---|---|---|
| Unit | Satu fungsi/modul secara terisolasi | Sangat cepat | Vitest, Jest |
| Integrasi | Beberapa unit bekerja bersama, misalnya fungsi yang memanipulasi DOM | Cepat | Vitest + jsdom + Testing Library |
| End-to-end (E2E) | Aplikasi utuh di browser sungguhan, dari sudut pandang pengguna | Lambat | Playwright, Cypress |
Banyak tim modern memakai variasi "trofi" yang memberi porsi lebih besar ke test integrasi, karena test itu paling sering menangkap bug nyata. Intinya sama: test yang murah dibuat banyak, test yang mahal dibuat secukupnya untuk alur penting.
Anatomi sebuah test: Arrange, Act, Assert
Hampir setiap test mengikuti pola AAA:
- Arrange (siapkan): data, DOM awal, atau kondisi yang dibutuhkan.
- Act (lakukan): panggil fungsi atau simulasikan aksi pengguna.
- Assert (pastikan): cek hasilnya sesuai harapan.
Vitest: test runner untuk dunia Vite
Vitest adalah test runner (program yang menemukan, menjalankan, dan melaporkan test) yang dibangun di atas Vite. Kelebihannya: memakai konfigurasi Vite yang sama, langsung mendukung ES modules (import/export) tanpa konfigurasi tambahan, sangat cepat, dan API-nya kompatibel dengan Jest yang banyak dipakai di tutorial. Versi saat materi ini ditulis adalah Vitest 5.0, yang membutuhkan Node.js 22.12+ dan Vite 6.4+.
Konsep dasar API-nya:
| API | Fungsi |
|---|---|
describe('nama', fn) |
Mengelompokkan test yang berkaitan |
it('perilaku', fn) atau test(...) |
Satu kasus test. Tulis namanya sebagai kalimat perilaku |
expect(nilai) |
Membuat assertion |
.toBe(x) |
Sama persis (Object.is), untuk nilai primitif |
.toEqual(obj) |
Isi sama, untuk object/array |
.toThrow(Tipe) |
Fungsi melempar error |
.toContain(x) |
Array/string berisi x |
it.each([...]) |
Menjalankan test yang sama dengan banyak data |
beforeEach(fn) |
Dijalankan sebelum setiap test, untuk menyiapkan kondisi bersih |
vi.fn() |
Membuat fungsi palsu (mock) untuk mencatat pemanggilan |
Vitest menemukan file test otomatis: nama file harus mengandung .test. atau .spec., misalnya utils.test.js. Perintah vitest menjalankan test dalam watch mode (otomatis mengulang saat file disimpan), sedangkan vitest run menjalankan sekali lalu selesai, cocok untuk CI.
Test DOM: jsdom dan Testing Library
Node.js tidak punya document atau window. Untuk menguji kode yang memanipulasi DOM tanpa membuka browser, Vitest bisa memakai jsdom, implementasi DOM yang ditulis dalam JavaScript murni. Aktifkan per file dengan komentar // @vitest-environment jsdom di baris pertama, atau untuk semua test lewat test.environment: 'jsdom' di konfigurasi.
Di atasnya, Testing Library memberi cara mencari elemen seperti pengguna mencarinya: lewat peran dan label, bukan lewat class CSS atau struktur DOM. Prinsipnya: semakin mirip test-mu dengan cara software dipakai, semakin besar kepercayaan yang diberikannya.
| Query | Mencari berdasarkan | Kapan dipakai |
|---|---|---|
getByRole('button', { name: 'Daftar' }) |
Role + nama aksesibel | Pilihan pertama untuk hampir semua elemen interaktif |
getByLabelText('Email') |
Teks label input | Input form |
getByText(/berhasil/i) |
Teks yang terlihat | Paragraf, pesan |
getByTestId('x') |
Atribut data-testid |
Jalan terakhir kalau tidak ada cara lain |
Perhatikan hubungannya dengan modul Aksesibilitas: kalau tombolmu tidak punya nama aksesibel atau input tidak punya label, getByRole dan getByLabelText gagal. Testing Library memaksa kodemu aksesibel.
Pelengkapnya:
@testing-library/user-eventmensimulasikan interaksi lengkap (mengetik huruf per huruf, klik, Tab, Enter), lebih realistis daripada memanggilelement.click().@testing-library/jest-dommenambah matcher yang mudah dibaca:toBeVisible(),toHaveFocus(),toHaveAttribute(),toHaveTextContent(). Untuk Vitest, impor@testing-library/jest-dom/vitest.
Test yang baik
- Uji perilaku, bukan implementasi. Test tidak peduli apakah kamu memakai
forataureduce. Test peduli hasilnya. - Satu test, satu alasan untuk gagal. Kalau gagal, nama test langsung memberi tahu apa yang rusak.
- Independen. Setiap test menyiapkan kondisinya sendiri (
beforeEach), tidak bergantung pada urutan. - Uji kasus tepi (edge case): string kosong, spasi di awal/akhir, angka negatif, input yang salah tipe.
- Lihat test gagal dulu. Test yang belum pernah merah bisa jadi tidak menguji apa-apa. Sengaja rusak kodenya sebentar untuk memastikan test menangkapnya.
Contoh kode
Siapkan proyek (kalau sudah punya proyek Vite dari modul sebelumnya, lewati baris pertama):
npm create vite@latest newsletter -- --template vanilla
cd newsletter
npm install
npm install -D vitest jsdom @testing-library/dom @testing-library/user-event @testing-library/jest-dom
Tambahkan script di package.json, lalu buat konfigurasi Vitest:
npm pkg set scripts.test="vitest"
// vite.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom', // semua test punya document & window
include: ['src/**/*.test.js'],
setupFiles: ['./vitest.setup.js'],
},
});
// vitest.setup.js, matcher tambahan untuk semua file test
import '@testing-library/jest-dom/vitest';
Fungsi yang akan diuji (lengkap dengan escapeHTML dari modul Keamanan):
// src/utils.js
export function escapeHTML(teks) {
return String(teks)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
export function isEmailValid(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
}
export function formatRupiah(angka) {
if (!Number.isFinite(angka)) throw new TypeError('angka harus number');
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(angka);
}
Unit test:
// src/utils.test.js
import { describe, it, expect } from 'vitest';
import { escapeHTML, isEmailValid, formatRupiah } from './utils.js';
describe('escapeHTML', () => {
it('mengubah tag jadi teks biasa', () => {
expect(escapeHTML('<img src=x onerror=alert(1)>')).toBe(
'<img src=x onerror=alert(1)>',
);
});
});
describe('isEmailValid', () => {
// it.each: satu test, banyak data (termasuk kasus tepi)
it.each([
['budi@contoh.id', true],
['budi@contoh', false],
[' sari@mail.com ', true],
['', false],
])('%s → %s', (email, hasil) => {
expect(isEmailValid(email)).toBe(hasil);
});
});
describe('formatRupiah', () => {
it('memformat angka ke Rupiah', () => {
// Intl memakai NO-BREAK SPACE (U+00A0) setelah "Rp", bukan spasi biasa!
expect(formatRupiah(150000)).toBe('Rp 150.000');
});
it('melempar error untuk input bukan angka', () => {
expect(() => formatRupiah('150000')).toThrow(TypeError);
});
});
Saat pertama menulis test formatRupiah, kami menulis 'Rp 150.000' dengan spasi biasa, dan test gagal dengan pesan yang membingungkan: expected 'Rp 150.000' to be 'Rp 150.000'. Keduanya terlihat sama, tapi Intl.NumberFormat memakai karakter no-break space (kode U+00A0) supaya "Rp" dan angkanya tidak terpisah baris. Inilah contoh nyata test menangkap hal yang tidak akan kamu sadari lewat mata.
Test DOM untuk form newsletter dari modul Aksesibilitas:
// src/form.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { setupForm } from './form.js';
describe('form newsletter', () => {
beforeEach(() => {
// Arrange: DOM bersih untuk setiap test
document.body.innerHTML = '<div id="app"></div>';
setupForm(document.querySelector('#app'));
});
it('menampilkan error saat email salah', async () => {
const user = userEvent.setup();
// Act: cari elemen seperti pengguna mencarinya (label & role)
await user.type(screen.getByLabelText('Email'), 'budi@');
await user.click(screen.getByRole('button', { name: 'Daftar' }));
// Assert
expect(screen.getByText(/format email belum benar/i)).toBeVisible();
expect(screen.getByLabelText('Email')).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByLabelText('Email')).toHaveFocus();
});
it('menampilkan pesan sukses dan aman dari XSS', async () => {
const user = userEvent.setup();
await user.type(screen.getByLabelText('Email'), '<b>x</b>@contoh.id');
await user.keyboard('{Enter}'); // submit lewat keyboard, bukan klik
const status = screen.getByRole('status');
expect(status).toHaveTextContent('<b>x</b>@contoh.id sudah terdaftar');
expect(status.querySelector('b')).toBeNull(); // tag tidak dirender sebagai HTML
});
});
Jalankan:
npx vitest run
# → ✓ src/utils.test.js (7 tests)
# → ✓ src/form.test.js (2 tests)
# → Test Files 2 passed (2)
# → Tests 9 passed (9)
# → Duration ~1s
npm test # watch mode: tekan q untuk keluar
Mau tahu bagian kode mana yang belum tersentuh test? Pasang modul coverage (versinya harus sama dengan Vitest), lalu jalankan dengan --coverage:
npm install -D @vitest/coverage-v8
npx vitest run --coverage
# → tabel % Stmts / Branch / Funcs / Lines per file
Coverage 100% bukan tujuan. Coverage hanya menunjukkan baris mana yang dijalankan, bukan apakah hasilnya dicek dengan benar. Pakai untuk menemukan bagian yang terlupa.
Kesalahan umum
- Mencari elemen lewat class CSS (
document.querySelector('.btn-primary')). Test patah setiap kali desain berubah. PakaigetByRole/getByLabelText. - Lupa
awaitpada user-event. Semua methoduser.*mengembalikan Promise. Tanpaawait, assertion jalan sebelum ketikan selesai dan hasilnya acak. - Test saling bergantung. Test kedua lulus hanya karena test pertama sudah mengisi DOM. Reset kondisi di
beforeEach. toBeuntuk object/array.expect([1, 2]).toBe([1, 2])gagal karena dua array berbeda referensi. PakaitoEqual.- Hanya menguji jalur sukses. Bug sering bersembunyi di input kosong, spasi, atau tipe yang salah. Tambahkan kasus tepi.
Latihan
- Mudah, Tulis unit test untuk fungsi
hitungDiskon(harga, persen)yang mengembalikan harga setelah diskon, dibulatkan ke bawah. Uji: 100000 diskon 15% → 85000, dan 99999 diskon 10% → 89999. - Sedang, Tambahkan validasi ke
hitungDiskon: lemparRangeErrorjika persen di luar 0-100. Tulis test untuk -5, 0, 100, dan 101 denganit.each. - Menantang, Buat fungsi
setupCounter(button)yang menampilkan "Diklik 0 kali" dan bertambah setiap diklik. Tulis test DOM dengan Testing Library yang mengklik tiga kali dan memastikan teks menjadi "Diklik 3 kali", serta bisa ditekan dengan keyboard (Tab lalu Enter).
Lihat solusi
1 & 2.
// src/diskon.js
export function hitungDiskon(harga, persen) {
if (persen < 0 || persen > 100) throw new RangeError('persen harus 0-100');
return Math.floor(harga * (1 - persen / 100));
}
// src/diskon.test.js
import { describe, it, expect } from 'vitest';
import { hitungDiskon } from './diskon.js';
describe('hitungDiskon', () => {
it('menghitung harga setelah diskon', () => {
expect(hitungDiskon(100000, 15)).toBe(85000);
});
it('membulatkan ke bawah', () => {
expect(hitungDiskon(99999, 10)).toBe(89999); // 89999.1 → 89999
});
it.each([
[-5, true],
[0, false],
[100, false],
[101, true],
])('persen %i → error? %s', (persen, error) => {
const panggil = () => hitungDiskon(1000, persen);
if (error) expect(panggil).toThrow(RangeError);
else expect(panggil).not.toThrow();
});
});
3.
// src/counter.js
export function setupCounter(button) {
let jumlah = 0;
const render = () => (button.textContent = `Diklik ${jumlah} kali`);
button.addEventListener('click', () => {
jumlah += 1;
render();
});
render();
}
// src/counter.test.js
import { it, expect } from 'vitest';
import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { setupCounter } from './counter.js';
it('menghitung klik keyboard dan mouse', async () => {
document.body.innerHTML = '<button type="button"></button>';
setupCounter(document.querySelector('button'));
const user = userEvent.setup();
const tombol = screen.getByRole('button', { name: 'Diklik 0 kali' });
// Keyboard dulu: belum ada elemen yang fokus, jadi Tab mendarat di tombol
await user.tab();
expect(tombol).toHaveFocus();
await user.keyboard('{Enter}');
expect(tombol).toHaveTextContent('Diklik 1 kali');
await user.click(tombol);
await user.click(tombol);
expect(tombol).toHaveTextContent('Diklik 3 kali');
});
Catatan: kalau urutannya dibalik (klik dulu, baru user.tab()), test gagal. Setelah diklik, tombol sudah fokus, sehingga Tab justru memindahkan fokus keluar dari tombol. Kami mengalaminya sendiri saat menguji solusi ini.
Ringkasan
- Test otomatis membuktikan kode bekerja, mencegah regresi, dan menjadi dokumentasi hidup.
- Piramida test: banyak unit test (cepat), cukup test integrasi, sedikit E2E (lambat tapi realistis).
- Pola Arrange-Act-Assert. API Vitest:
describe,it,expect,it.each,beforeEach. - Vitest 5 butuh Node 22.12+.
vitest= watch mode,vitest run= sekali jalan (CI). - jsdom menyediakan DOM di Node. Testing Library mencari elemen lewat role dan label, sehingga test sekaligus mendorong aksesibilitas.
- Selalu uji kasus tepi dan pastikan test pernah gagal sebelum dipercaya.