Pengujian komponen frontend pada aplikasi Inertia.js (React/Vue) sering kali menghasilkan flaky test atau melempar runtime error TypeError: Cannot read properties of undefined (reading 'props'). Masalah ini muncul karena komponen memanggil hook runtime Inertia di luar arsitektur aplikasi aslinya.
Artikel ini membahas akar penyebab kegagalan pengujian komponen Inertia.js, implementasi mock Page Context yang ringan, teknik intersepsi routing, dan isolasi state antar-test menggunakan Vitest dan React Testing Library.
Akar Masalah: Missing Context dan State Leakage
Komponen Inertia.js mengandalkan dua dependensi runtime utama: Page Context (diakses melalui usePage()) dan Router Singleton (diakses via router atau useForm()). Kerusakan pengujian umumnya bersumber dari dua hal berikut:
- Context Missing: Menjalankan fungsi
render(<Component />)secara langsung tanpa provider wrapper menyebabkanusePage()mengembalikanundefined. Akses keprops.authatau flash data langsung memicu fatal error. - State Leakage: Router Inertia menyimpan state internal dalam memori. Jika mock routing atau state dummy dimutasi pada Test Case A dan tidak dibersihkan secara atomik, Test Case B akan mengeksekusi assertion dengan state kotor, menyebabkan hasil uji tidak konsisten (flaky) saat dijalankan paralel di CI.
Implementasi Mock Page Context
Hindari membungkus komponen uji dengan arsitektur penuh Inertia runtime yang lambat. Gunakan dynamic module mock untuk mengisolasi usePage() pada tingkat test runner.
// tests/mocks/inertia.ts
import { vi } from 'vitest';
interface PageContextData {
props: Record<string, unknown>;
url: string;
component: string;
version: string | null;
}
let currentPage: PageContextData = {
props: {},
url: '/',
component: 'TestComponent',
version: null,
};
export const setPageContext = (customData: Partial<PageContextData>) => {
currentPage = { ...currentPage, ...customData };
};
export const resetPageContext = () => {
currentPage = {
props: {},
url: '/',
component: 'TestComponent',
version: null,
};
};
export const mockUsePage = () => currentPage;// ponytail: in-memory global mock container. skipped: full Inertia provider tree, add when testing end-to-end page transitions.
Mocking Router: Intersepsi Payload HTTP
Komponen form yang memanggil router.post atau router.visit tidak boleh mengeksekusi request jaringan aktual. Mock modul @inertiajs/react agar method routing dapat diinspeksi secara langsung untuk memverifikasi payload dan konfigurasi callback.
import { vi } from 'vitest';
import * as actualInertia from '@inertiajs/react';
import { mockUsePage } from './tests/mocks/inertia';
vi.mock('@inertiajs/react', async (importOriginal) => {
const actual = await importOriginal<typeof actualInertia>();
return {
...actual,
usePage: () => mockUsePage(),
router: {
...actual.router,
visit: vi.fn(),
post: vi.fn(),
get: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
};
});Testing Siklus State useForm Secara Deterministik
Hook useForm mengontrol siklus hidup form: state input, flag processing, error bag, dan callback penutup. Daripada menunggu asynchronous delay palsu via setTimeout, uji transisi form dengan memicu callback options yang diterima oleh mock router.post.
1. Komponen Target (LoginForm.tsx)
import React from 'react';
import { useForm, usePage } from '@inertiajs/react';
export function LoginForm() {
const { auth } = usePage().props as { auth?: { user?: { name: string } } };
const { data, setData, post, processing, errors, reset } = useForm({
email: '',
password: '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
post('/login', {
onSuccess: () => reset(),
});
};
if (auth?.user) {
return <p>Aktif: {auth.user.name}</p>;
}
return (
<form onSubmit={handleSubmit}>
<input
aria-label="Email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
/>
{errors.email && <span role="alert">{errors.email}</span>}
<input
type="password"
aria-label="Password"
value={data.password}
onChange={(e) => setData('password', e.target.value)}
/>
<button type="submit" disabled={processing}>
{processing ? 'Memproses...' : 'Masuk'}
</button>
</form>
);
}2. Test Suite Lengkap (LoginForm.test.tsx)
import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { router } from '@inertiajs/react';
import { LoginForm } from './LoginForm';
import { setPageContext, resetPageContext } from './tests/mocks/inertia';
describe('LoginForm', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
resetPageContext();
});
it('menampilkan info user saat auth page context terisi', () => {
setPageContext({
props: { auth: { user: { name: 'Budi' } } },
});
render(<LoginForm />);
expect(screen.getByText('Aktif: Budi')).toBeDefined();
});
it('mengirim payload email dan password ke endpoint /login', () => {
render(<LoginForm />);
fireEvent.change(screen.getByLabelText('Email'), {
target: { value: '[email protected]' },
});
fireEvent.change(screen.getByLabelText('Password'), {
target: { value: 'rahasia123' },
});
fireEvent.click(screen.getByRole('button', { name: 'Masuk' }));
expect(router.post).toHaveBeenCalledTimes(1);
expect(router.post).toHaveBeenCalledWith(
'/login',
{
email: '[email protected]',
password: 'rahasia123',
},
expect.objectContaining({
onSuccess: expect.any(Function),
})
);
});
it('menangani callback onError secara deterministik', () => {
// Intersepsi router.post untuk mengeksekusi callback error secara instan
vi.mocked(router.post).mockImplementation((_url, _data, options) => {
options?.onError?.({ email: 'Kredensial tidak valid' });
});
render(<LoginForm />);
fireEvent.click(screen.getByRole('button', { name: 'Masuk' }));
expect(screen.getByRole('alert').textContent).toBe('Kredensial tidak valid');
});
});// ponytail: router.post manual callback mock. skipped: simulated network latency, add when testing loader spinners or debounce boundaries.
Isolasi State & Cleanup afterEach
Test suite yang gagal secara acak saat dijalankan dalam pipeline CI multi-threaded hampir selalu disebabkan oleh state lifecycle yang bocor. Pasang konfigurasi berikut di file setupTests.ts atau blok afterEach lokal:
cleanup(): Menghapus DOM tree virtual dari React Testing Library untuk memastikan tidak ada query selector yang tertukar.vi.clearAllMocks(): Menghapus counter panggilan fungsi dan arguments yang tersimpan pada mockrouter.postataurouter.visit.resetPageContext(): Mengembalikan props global ke state default agar data user, flash session, atau URL dari test sebelumnya tidak terbawa.
Pola di atas menjamin pengujian komponen Inertia.js berjalan deterministik, cepat, dan terbebas dari ketergantungan server nyata.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!