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: truediterima, namun framework UI (Vue, React, atau Svelte) membutuhkan siklus tick tambahan untuk melakukan re-render DOM virtual. - Kegagalan
networkidle: Menunggu kondisi jaringan idle viapage.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
storageStateindependen 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
1untuk suite pengujian mutasi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!