Akar Masalah: Mengapa Pengujian Inertia.js di Playwright Sering Flaky

Inertia.js menjembatani backend monolitik dengan frontend SPA menggunakan protokol berbasis XHR/Fetch. Navigasi berbasis komponen <Link> atau eksekusi router.visit() tidak memicu navigasi browser tradisional (seperti document load atau DOMContentLoaded). Playwright dirancang dengan kapabilitas auto-waiting pada locator, tetapi asumsi ini sering runtuh pada Inertia karena alasan berikut:

  • Lifecycle decoupling: Permintaan HTTP selesai dan header X-Inertia: true diterima, namun framework UI (Vue, React, atau Svelte) membutuhkan siklus tick tambahan untuk melakukan re-render DOM virtual.
  • Kegagalan networkidle: Menunggu kondisi jaringan idle via page.waitForLoadState('networkidle') tidak deterministik. Koneksi WebSocket, polling background, atau analitik pihak ketiga dapat menggantung runner. Sebaliknya, pada koneksi cepat, event idle bisa terpicu sebelum proses re-rendering selesai.
  • Arbitrary timeout sebagai anti-pattern: Penggunaan page.waitForTimeout(1000) memperlambat eksekusi tes di lokal dan tetap berpotensi gagal di lingkungan CI yang memiliki beban CPU lebih tinggi.

Sinkronisasi Siklus Hidup Inertia

Inertia mendistribusikan custom DOM event pada objek document: inertia:start, inertia:progress, inertia:success, inertia:error, inertia:finish, dan inertia:navigate.

Race condition terjadi ketika Playwright mengevaluasi asersi DOM sebelum event inertia:finish ditembakkan. Solusi deterministik membutuhkan sinkronisasi eksplisit terhadap event tersebut atau kombinasi deteksi respons x-inertia bersama pembaruan DOM spesifik.

Implementasi Custom Fixture Playwright (TypeScript)

Bangun custom fixture untuk menangkap siklus navigasi Inertia tanpa memodifikasi kode produksi aplikasi secara berlebihan. Fixture ini menyuntikkan listener global dan mengekspos utilitas waitForInertia.

import { test as base, expect, Page } from '@playwright/test';

type InertiaFixtures = {
  inertiaPage: Page;
  waitForInertia: (action: () => Promise<void>) => Promise<void>;
};

export const test = base.extend<InertiaFixtures>({
  waitForInertia: async ({ page }, use) => {
    const helper = async (action: () => Promise<void>) => {
      // Inisialisasi listener event finish sebelum aksi dijalankan
      const finishPromise = page.evaluate(() => {
        return new Promise<void>((resolve) => {
          const handler = () => {
            document.removeEventListener('inertia:finish', handler);
            resolve();
          };
          document.addEventListener('inertia:finish', handler);
        });
      });

      // Tunggu respons XHR yang membawa header Inertia
      const responsePromise = page.waitForResponse(
        (resp) => resp.headers()['x-inertia'] === 'true' && resp.status() < 400
      );

      await action();
      await Promise.all([finishPromise, responsePromise]);
    };

    await use(helper);
  },
});

export { expect };

Skenario Uji: Mutasi Form dan Verifikasi State Drift

State drift sering terjadi saat submit form: validasi gagal tetapi state form lokal tertinggal, atau redirect sukses tidak membersihkan payload form lama. Contoh di bawah menguji mutasi resource dengan sinkronisasi deterministik.

import { test, expect } from './inertia-fixture';

test.describe('Resource Mutation Flow', () => {
  test.beforeEach(async ({ page }) => {
    // Isolasi state: Login dan navigasi awal dengan full-page render
    await page.goto('/projects/create');
    await expect(page.locator('h1')).toHaveText('Create Project');
  });

  test('berhasil membuat proyek baru dan melakukan redirect bersih', async ({
    page,
    waitForInertia,
  }) => {
    await page.getByLabel('Project Name').fill('Alpha Core');
    await page.getByLabel('Budget').fill('150000');

    // Bungkus interaksi submit dengan helper waitForInertia
    await waitForInertia(async () => {
      await page.getByRole('button', { name: 'Save Project' }).click();
    });

    // Validasi URL dan Flash Message yang dirender dari page props baru
    await expect(page).toHaveURL(/\/projects\/\d+$/);
    await expect(page.getByRole('alert')).toHaveText('Project created successfully.');
    
    // Verifikasi bahwa data form lama tidak bocor ke halaman detail (no state drift)
    await expect(page.getByLabel('Project Name')).toHaveCount(0);
    await expect(page.locator('[data-testid="project-title"]')).toHaveText('Alpha Core');
  });

  test('menampilkan error validasi tanpa reload halaman', async ({
    page,
    waitForInertia,
  }) => {
    // Submit tanpa mengisi field wajib
    await waitForInertia(async () => {
      await page.getByRole('button', { name: 'Save Project' }).click();
    });

    // URL tidak boleh berubah
    await expect(page).toHaveURL('/projects/create');
    
    // Asersi error boundary yang diperbarui oleh router.visit Inertia
    const errorText = page.locator('[data-error-for="name"]');
    await expect(errorText).toBeVisible();
    await expect(errorText).toHaveText('The project name field is required.');
  });
});

Menangani State Preservation dan History State

Inertia secara default mempertahankan history browser melalui window.history.pushState. Ketika menguji navigasi kembali (back button), Inertia mengambil data dari cache internal kecuali opsi preserveState atau konfigurasi cache dinonaktifkan.

  • Verifikasi cache expiration: Jika aplikasi memvalidasi hak akses mutakhir pada backend, pastikan pengujian mengevaluasi navigasi history browser (page.goBack()) dengan menunggu respons baru jika page component diset untuk melakukan re-fetch.
  • Storage state isolation: Hindari state leakage antar worker dengan menggunakan storageState independen pada level konfigurasi context, bukan login manual melalui UI di setiap test case.

Strategi Konfigurasi CI Pipeline yang Deterministik

Flakiness pada CI umumnya dipicu oleh konkurensi database atau starvation sumber daya CPU. Terapkan aturan konfigurasi berikut pada playwright.config.ts:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined, // Batasi konkurensi untuk mencegah database deadlock
  use: {
    baseURL: process.env.APP_URL || 'http://localhost:8000',
    trace: 'on-first-retry',
    video: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Catatan: Jika backend menggunakan database SQLite, parallel execution lintas worker akan memicu database lock. Gunakan PostgreSQL/MySQL dengan dynamic schema isolation per worker thread atau batasi worker CI menjadi 1 untuk suite pengujian mutasi.