kodingup.camp

Beranda/Kualitas Web: Aksesibilitas, Performa, Keamanan, Testing & Tooling

Testing Dasar

Modul 5 dari 87 menit bacaTesting Fundamentals

Isi modul

    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:

    1. Arrange (siapkan): data, DOM awal, atau kondisi yang dibutuhkan.
    2. Act (lakukan): panggil fungsi atau simulasikan aksi pengguna.
    3. 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:

    Test yang baik

    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('<', '&lt;')
        .replaceAll('>', '&gt;')
        .replaceAll('"', '&quot;')
        .replaceAll("'", '&#39;');
    }
    
    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(
          '&lt;img src=x onerror=alert(1)&gt;',
        );
      });
    });
    
    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

    1. Mencari elemen lewat class CSS (document.querySelector('.btn-primary')). Test patah setiap kali desain berubah. Pakai getByRole/getByLabelText.
    2. Lupa await pada user-event. Semua method user.* mengembalikan Promise. Tanpa await, assertion jalan sebelum ketikan selesai dan hasilnya acak.
    3. Test saling bergantung. Test kedua lulus hanya karena test pertama sudah mengisi DOM. Reset kondisi di beforeEach.
    4. toBe untuk object/array. expect([1, 2]).toBe([1, 2]) gagal karena dua array berbeda referensi. Pakai toEqual.
    5. Hanya menguji jalur sukses. Bug sering bersembunyi di input kosong, spasi, atau tipe yang salah. Tambahkan kasus tepi.

    Latihan

    1. 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.
    2. Sedang, Tambahkan validasi ke hitungDiskon: lempar RangeError jika persen di luar 0-100. Tulis test untuk -5, 0, 100, dan 101 dengan it.each.
    3. 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

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    React Testing Full Course 2026 | Vitest and React Testing Library TutorialRoadsideCoder · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.