Panduan Testing Vitest: Dari Nol ke Kepercayaan Produksi
Unit tests, integration tests, component tests dengan Testing Library, strategi mocking, konfigurasi coverage, dan filosofi testing yang benar-benar menghasilkan software lebih baik.
Kebanyakan tim menulis test seperti kebanyakan orang berolahraga: mereka tahu seharusnya melakukannya, mereka merasa bersalah saat tidak melakukan, dan saat akhirnya melakukan, mereka memaksakan diri terlalu keras di hari pertama lalu berhenti di hari Jumat. Codebase berakhir dengan sebaran snapshot test dangkal yang rusak setiap kali seseorang mengubah kelas CSS, beberapa integration test yang tidak dipercaya siapa pun, dan badge coverage di README yang berbohong.
Saya sudah pernah di kedua sisi. Saya pernah mengirim proyek tanpa test sama sekali dan deg-degan setiap deploy. Saya juga pernah di tim yang mengejar 100% coverage dan menghabiskan lebih banyak waktu memelihara test daripada menulis fitur. Keduanya tidak berhasil. Yang berhasil adalah menulis test yang tepat, di tempat yang tepat, dengan alat yang tepat.
Vitest mengubah cara saya berpikir tentang testing di JavaScript. Bukan karena ia menemukan konsep baru — fundamental-nya tidak berubah sejak Kent Beck menulis tentangnya puluhan tahun lalu. Tapi karena ia menghilangkan cukup banyak gesekan sehingga menulis test berhenti terasa seperti tugas berat dan mulai terasa seperti bagian dari loop pengembangan. Saat test runner-mu secepat dev server-mu dan menggunakan konfigurasi yang sama, alasan-alasan untuk tidak menulis test menguap.
Posting ini adalah semua yang saya tahu tentang testing dengan Vitest, dari pengaturan awal sampai filosofi yang membuat semuanya berharga.
Mengapa Vitest Dibanding Jest#
Jika kamu pernah menggunakan Jest, kamu sudah tahu sebagian besar API Vitest. Itu memang disengaja — Vitest kompatibel dengan Jest di level API. describe, it, expect, beforeEach, vi.fn() — semuanya berfungsi. Jadi mengapa beralih?
Dukungan ESM Native#
Jest dibangun untuk CommonJS. Ia bisa menangani ESM, tapi memerlukan konfigurasi, flag eksperimental, dan sesekali doa. Jika kamu menggunakan sintaks import/export (yang berarti semua hal modern), kamu mungkin pernah bertengkar dengan pipeline transform Jest.
Vitest berjalan di Vite. Vite memahami ESM secara native. Tidak ada langkah transform untuk source code-mu — langsung berjalan. Ini lebih penting daripada kedengarannya. Setengah dari masalah Jest yang pernah saya debug selama bertahun-tahun bisa ditelusuri kembali ke resolusi modul: SyntaxError: Cannot use import statement outside a module, atau mock yang tidak berfungsi karena modul sudah di-cache dalam format berbeda.
Konfigurasi Sama dengan Dev Server-mu#
Jika proyekmu menggunakan Vite (dan jika kamu membangun aplikasi React, Vue, atau Svelte di 2026, kemungkinan besar iya), Vitest membaca vite.config.ts secara otomatis. Path alias, plugin, dan variabel environment-mu bekerja di test tanpa konfigurasi tambahan. Dengan Jest, kamu memelihara konfigurasi paralel yang harus tetap sinkron dengan setup bundler-mu. Setiap kali kamu menambahkan path alias di vite.config.ts, kamu harus ingat menambahkan moduleNameMapper yang sesuai di jest.config.ts. Ini hal kecil, tapi hal kecil menumpuk.
Kecepatan#
Vitest itu cepat. Cepat secara bermakna. Bukan "menghemat dua detik" cepat — "mengubah cara kerjamu" cepat. Ia menggunakan module graph Vite untuk memahami test mana yang terpengaruh oleh perubahan file dan hanya menjalankan itu. Watch mode-nya menggunakan infrastruktur HMR yang sama yang membuat dev server Vite terasa instan.
Di proyek dengan 400+ test, beralih dari Jest ke Vitest memotong feedback loop watch-mode kami dari ~4 detik menjadi di bawah 500ms. Itu perbedaan antara "saya akan tunggu test-nya lulus" dan "saya akan lirik terminal sementara jari-jari saya masih di keyboard."
Benchmarking Sudah Built-In#
Vitest menyertakan bench() langsung dari paketnya untuk pengujian performa. Tidak perlu library terpisah:
import { bench, describe } from "vitest";
describe("penggabungan string", () => {
bench("template literals", () => {
const name = "world";
const _result = `hello ${name}`;
});
bench("string concat", () => {
const name = "world";
const _result = "hello " + name;
});
});Jalankan dengan vitest bench. Ini bukan acara utama, tapi bagus memiliki pengujian performa dalam toolchain yang sama tanpa menginstal benchmark.js dan menyiapkan runner terpisah.
Pengaturan#
Instalasi#
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomKonfigurasi#
Buat vitest.config.ts di root proyek:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
alias: {
"@": path.resolve(__dirname, "./src"),
},
css: false,
},
});Atau, jika kamu sudah punya vite.config.ts, kamu bisa memperpanjangnya:
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": "/src",
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});Keputusan globals: true#
Saat globals bernilai true, kamu tidak perlu mengimpor describe, it, expect, beforeEach, dll. — mereka tersedia di mana-mana, sama seperti di Jest. Saat false, kamu mengimpornya secara eksplisit:
// globals: false
import { describe, it, expect } from "vitest";
describe("matematika", () => {
it("menjumlahkan angka", () => {
expect(1 + 1).toBe(2);
});
});Saya menggunakan globals: true karena mengurangi noise visual dan sesuai dengan ekspektasi kebanyakan developer. Jika kamu di tim yang menghargai impor eksplisit, atur ke false — tidak ada jawaban yang salah di sini.
Jika kamu menggunakan globals: true, tambahkan tipe Vitest ke tsconfig.json agar TypeScript mengenalinya:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}Environment: jsdom vs happy-dom vs node#
Vitest memungkinkanmu memilih implementasi DOM per test atau secara global:
node— Tanpa DOM. Untuk logika murni, utilitas, API route, dan apa pun yang tidak menyentuh browser.jsdom— Standar. Implementasi DOM lengkap. Lebih berat tapi lebih lengkap.happy-dom— Lebih ringan dan lebih cepat dari jsdom tapi kurang lengkap. Beberapa kasus edge (sepertiRange,Selection, atauIntersectionObserver) mungkin tidak berfungsi.
Saya default ke jsdom secara global dan override per-file saat memerlukan node:
// src/lib/utils.test.ts
// @vitest-environment node
import { formatDate, slugify } from "./utils";
describe("slugify", () => {
it("mengonversi spasi menjadi tanda hubung", () => {
expect(slugify("hello world")).toBe("hello-world");
});
});File Setup#
File setup berjalan sebelum setiap file test. Di sinilah kamu mengonfigurasi matcher Testing Library dan mock global:
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
// Mock IntersectionObserver — jsdom tidak mengimplementasikannya
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
value: MockIntersectionObserver,
});
// Mock window.matchMedia — diperlukan untuk komponen responsif
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});Impor @testing-library/jest-dom/vitest memberikanmu matcher seperti toBeInTheDocument(), toHaveClass(), toBeVisible(), dan banyak lainnya. Ini membuat assertion pada elemen DOM mudah dibaca dan ekspresif.
Menulis Test yang Baik#
Pola AAA#
Setiap test mengikuti struktur yang sama: Arrange (Siapkan), Act (Lakukan), Assert (Verifikasi). Bahkan saat kamu tidak menulis komentar eksplisit, strukturnya harus terlihat:
it("menghitung total dengan pajak", () => {
// Arrange
const items = [
{ name: "Widget", price: 10 },
{ name: "Gadget", price: 20 },
];
const taxRate = 0.08;
// Act
const total = calculateTotal(items, taxRate);
// Assert
expect(total).toBe(32.4);
});Saat saya melihat test yang mencampurkan persiapan, aksi, dan assertion menjadi satu rantai pemanggilan metode, saya tahu akan sulit dipahami saat gagal. Jaga ketiga fase tetap terlihat berbeda secara visual meskipun kamu tidak menambahkan komentar.
Penamaan Test#
Ada dua aliran: it('should calculate total with tax') dan it('calculates total with tax'). Prefiks "should" bertele-tele tanpa menambahkan informasi. Saat test gagal, kamu akan melihat:
FAIL ✕ calculates total with tax
Itu sudah kalimat lengkap. Menambahkan "should" hanya menambah noise. Saya lebih suka bentuk langsung: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').
Untuk blok describe, gunakan nama unit yang diuji:
describe("calculateTotal", () => {
it("menjumlahkan harga item", () => { /* ... */ });
it("menerapkan tarif pajak", () => { /* ... */ });
it("mengembalikan 0 untuk array kosong", () => { /* ... */ });
it("menangani harga negatif", () => { /* ... */ });
});Baca dengan suara keras: "calculateTotal menjumlahkan harga item." "calculateTotal menerapkan tarif pajak." Jika kalimatnya masuk akal, penamaannya berhasil.
Satu Assertion Per Test vs Pengelompokan Praktis#
Aturan puris mengatakan satu assertion per test. Aturan praktis mengatakan: satu konsep per test. Ini berbeda.
// Ini baik-baik saja — satu konsep, banyak assertion
it("memformat nama tampilan pengguna", () => {
const user = { firstName: "John", lastName: "Doe", title: "Dr." };
const result = formatDisplayName(user);
expect(result).toContain("John");
expect(result).toContain("Doe");
expect(result).toStartWith("Dr.");
});
// Ini tidak baik — banyak konsep dalam satu test
it("menangani operasi pengguna", () => {
const user = createUser("John");
expect(user.id).toBeDefined();
const updated = updateUser(user.id, { name: "Jane" });
expect(updated.name).toBe("Jane");
deleteUser(user.id);
expect(getUser(user.id)).toBeNull();
});Test pertama punya tiga assertion tapi semuanya memverifikasi satu hal: format nama tampilan. Jika ada assertion yang gagal, kamu tahu persis apa yang rusak. Test kedua adalah tiga test terpisah yang dijejal jadi satu. Jika assertion kedua gagal, kamu tidak tahu apakah pembuatan atau pembaruan yang rusak, dan assertion ketiga tidak pernah berjalan.
Deskripsi Test sebagai Dokumentasi#
Suite test yang baik berfungsi sebagai dokumentasi hidup. Seseorang yang tidak familiar dengan kode seharusnya bisa membaca deskripsi test dan memahami perilaku fitur:
describe("PasswordValidator", () => {
describe("panjang minimum", () => {
it("menolak password lebih pendek dari 8 karakter", () => { /* ... */ });
it("menerima password dengan tepat 8 karakter", () => { /* ... */ });
});
describe("persyaratan karakter", () => {
it("memerlukan setidaknya satu huruf besar", () => { /* ... */ });
it("memerlukan setidaknya satu angka", () => { /* ... */ });
it("memerlukan setidaknya satu karakter khusus", () => { /* ... */ });
});
describe("pemeriksaan password umum", () => {
it("menolak password dalam daftar password umum", () => { /* ... */ });
it("melakukan perbandingan case-insensitive", () => { /* ... */ });
});
});Saat suite test ini berjalan, outputnya terbaca seperti spesifikasi. Itulah tujuannya.
Mocking#
Mocking adalah alat paling powerful dan paling berbahaya dalam toolkit testing-mu. Digunakan dengan baik, ia mengisolasi unit yang diuji dan membuat test cepat dan deterministik. Digunakan dengan buruk, ia membuat test yang lulus apa pun yang dilakukan kode.
vi.fn() — Membuat Fungsi Mock#
Mock paling sederhana adalah fungsi yang mencatat pemanggilannya:
const mockCallback = vi.fn();
// Panggil
mockCallback("hello", 42);
mockCallback("world");
// Assert pada pemanggilan
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");Kamu bisa memberikan nilai kembalian:
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: "Test" }),
});Atau membuatnya mengembalikan nilai berbeda pada pemanggilan berurutan:
const mockRandom = vi.fn()
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.9);vi.spyOn() — Mengamati Metode Asli#
Saat kamu ingin mengamati metode tanpa mengganti perilakunya:
const consoleSpy = vi.spyOn(console, "warn");
validateInput("");
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();spyOn mempertahankan implementasi asli secara default. Kamu bisa meng-override-nya dengan .mockImplementation() saat diperlukan tapi kembalikan yang asli setelahnya dengan .mockRestore().
vi.mock() — Mocking Level Modul#
Ini yang besar. vi.mock() menggantikan seluruh modul:
// Mock seluruh modul
vi.mock("@/lib/api", () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]),
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
// Impor sekarang menggunakan versi yang di-mock
import { fetchUsers } from "@/lib/api";
describe("UserList", () => {
it("menampilkan pengguna dari API", async () => {
const users = await fetchUsers();
expect(users).toHaveLength(2);
});
});Vitest meng-hoist panggilan vi.mock() ke atas file secara otomatis. Ini berarti mock sudah siap sebelum impor apa pun berjalan. Kamu tidak perlu khawatir tentang urutan impor.
Mocking Otomatis#
Jika kamu hanya ingin setiap ekspor diganti dengan vi.fn():
vi.mock("@/lib/analytics");
import { trackEvent, trackPageView } from "@/lib/analytics";
it("melacak pengiriman form", () => {
submitForm();
expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});Tanpa fungsi factory, Vitest secara otomatis mem-mock semua ekspor. Setiap fungsi yang diekspor menjadi vi.fn() yang mengembalikan undefined. Ini berguna untuk modul yang ingin kamu diamkan (seperti analytics atau logging) tanpa menentukan setiap fungsi.
Clearing vs Resetting vs Restoring#
Ini membingungkan semua orang di beberapa titik:
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
// mockClear — mereset riwayat panggilan, mempertahankan implementasi
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // masih mengembalikan 42
// mockReset — mereset riwayat panggilan DAN implementasi
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // tidak lagi mengembalikan 42
// mockRestore — untuk spy, mengembalikan implementasi asli
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() sekarang berfungsi normal kembaliDalam praktik, gunakan vi.clearAllMocks() di beforeEach untuk mereset riwayat panggilan antar test. Gunakan vi.restoreAllMocks() jika kamu menggunakan spyOn dan ingin mengembalikan yang asli:
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});Bahaya Over-Mocking#
Ini saran mocking paling penting yang bisa saya berikan: setiap mock adalah kebohongan yang kamu ceritakan pada test-mu. Saat kamu mem-mock dependensi, kamu mengatakan "saya percaya bahwa benda ini bekerja dengan benar, jadi saya akan menggantinya dengan versi yang disederhanakan." Jika asumsimu salah, test lulus tapi fiturnya rusak.
// Over-mocked — tidak menguji sesuatu yang berguna
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
it("memproses input pengguna", () => {
processInput("hello");
expect(validator.validate).toHaveBeenCalledWith("hello");
expect(formatter.format).toHaveBeenCalledWith("hello");
});Test ini memverifikasi bahwa processInput memanggil validate dan format. Tapi bagaimana jika processInput memanggilnya dalam urutan yang salah? Bagaimana jika ia mengabaikan nilai kembaliannya? Bagaimana jika validasi seharusnya mencegah langkah format berjalan? Test tidak tahu. Kamu telah mem-mock semua perilaku yang menarik.
Aturan praktisnya: mock di batas, bukan di tengah. Mock permintaan jaringan, akses file system, dan layanan pihak ketiga. Jangan mock fungsi utilitas milikmu sendiri kecuali ada alasan kuat (seperti mereka mahal dijalankan atau memiliki efek samping).
Menguji Komponen React#
Dasar-dasar dengan Testing Library#
Testing Library memaksakan filosofi: uji komponen sebagaimana pengguna berinteraksi dengannya. Tidak memeriksa state internal, tidak menginspeksi instance komponen, tidak shallow rendering. Kamu me-render komponen dan berinteraksi dengannya melalui DOM, sama seperti yang dilakukan pengguna.
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
describe("Button", () => {
it("me-render dengan teks label", () => {
render(<Button>Klik saya</Button>);
expect(screen.getByRole("button", { name: "Klik saya" })).toBeInTheDocument();
});
it("menerapkan kelas varian", () => {
render(<Button variant="primary">Kirim</Button>);
const button = screen.getByRole("button");
expect(button).toHaveClass("bg-primary");
});
it("dinonaktifkan saat prop disabled bernilai true", () => {
render(<Button disabled>Kirim</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
});Query: getBy vs queryBy vs findBy#
Di sinilah pemula kebingungan. Ada tiga varian query dan masing-masing punya kasus penggunaan spesifik:
getBy* — Mengembalikan elemen atau throw jika tidak ditemukan. Gunakan saat kamu mengharapkan elemen ada:
// Throw jika tidak ada button ditemukan — test gagal dengan pesan error yang membantu
const button = screen.getByRole("button", { name: "Kirim" });queryBy* — Mengembalikan elemen atau null jika tidak ditemukan. Gunakan saat kamu meng-assert sesuatu TIDAK ada:
// Mengembalikan null — tidak throw
expect(screen.queryByText("Pesan error")).not.toBeInTheDocument();findBy* — Mengembalikan Promise. Gunakan untuk elemen yang muncul secara asinkron:
// Menunggu sampai 1000ms untuk elemen muncul
const successMessage = await screen.findByText("Berhasil disimpan");
expect(successMessage).toBeInTheDocument();Query yang Mengutamakan Aksesibilitas#
Testing Library menyediakan query ini dalam urutan prioritas yang disengaja:
getByRole— Query terbaik. Menggunakan ARIA role. Jika komponenmu tidak bisa ditemukan berdasarkan role, mungkin ada masalah aksesibilitas.getByLabelText— Untuk elemen form. Jika input-mu tidak punya label, perbaiki itu dulu.getByPlaceholderText— Bisa diterima tapi lebih lemah. Placeholder menghilang saat pengguna mengetik.getByText— Untuk elemen non-interaktif. Menemukan berdasarkan konten teks yang terlihat.getByTestId— Pilihan terakhir. Gunakan saat tidak ada query semantik yang berfungsi.
// Lebih baik ini
screen.getByRole("textbox", { name: "Alamat email" });
// Daripada ini
screen.getByPlaceholderText("Masukkan email Anda");
// Dan pasti daripada ini
screen.getByTestId("email-input");Urutan ini tidak sembarangan. Ini sesuai dengan cara teknologi bantu menavigasi halaman. Jika kamu bisa menemukan elemen berdasarkan role dan nama aksesibilitasnya, screen reader juga bisa. Jika kamu hanya bisa menemukannya berdasarkan test ID, kamu mungkin punya celah aksesibilitas.
User Events#
Jangan gunakan fireEvent. Gunakan @testing-library/user-event. Perbedaannya penting:
import userEvent from "@testing-library/user-event";
describe("SearchInput", () => {
it("memfilter hasil saat pengguna mengetik", async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
const input = screen.getByRole("searchbox");
await user.type(input, "vitest");
// user.type memicu keydown, keypress, input, keyup untuk SETIAP karakter
// fireEvent.change hanya menetapkan nilainya — melewati alur event yang realistis
expect(onSearch).toHaveBeenLastCalledWith("vitest");
});
it("menghapus input saat tombol escape ditekan", async () => {
const user = userEvent.setup();
render(<SearchInput onSearch={vi.fn()} />);
const input = screen.getByRole("searchbox");
await user.type(input, "hello");
await user.keyboard("{Escape}");
expect(input).toHaveValue("");
});
});userEvent mensimulasikan rantai event lengkap yang akan ditembakkan browser asli. fireEvent.change adalah satu event sintetis. Jika komponenmu mendengarkan onKeyDown atau menggunakan onInput alih-alih onChange, fireEvent.change tidak akan memicu handler tersebut tapi userEvent.type akan.
Selalu panggil userEvent.setup() di awal dan gunakan instance user yang dikembalikan. Ini memastikan urutan event dan pelacakan state yang benar.
Menguji Interaksi Komponen#
Test komponen yang realistis terlihat seperti ini:
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
describe("TodoList", () => {
it("menambahkan item todo baru", async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByRole("textbox", { name: /todo baru/i });
const addButton = screen.getByRole("button", { name: /tambah/i });
await user.type(input, "Tulis test");
await user.click(addButton);
expect(screen.getByText("Tulis test")).toBeInTheDocument();
expect(input).toHaveValue("");
});
it("menandai todo sebagai selesai", async () => {
const user = userEvent.setup();
render(<TodoList initialItems={[{ id: "1", text: "Beli bahan makanan", done: false }]} />);
const checkbox = screen.getByRole("checkbox", { name: /beli bahan makanan/i });
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
it("menghapus item selesai saat tombol hapus diklik", async () => {
const user = userEvent.setup();
render(
<TodoList
initialItems={[
{ id: "1", text: "Tugas selesai", done: true },
{ id: "2", text: "Tugas tertunda", done: false },
]}
/>
);
await user.click(screen.getByRole("button", { name: /hapus yang selesai/i }));
expect(screen.queryByText("Tugas selesai")).not.toBeInTheDocument();
expect(screen.getByText("Tugas tertunda")).toBeInTheDocument();
});
});Perhatikan: tidak ada inspeksi state internal, tidak ada component.setState(), tidak ada pemeriksaan detail implementasi. Kita render, berinteraksi, dan meng-assert apa yang akan dilihat pengguna. Jika komponen merefaktor manajemen state internal-nya dari useState ke useReducer, test-test ini masih lulus. Itulah intinya.
Menguji Kode Async#
waitFor#
Saat komponen diperbarui secara asinkron, waitFor melakukan polling sampai assertion lulus:
import { render, screen, waitFor } from "@testing-library/react";
it("memuat dan menampilkan profil pengguna", async () => {
render(<UserProfile userId="123" />);
// Awalnya menampilkan loading
expect(screen.getByText("Memuat...")).toBeInTheDocument();
// Tunggu konten muncul
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
// Indikator loading seharusnya hilang
expect(screen.queryByText("Memuat...")).not.toBeInTheDocument();
});waitFor mencoba ulang callback setiap 50ms (secara default) sampai lulus atau timeout (1000ms secara default). Kamu bisa menyesuaikan keduanya:
await waitFor(
() => expect(screen.getByText("Selesai")).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
);Fake Timers#
Saat menguji kode yang menggunakan setTimeout, setInterval, atau Date, fake timers memungkinkanmu mengontrol waktu:
describe("Debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("menunda eksekusi sesuai waktu yang ditentukan", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
it("mereset timer pada panggilan berikutnya", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
vi.advanceTimersByTime(200);
debounced(); // reset timer
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
});Penting: selalu panggil vi.useRealTimers() di afterEach. Fake timers yang bocor antar test menyebabkan kegagalan paling membingungkan yang pernah kamu debug.
Menguji dengan Fake Timers dan Rendering Async#
Menggabungkan fake timers dengan pengujian komponen React memerlukan kehati-hatian. Penjadwalan internal React menggunakan timer asli, jadi kamu sering perlu memajukan timer DAN mem-flush pembaruan React bersamaan:
import { render, screen, act } from "@testing-library/react";
describe("Notification", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("otomatis menghilang setelah 5 detik", async () => {
render(<Notification message="Tersimpan!" autoDismiss={5000} />);
expect(screen.getByText("Tersimpan!")).toBeInTheDocument();
// Majukan timer di dalam act() untuk mem-flush pembaruan React
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText("Tersimpan!")).not.toBeInTheDocument();
});
});Mocking API dengan MSW#
Untuk menguji data fetching, Mock Service Worker (MSW) mencegat permintaan jaringan di level jaringan. Ini berarti kode fetch/axios komponenmu berjalan persis seperti di produksi — MSW hanya mengganti respons jaringan:
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
const server = setupServer(
http.get("/api/users", () => {
return HttpResponse.json([
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
]);
}),
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: "Alice",
email: "alice@example.com",
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("UserList", () => {
it("menampilkan pengguna dari API", async () => {
render(<UserList />);
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(await screen.findByText("Bob")).toBeInTheDocument();
});
it("menampilkan state error saat API gagal", async () => {
// Override handler default untuk test ini saja
server.use(
http.get("/api/users", () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserList />);
expect(await screen.findByText(/gagal memuat/i)).toBeInTheDocument();
});
});MSW lebih baik daripada mem-mock fetch atau axios langsung karena:
- Kode data fetching aktual komponenmu berjalan — kamu menguji integrasi yang sesungguhnya.
- Kamu bisa menguji penanganan error dengan meng-override handler per test.
- Handler yang sama berfungsi baik di test maupun mode pengembangan browser (Storybook, misalnya).
Integration Tests#
Unit Tests vs Integration Tests#
Unit test mengisolasi satu fungsi atau komponen dan mem-mock semua yang lain. Integration test membiarkan beberapa unit bekerja bersama dan hanya mem-mock batas eksternal (jaringan, file system, database).
Kenyataannya: kebanyakan bug yang saya lihat di produksi terjadi di batas antar unit, bukan di dalamnya. Sebuah fungsi bekerja sempurna secara terisolasi tapi gagal karena pemanggilnya mengirimkan data dalam format yang sedikit berbeda. Sebuah komponen me-render dengan baik dengan data mock tapi rusak saat respons API aktual punya level nesting tambahan.
Integration test menangkap bug-bug ini. Mereka lebih lambat dari unit test dan lebih sulit di-debug saat gagal, tapi memberikan lebih banyak kepercayaan per test.
Menguji Beberapa Komponen Bersama#
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
// Hanya mock layer API — semua yang lain asli
vi.mock("@/lib/api", () => ({
checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
describe("Alur Keranjang Belanja", () => {
const renderCart = (initialItems = []) => {
return render(
<CartProvider initialItems={initialItems}>
<ShoppingCart />
</CartProvider>
);
};
it("menampilkan jumlah item dan total", () => {
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
{ id: "2", name: "Mouse", price: 49.99, quantity: 2 },
]);
expect(screen.getByText("3 items")).toBeInTheDocument();
expect(screen.getByText("$179.97")).toBeInTheDocument();
});
it("memperbarui kuantitas dan menghitung ulang total", async () => {
const user = userEvent.setup();
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
const incrementButton = screen.getByRole("button", { name: /tambah kuantitas/i });
await user.click(incrementButton);
expect(screen.getByText("$159.98")).toBeInTheDocument();
});
it("menyelesaikan alur checkout", async () => {
const user = userEvent.setup();
const { checkout } = await import("@/lib/api");
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
await user.click(screen.getByRole("button", { name: /checkout/i }));
expect(checkout).toHaveBeenCalledWith({
items: [{ id: "1", quantity: 1 }],
});
expect(await screen.findByText(/pesanan dikonfirmasi/i)).toBeInTheDocument();
expect(screen.getByText("ORD-123")).toBeInTheDocument();
});
});Dalam test ini, ShoppingCart dan CartProvider dan komponen internal mereka (baris item, pemilih kuantitas, tampilan total) semuanya bekerja bersama dengan kode asli. Satu-satunya mock adalah panggilan API, karena kita tidak ingin membuat permintaan jaringan asli dalam test.
Kapan Menggunakan Integration vs Unit Tests#
Gunakan unit tests saat:
- Logikanya kompleks dan punya banyak kasus edge (parser tanggal, state machine, kalkulasi).
- Kamu memerlukan feedback cepat tentang perilaku fungsi tertentu.
- Unit tersebut relatif terisolasi dan tidak terlalu bergantung pada unit lain.
Gunakan integration tests saat:
- Beberapa komponen perlu bekerja bersama dengan benar (form dengan validasi dan pengiriman).
- Data mengalir melalui beberapa layer (context -> komponen -> komponen anak).
- Kamu menguji alur kerja pengguna, bukan nilai kembalian fungsi.
Dalam praktik, suite test yang sehat berat di integration test untuk fitur dan punya unit test untuk utilitas kompleks. Komponen sendiri diuji melalui integration test — kamu tidak perlu unit test terpisah untuk setiap komponen kecil jika integration test sudah mengujinya.
Coverage#
Menjalankan Coverage#
vitest run --coverageKamu memerlukan provider coverage. Vitest mendukung dua:
# V8 — lebih cepat, menggunakan coverage built-in V8
npm install -D @vitest/coverage-v8
# Istanbul — lebih matang, lebih banyak opsi konfigurasi
npm install -D @vitest/coverage-istanbulKonfigurasi di config Vitest-mu:
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/test/**",
"src/**/*.d.ts",
"src/**/types.ts",
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});Istanbul vs V8#
V8 coverage menggunakan instrumentasi built-in engine V8. Lebih cepat karena tidak ada langkah transformasi kode. Tapi bisa kurang akurat untuk beberapa kasus edge, terutama di sekitar batas modul ES.
Istanbul menginstrumentasi source code-mu dengan penghitung sebelum menjalankan test. Lebih lambat tapi lebih teruji dan memberikan branch coverage yang lebih akurat. Jika kamu memaksakan threshold coverage di CI, akurasi Istanbul mungkin penting.
Saya menggunakan V8 untuk pengembangan lokal (feedback lebih cepat) dan Istanbul di CI (penegakan lebih akurat). Kamu bisa mengonfigurasi provider berbeda per environment jika diperlukan.
Apa Arti Coverage Sebenarnya#
Coverage memberitahumu baris kode mana yang dieksekusi selama test. Itu saja. Coverage tidak memberitahumu apakah baris-baris itu diuji dengan benar. Perhatikan ini:
function divide(a: number, b: number): number {
return a / b;
}
it("membagi angka", () => {
divide(10, 2);
// Tidak ada assertion!
});Test ini memberikan 100% coverage untuk fungsi divide. Ia juga tidak menguji apa-apa sama sekali. Test akan lulus jika divide mengembalikan null, melempar error, atau meluncurkan rudal.
Coverage adalah indikator negatif yang berguna: coverage rendah berarti pasti ada jalur yang belum diuji. Tapi coverage tinggi tidak berarti kodemu sudah diuji dengan baik. Hanya berarti setiap baris dijalankan selama suatu test.
Lines vs Branches#
Line coverage adalah metrik paling umum tapi branch coverage lebih berharga:
function getDiscount(user: User): number {
if (user.isPremium) {
return user.yearsActive > 5 ? 0.2 : 0.1;
}
return 0;
}Test dengan getDiscount({ isPremium: true, yearsActive: 10 }) mengenai setiap baris (100% line coverage) tapi hanya menguji dua dari tiga branch. Jalur isPremium: false dan jalur yearsActive <= 5 belum diuji.
Branch coverage menangkap ini. Ia melacak setiap jalur yang mungkin melalui logika kondisional. Jika kamu akan memaksakan threshold coverage, gunakan branch coverage.
Mengabaikan Kode yang Di-generate#
Beberapa kode seharusnya tidak dihitung dalam coverage. File yang di-generate, definisi tipe, konfigurasi — ini menggembungkan metrikmu tanpa menambah nilai:
// vitest.config.ts
coverage: {
exclude: [
"src/**/*.d.ts",
"src/**/types.ts",
"src/**/*.stories.tsx",
"src/generated/**",
".velite/**",
],
}Kamu juga bisa mengabaikan baris atau blok tertentu di source code-mu:
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
console.log("Debug info:", data);
}
/* v8 ignore stop */
// Atau untuk Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }Gunakan ini dengan hemat. Jika kamu sering mengabaikan potongan besar kode, potongan tersebut memerlukan test atau seharusnya tidak ada di laporan coverage sejak awal.
Menguji Next.js#
Mocking next/navigation#
Komponen Next.js yang menggunakan useRouter, usePathname, atau useSearchParams memerlukan mock:
import { vi } from "vitest";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/en/blog",
useSearchParams: () => new URLSearchParams("?page=1"),
useParams: () => ({ locale: "en" }),
}));Untuk test yang perlu memverifikasi navigasi dipanggil:
import { useRouter } from "next/navigation";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
describe("LogoutButton", () => {
it("mengarahkan ulang ke beranda setelah logout", async () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
forward: vi.fn(),
});
const user = userEvent.setup();
render(<LogoutButton />);
await user.click(screen.getByRole("button", { name: /keluar/i }));
expect(mockPush).toHaveBeenCalledWith("/");
});
});Mocking next-intl#
Untuk komponen yang diinternasionalisasi menggunakan next-intl:
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));Ini pendekatan paling sederhana — terjemahan mengembalikan key itu sendiri, jadi t("hero.title") mengembalikan "hero.title". Dalam assertion, kamu memeriksa key terjemahan alih-alih string terjemahan aktual. Ini membuat test tidak bergantung pada bahasa.
Jika kamu memerlukan terjemahan aktual di test tertentu:
vi.mock("next-intl", () => ({
useTranslations: () => {
const translations: Record<string, string> = {
"hero.title": "Selamat Datang di Situs Saya",
"hero.subtitle": "Membangun sesuatu untuk web",
};
return (key: string) => translations[key] ?? key;
},
}));Menguji Route Handler#
Route Handler Next.js adalah fungsi biasa yang menerima Request dan mengembalikan Response. Mereka mudah diuji:
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
describe("GET /api/users", () => {
it("mengembalikan daftar pengguna", async () => {
const request = new NextRequest("http://localhost:3000/api/users");
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
])
);
});
it("mendukung paginasi melalui search params", async () => {
const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
const response = await GET(request);
const data = await response.json();
expect(data.page).toBe(2);
expect(data.items).toHaveLength(10);
});
});
describe("POST /api/users", () => {
it("membuat pengguna baru", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.name).toBe("Alice");
});
it("mengembalikan 400 untuk body yang tidak valid", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "" }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});Menguji Middleware#
Middleware Next.js berjalan di edge dan memproses setiap permintaan. Uji sebagai fungsi:
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
const url = new URL(path, "http://localhost:3000");
return new NextRequest(url, { headers });
}
describe("middleware", () => {
it("mengarahkan ulang pengguna yang belum terotentikasi dari rute terproteksi", async () => {
const request = createRequest("/dashboard");
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/login");
});
it("mengizinkan pengguna terotentikasi lewat", async () => {
const request = createRequest("/dashboard", {
cookie: "session=valid-token",
});
const response = await middleware(request);
expect(response.status).toBe(200);
});
it("menambahkan header keamanan", async () => {
const request = createRequest("/");
const response = await middleware(request);
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("menangani deteksi locale", async () => {
const request = createRequest("/", {
"accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
});
const response = await middleware(request);
expect(response.headers.get("location")).toContain("/tr");
});
});Menguji Server Actions#
Server Actions adalah fungsi async yang berjalan di server. Karena mereka hanya fungsi, kamu bisa mengujinya langsung — tapi mungkin perlu mem-mock dependensi khusus server:
vi.mock("@/lib/db", () => ({
db: {
user: {
update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
},
},
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
describe("updateProfile", () => {
it("memperbarui pengguna dan merevalidasi halaman profil", async () => {
const formData = new FormData();
formData.set("name", "Nama Baru");
formData.set("bio", "Teks bio baru");
const result = await updateProfile(formData);
expect(result.success).toBe(true);
expect(revalidatePath).toHaveBeenCalledWith("/profile");
});
it("mengembalikan error untuk data yang tidak valid", async () => {
const formData = new FormData();
// Field wajib tidak ada
const result = await updateProfile(formData);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});Pola Praktis#
Fungsi Render Kustom#
Kebanyakan proyek memerlukan provider yang sama dibungkus di sekitar setiap komponen. Buat render kustom:
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
interface CustomRenderOptions extends RenderOptions {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
children: React.ReactNode;
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}) {
return (
<ThemeProvider defaultTheme={theme}>
<CartProvider initialItems={initialCartItems}>
{children}
</CartProvider>
</ThemeProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
const { theme, initialCartItems, ...renderOptions } = options;
return render(ui, {
wrapper: ({ children }) => (
<AllProviders theme={theme} initialCartItems={initialCartItems}>
{children}
</AllProviders>
),
...renderOptions,
});
}
// Re-export semua dari testing library
export * from "@testing-library/react";
export { renderWithProviders as render };Sekarang setiap file test mengimpor dari utilitas kustom alih-alih @testing-library/react:
import { render, screen } from "@/test/utils";
it("me-render dalam mode gelap", () => {
render(<Header />, { theme: "dark" });
// Header dan semua anak-anaknya punya akses ke ThemeProvider dan CartProvider
});Menguji Custom Hooks#
Vitest bekerja dengan renderHook dari @testing-library/react:
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
describe("useCounter", () => {
it("dimulai dari nilai awal", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increment", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrement dengan batas bawah", () => {
const { result } = renderHook(() => useCounter(0, { min: 0 }));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0); // tidak turun di bawah min
});
});Menguji Error Boundaries#
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
const ThrowingComponent = () => {
throw new Error("Ledakan test");
};
describe("ErrorBoundary", () => {
// Redam console.error untuk error yang diharapkan
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("menampilkan UI fallback saat komponen anak melempar error", () => {
render(
<ErrorBoundary fallback={<div>Terjadi kesalahan</div>}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText("Terjadi kesalahan")).toBeInTheDocument();
});
it("me-render komponen anak saat tidak ada error", () => {
render(
<ErrorBoundary fallback={<div>Error</div>}>
<div>Semua baik</div>
</ErrorBoundary>
);
expect(screen.getByText("Semua baik")).toBeInTheDocument();
expect(screen.queryByText("Error")).not.toBeInTheDocument();
});
});Snapshot Testing (Dengan Hati-hati)#
Snapshot test punya reputasi buruk karena orang menggunakannya sebagai pengganti assertion nyata. Snapshot dari seluruh output HTML komponen adalah beban pemeliharaan — ia rusak setiap kali kelas CSS berubah dan tidak ada yang meninjau diff-nya dengan cermat.
Tapi snapshot yang terarah bisa berguna:
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
// Bagus — snapshot kecil, terarah dari output fungsi murni
it("memformat berbagai nilai mata uang secara konsisten", () => {
expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
// Buruk — snapshot raksasa yang tidak akan ditinjau siapa pun
it("me-render dashboard", () => {
const { container } = render(<Dashboard />);
expect(container).toMatchSnapshot(); // Jangan lakukan ini
});Inline snapshot (toMatchInlineSnapshot) lebih baik dari file snapshot karena nilai yang diharapkan terlihat langsung di test. Kamu bisa melihat sekilas apa yang dikembalikan fungsi tanpa membuka file .snap terpisah.
Filosofi Testing#
Uji Perilaku, Bukan Implementasi#
Prinsip ini sangat penting sehingga layak mendapat bagian khusus. Perhatikan dua test untuk fitur yang sama:
// Test implementasi — rapuh, rusak saat refaktor
it("memanggil setState dengan count baru", () => {
const setStateSpy = vi.spyOn(React, "useState");
render(<Counter />);
fireEvent.click(screen.getByText("+"));
expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
// Test perilaku — tangguh, menguji apa yang dilihat pengguna
it("menambah count yang ditampilkan saat tombol plus diklik", async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "+" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});Test pertama rusak jika kamu beralih dari useState ke useReducer, meskipun komponen bekerja persis sama. Test kedua hanya rusak jika perilaku komponen benar-benar berubah. Ia tidak peduli bagaimana count dikelola secara internal — hanya bahwa mengklik "+" membuat angkanya naik.
Tes lakmusnya sederhana: bisakah kamu merefaktor implementasi tanpa mengubah test? Jika ya, kamu menguji perilaku. Jika tidak, kamu menguji implementasi.
Testing Trophy#
Kent C. Dodds mengusulkan "Testing Trophy" sebagai alternatif untuk piramida testing tradisional:
╭─────────╮
│ E2E │ Sedikit — mahal, lambat, kepercayaan tinggi
├─────────┤
│ │
│ Integr. │ Paling banyak — rasio kepercayaan-biaya bagus
│ │
├─────────┤
│ Unit │ Beberapa — cepat, fokus, biaya rendah
├─────────┤
│ Static │ Selalu — TypeScript, ESLint
╰─────────╯
Piramida tradisional menempatkan unit test di bawah (banyak) dan integration test di tengah (lebih sedikit). Trophy membalik ini: integration test adalah titik manis. Inilah alasannya:
- Analisis statis (TypeScript, ESLint) menangkap typo, tipe yang salah, dan kesalahan logika sederhana secara gratis. Kamu bahkan tidak perlu menjalankan apa pun.
- Unit test bagus untuk logika murni yang kompleks tapi tidak memberitahumu apakah potongan-potongannya bekerja bersama.
- Integration test memverifikasi bahwa komponen, hooks, dan context bekerja bersama. Mereka memberikan kepercayaan paling banyak per test yang ditulis.
- End-to-end test memverifikasi seluruh sistem tapi lambat, tidak stabil, dan mahal dipelihara. Kamu memerlukan beberapa untuk jalur kritis tapi tidak ratusan.
Saya mengikuti distribusi ini dalam praktik: TypeScript menangkap sebagian besar error tipe saya, saya menulis unit test untuk utilitas dan algoritma kompleks, integration test untuk fitur dan alur pengguna, dan beberapa E2E test untuk jalur kritis (pendaftaran, pembelian, alur kerja inti).
Apa yang Memberi Kepercayaan vs Apa yang Membuang Waktu#
Test ada untuk memberikanmu kepercayaan untuk mengirim. Bukan kepercayaan bahwa setiap baris kode berjalan — kepercayaan bahwa aplikasinya bekerja untuk pengguna. Ini hal yang berbeda.
Kepercayaan tinggi, nilai tinggi:
- Integration test dari alur checkout — mencakup validasi form, panggilan API, pembaruan state, dan UI sukses/error.
- Unit test dari fungsi kalkulasi harga dengan kasus edge — floating point, pembulatan, diskon, nilai nol/negatif.
- Test bahwa rute terproteksi mengarahkan ulang pengguna yang belum terotentikasi.
Kepercayaan rendah, pembuang waktu:
- Snapshot test dari halaman marketing statis — rusak setiap kali copy berubah, tidak menangkap apa-apa yang bermakna.
- Unit test bahwa komponen meneruskan prop ke komponen anak — menguji React itu sendiri, bukan kodemu.
- Test bahwa
useStatedipanggil — menguji framework, bukan perilaku. - 100% coverage dari file konfigurasi — itu data statis, TypeScript sudah memvalidasi bentuknya.
Pertanyaan yang harus ditanyakan sebelum menulis test: "Jika test ini tidak ada, bug apa yang bisa lolos ke produksi?" Jika jawabannya "tidak ada yang tidak ditangkap TypeScript" atau "tidak ada yang akan diperhatikan siapa pun," test itu mungkin tidak layak ditulis.
Testing sebagai Umpan Balik Desain#
Kode yang sulit diuji biasanya kode yang didesain dengan buruk. Jika kamu perlu mem-mock lima hal untuk menguji satu fungsi, fungsi itu punya terlalu banyak dependensi. Jika kamu tidak bisa me-render komponen tanpa menyiapkan context provider yang rumit, komponen itu terlalu terikat dengan lingkungannya.
Test adalah pengguna kodemu. Jika test-mu kesulitan menggunakan API-mu, developer lain juga akan. Saat kamu menemukan dirimu berjuang dengan setup test, ambil itu sebagai sinyal untuk merefaktor kode yang diuji, bukan untuk menambahkan lebih banyak mock.
// Sulit diuji — fungsi melakukan terlalu banyak
async function processOrder(orderId: string) {
const order = await db.orders.findById(orderId);
const user = await db.users.findById(order.userId);
const inventory = await checkInventory(order.items);
if (!inventory.available) {
await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
return { success: false, reason: "out-of-stock" };
}
const payment = await chargeCard(user.paymentMethod, order.total);
if (!payment.success) {
await sendEmail(user.email, "payment-failed", { error: payment.error });
return { success: false, reason: "payment-failed" };
}
await db.orders.update(orderId, { status: "confirmed" });
await sendEmail(user.email, "order-confirmed", { orderId });
return { success: true };
}
// Lebih mudah diuji — concern yang dipisahkan
function determineOrderAction(
inventory: InventoryResult,
payment: PaymentResult
): OrderAction {
if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
if (!payment.success) return { type: "payment-failed", error: payment.error };
return { type: "confirmed" };
}Versi kedua adalah fungsi murni. Kamu bisa menguji setiap kombinasi inventory dan payment result tanpa mem-mock database, payment provider, atau layanan email. Logika orkestrasi (mengambil data, mengirim email) tinggal di layer tipis yang diuji di level integrasi.
Inilah nilai nyata testing: bukan menangkap bug setelah ditulis, tapi mencegah desain buruk sebelum di-commit. Disiplin menulis test mendorongmu ke arah fungsi yang lebih kecil, interface yang lebih jelas, dan arsitektur yang lebih modular. Test adalah efek sampingnya. Peningkatan desain adalah acara utamanya.
Tulis Test yang Kamu Percaya#
Hal terburuk yang bisa terjadi pada suite test bukan bahwa ia punya celah. Tapi bahwa orang berhenti mempercayainya. Suite test dengan beberapa test yang tidak stabil dan gagal secara acak di CI mengajarkan tim untuk mengabaikan build merah. Setelah itu terjadi, suite test menjadi lebih buruk daripada tidak berguna — ia secara aktif memberikan rasa aman palsu.
Jika test gagal secara intermiten, perbaiki atau hapus. Jika test lambat, percepat atau pindahkan ke suite test lambat terpisah. Jika test rusak pada setiap perubahan yang tidak terkait, tulis ulang untuk menguji perilaku alih-alih implementasi.
Tujuannya adalah suite test di mana setiap kegagalan berarti sesuatu yang nyata rusak. Saat developer mempercayai test, mereka menjalankannya sebelum setiap commit. Saat mereka tidak mempercayai test, mereka melewatinya dengan --no-verify dan deploy sambil menyilangkan jari.
Bangun suite test yang kamu berani pertaruhkan akhir pekanmu. Tidak kurang dari itu yang layak dipelihara.