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:

  1. Context Missing: Menjalankan fungsi render(<Component />) secara langsung tanpa provider wrapper menyebabkan usePage() mengembalikan undefined. Akses ke props.auth atau flash data langsung memicu fatal error.
  2. 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 mock router.post atau router.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.