Flaky test pada suite pengujian unit dan komponen Nuxt 3 umumnya disebabkan oleh tiga faktor: kebocoran SSR payload state antar pengujian, race condition pada lifecycle asynchronous komponen, dan pemanggilan network I/O yang tidak terisolasi. Nuxt 3 mengoptimalkan data fetching melalui useAsyncData dengan mekanisme caching berbasis key pada nuxtApp.payload.data. Jika cache ini tidak dibersihkan saat test runner mengeksekusi test case berikutnya, state pengujian sebelumnya akan mencemari eksekusi berikutnya.

Akar Penyebab Ketidakstabilan Pengujian Nuxt 3

Saat menjalankan Vitest di lingkungan Node.js, instance pengujian berbagi memori proses yang sama jika tidak dikonfigurasi dengan isolasi ketat. Ada tiga sumber utama ketidakstabilan (flakiness):

  • SSR Context Leaks: Objek nuxtApp menyimpan data hasil eksekusi useAsyncData. Tanpa pembersihan eksplisit, data dari test case A akan langsung dikonsumsi oleh test case B tanpa memicu handler asynchronous.
  • Race Condition Suspense: Komponen Nuxt 3 yang memanggil useAsyncData memiliki async setup. Menggunakan mount standar dari @vue/test-utils alih-alih wrapper runtime Nuxt akan memicu unhandled promise atau pembacaan DOM sebelum hidrasi selesai.
  • Network Delay & Unmocked Endpoints: Mengandalkan request HTTP sungguhan atau mock global yang tidak sinkron menyebabkan assertion dieksekusi sebelum payload diterima (timeout).

Konfigurasi Vitest dengan @nuxt/test-utils/runtime

Langkah pertama mengeliminasi flaky test adalah menjalankan pengujian di dalam Nuxt Runtime Environment resmi, bukan sekadar JSDOM kosong.

// vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'

export default defineVitestConfig({
  test: {
    environment: 'nuxt',
    environmentOptions: {
      nuxt: {
        domEnvironment: 'happy-dom',
        mock: {
          intersectionObserver: true,
          indexedDb: true,
        },
      },
    },
    globals: true,
    clearMocks: true,
    restoreMocks: true,
  },
})

Isolasi State Menggunakan clearNuxtData di Lifecycle Hook

Nuxt 3 menyediakan utilitas clearNuxtData() untuk menghapus entri dari nuxtApp.payload.data dan nuxtApp.static.data. Panggil fungsi ini pada blok beforeEach atau afterEach untuk memastikan tidak ada residu state.

// tests/setup.ts atau langsung pada file test spec
import { beforeEach, vi } from 'vitest'
import { clearNuxtData } from '#app'

beforeEach(async () => {
  // Hapus semua cache key useAsyncData dan useFetch
  clearNuxtData()
  vi.clearAllMocks()
})

Teknik Mock Data Fetching dengan mockNuxtImport

Jangan memodifikasi prototype window.$fetch atau merekayasa internal useNuxtApp() secara manual karena dapat merusak integrasi plugin Nuxt. Gunakan mockNuxtImport dari @nuxt/test-utils/runtime untuk menimpa composable secara aman sebelum modul dimuat ke memori.

import { mockNuxtImport } from '@nuxt/test-utils/runtime'

// Mock useAsyncData langsung pada level import Nuxt
mockNuxtImport('useAsyncData', (key: string, handler: () => any) => {
  return () => {
    if (key === 'user-profile') {
      return {
        data: ref({ id: 1, name: 'Budi Santoso', role: 'admin' }),
        pending: ref(false),
        error: ref(null),
        refresh: vi.fn(),
        status: ref('success'),
      }
    }
    // Fallback eksekusi handler default jika key tidak cocok
    return {
      data: ref(null),
      pending: ref(false),
      error: ref(null),
      refresh: vi.fn(),
      status: ref('idle'),
    }
  }
})
Catatan: mockNuxtImport harus dipanggil di level teratas (top-level scope) file pengujian, sejajar dengan import statis, karena macro ini di-hoist oleh compiler Vitest.

Implementasi Komponen

Berikut adalah komponen profil pengguna yang melakukan data fetching secara asynchronous menggunakan useAsyncData:

<!-- components/UserProfile.vue -->
<template>
  <div class="profile-card">
    <div v-if="pending" data-testid="loading">Memuat data...</div>
    <div v-else-if="error" data-testid="error">Gagal memuat profil</div>
    <div v-else-if="data" data-testid="content">
      <h1>{{ data.name }}</h1>
      <span>{{ data.role }}</span>
    </div>
  </div>
</template>

<script setup lang="ts">
interface User {
  id: number
  name: string
  role: string
}

const { data, pending, error } = await useAsyncData<User>(
  'user-profile',
  () => $fetch('/api/user')
)
</script>

Perbandingan: Kode Rentan Flaky vs Solusi Deterministik

1. Contoh Kode Rentan Flaky (Anti-Pattern)

Kode di bawah ini rentan gagal (flaky) karena menggunakan mount biasa untuk komponen async, mengandalkan timeout buatan, dan membiarkan state user-profile bocor ke pengujian berikutnya.

// UserProfile.flaky.spec.ts (JANGAN GUNAKAN INI)
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import UserProfile from '~/components/UserProfile.vue'

describe('UserProfile Component (Flaky)', () => {
  it('merender profil pengguna', async () => {
    // Bencana 1: mount biasa gagal menangani async setup Nuxt dengan stabil
    const wrapper = mount(UserProfile)
    
    // Bencana 2: Arbitrary sleep/timeout memicu race condition di CI/CD
    await new Promise((resolve) => setTimeout(resolve, 500))
    
    expect(wrapper.find('[data-testid="content"]').exists()).toBe(true)
  })
})

2. Contoh Solusi Deterministik

Gunakan mountSuspended dari @nuxt/test-utils/runtime. Utilitas ini otomatis membungkus komponen dalam tag <Suspense> Nuxt, menuntaskan unresolved promises pada tahap setup, dan melakukan assertion langsung tanpa perlu setTimeout.

// UserProfile.deterministic.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime'
import { clearNuxtData } from '#app'
import UserProfile from '~/components/UserProfile.vue'

// Mock $fetch agar useAsyncData mengeksekusi pipeline Nuxt secara natural
const mockUserResponse = vi.fn()
mockNuxtImport('$fetch', () => mockUserResponse)

describe('UserProfile Component (Deterministik)', () => {
  beforeEach(async () => {
    // 1. Bersihkan internal payload cache Nuxt 3
    clearNuxtData('user-profile')
    mockUserResponse.mockReset()
  })

  it('menampilkan konten profil saat API sukses', async () => {
    mockUserResponse.mockResolvedValueOnce({
      id: 1,
      name: 'Budi Santoso',
      role: 'admin',
    })

    // mountSuspended mengisolasi context dan menunggu hidrasi selesai
    const component = await mountSuspended(UserProfile)

    expect(component.find('[data-testid="content"]').exists()).toBe(true)
    expect(component.find('h1').text()).toBe('Budi Santoso')
    expect(component.find('span').text()).toBe('admin')
  })

  it('menampilkan pesan error ketika API gagal', async () => {
    mockUserResponse.mockRejectedValueOnce(new Error('Network Error'))

    const component = await mountSuspended(UserProfile)

    expect(component.find('[data-testid="error"]').exists()).toBe(true)
    expect(component.find('[data-testid="content"]').exists()).toBe(false)
  })
})

Checklist Pencegahan Flaky Test di CI/CD

  1. Ganti mount dengan mountSuspended: Wajib untuk seluruh komponen yang memiliki async setup atau menggunakan composable bawaan Nuxt.
  2. Eksekusi clearNuxtData pada beforeEach: Menjamin idempotensi tes tanpa ketergantungan urutan eksekusi (execution order dependency).
  3. Hindari vi.waitFor atau setTimeout: Gunakan lifecycle resolution bawaan Vue Test Utils dan mountSuspended untuk menunggu DOM updates.
  4. Cek Cache Key: Pastikan key string pada useAsyncData('key', ...) unik untuk setiap domain data atau selalu direset sebelum pengujian dimulai.