Akar Masalah: Hydration Race Condition pada SSR Nuxt 3

Flaky test pada pengujian End-to-End (E2E) aplikasi Nuxt 3 umumnya bersumber dari hydration race condition. Pada arsitektur Server-Side Rendering (SSR), server mengirimkan markup HTML yang sudah ter-render secara utuh ke browser sebelum JavaScript bundle selesai diunduh, di-parse, dan dieksekusi oleh Vue runtime.

Playwright memiliki mekanisme actionability checks bawaan. Sebelum menjalankan aksi seperti click(), Playwright memvalidasi apakah elemen target telah terpasang di DOM (attached), terlihat (visible), stabil secara posisi, dan dapat menerima pointer events (enabled). Karena elemen HTML hasil SSR sudah berada di DOM, Playwright menganggap elemen tersebut siap menerima interaksi.

Masalah terjadi jika Playwright memicu aksi klik pada celah waktu antara rendering DOM statis dan tuntasnya proses hidrasi Vue. Pada interval mikro ini, event listener (misalnya @click atau v-on:click) belum terikat pada node DOM. Akibatnya, sinyal klik diterima oleh browser tetapi diabaikan oleh aplikasi, menyebabkan aksi gagal tanpa memunculkan error eksplisit pada Playwright.

Anti-Pattern: Mengapa waitForTimeout Harus Dihindari

Solusi instan yang sering digunakan pengembang adalah menyisipkan jeda statis:

// ANTI-PATTERN: Jangan gunakan pendekatan ini
await page.goto('/dashboard');
await page.waitForTimeout(2000);
await page.locator('button#submit').click();

Pendekatan ini memiliki sejumlah kelemahan fatal:

  • Non-deterministik: Waktu 2000ms mungkin cukup di mesin lokal, tetapi gagal di CI server yang memiliki alokasi CPU terbatas ketika beban komputasi meningkat.
  • Memperlambat pipeline CI: Akumulasi sleep statis di puluhan test case menambah durasi build secara eksponensial tanpa memberikan jaminan reliabilitas.
  • Menyembunyikan regresi performa: Jika hidrasi aplikasi melambat akibat bundle size membengkak, penambahan timeout hanya menutupi masalah arsitektur tersebut.

Solusi Deterministik: Menangkap Lifecycle Nuxt via Client Flag

Solusi yang reliabel adalah memastikan test runner hanya berinteraksi dengan elemen setelah hook hidrasi Nuxt selesai dieksekusi. Kita dapat memanfaatkan lifecycle hook app:mounted atau app:suspense:resolve melalui client-side plugin Nuxt untuk menginjeksikan state global ke objek window.

1. Buat Nuxt Client Plugin

Buat file plugin khusus client untuk menandai selesainya hidrasi:

// plugins/hydration-flag.client.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hook('app:suspense:resolve', () => {
    window.__NUXT_HYDRATED__ = true;
  });
});

Tambahkan deklarasi tipe global TypeScript agar compiler mengenali properti tersebut jika diperlukan:

// types/global.d.ts
export {};

declare global {
  interface Window {
    __NUXT_HYDRATED__?: boolean;
  }
}

Implementasi Custom Playwright Fixture

Alih-alih memanggil page.waitForFunction() secara repetitif di setiap test file, buat custom fixture Playwright yang secara otomatis menunggu hidrasi tuntas setiap kali navigasi dilakukan.

2. Buat Fixture Extended

// tests/fixtures/test.ts
import { test as base, type Page } from '@playwright/test';

export const test = base.extend<{
  hydratedPage: Page;
}>({
  hydratedPage: async ({ page }, use) => {
    // Intercept navigasi untuk menunggu hidrasi Nuxt
    const originalGoto = page.goto.bind(page);
    
    page.goto = async (url, options) => {
      const response = await originalGoto(url, options);
      
      // Tunggu flag window.__NUXT_HYDRATED__ bernilai true
      await page.waitForFunction(() => window.__NUXT_HYDRATED__ === true, null, {
        timeout: 10_000,
      });
      
      return response;
    };

    await use(page);
  },
});

export { expect } from '@playwright/test';

Contoh Test Spec Deterministik

Gunakan fixture hydratedPage pada test spec untuk memastikan event handler sudah aktif sebelum input atau interaksi tombol dieksekusi.

// tests/e2e/auth-modal.spec.ts
import { test, expect } from '../fixtures/test';

test.describe('Autentikasi User', () => {
  test('harus membuka modal login saat tombol diklik', async ({ hydratedPage: page }) => {
    await page.goto('/login');

    const triggerButton = page.locator('button#open-modal');
    const modalDialog = page.locator('[role="dialog"]');

    // Interaksi dijamin aman dari hydration race
    await triggerButton.click();

    await expect(modalDialog).toBeVisible();
    await expect(modalDialog).toContainText('Masuk ke Akun Anda');
  });
});

Konfigurasi Playwright dan CI untuk Mencegah False Negative

Selain penanganan hidrasi di level kode pengujian, konfigurasi test runner dan pipeline CI harus dioptimalkan untuk mengurangi flakiness.

1. Selalu Uji Terhadap Production Build di CI

Menjalankan test E2E di atas Nuxt development server (nuxi dev) meningkatkan flakiness secara drastis karena overhead Vite HMR dan transpilation on-the-fly. Selalu build aplikasi terlebih dahulu:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  retries: process.env.CI ? 2 : 0,
  // Hindari starvation CPU di runner CI
  workers: process.env.CI ? 2 : undefined,
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  webServer: {
    command: 'node .output/server/index.mjs',
    port: 3000,
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

2. Konfigurasi GitHub Actions Workflow

Pastikan alur CI menjalankan tahapan build secara terpisah sebelum Playwright dieksekusi:

# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
          
      - name: Install Dependencies
        run: npm ci
        
      - name: Build Nuxt Application
        run: npm run build
        
      - name: Install Playwright Browsers
        run: npx playwright install --with-deps chromium
        
      - name: Run Playwright Tests
        run: npx playwright test
        
      - name: Upload Artifacts on Failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-traces
          path: test-results/

Dengan menyinkronkan interaksi Playwright terhadap lifecycle hidrasi aktual Nuxt 3 dan mengisolasi runner pada production build, celah eksekusi yang memicu event hilang dapat dieliminasi secara total.