Testing pada aplikasi Nuxt 3 sebaiknya tidak hanya mengandalkan satu jenis pengujian. Jika seluruh validasi diletakkan di end-to-end test, suite akan lambat, sulit di-debug, dan mahal dirawat. Sebaliknya, jika hanya menulis unit test, banyak integrasi UI, routing, middleware, dan perilaku browser yang lolos tanpa terdeteksi.

Pendekatan yang lebih realistis untuk tim developer adalah strategi testing berlapis: unit test untuk logika kecil dan deterministik, component test untuk interaksi UI, lalu end-to-end test untuk alur bisnis utama. Pada Nuxt 3, kombinasi Vitest untuk pengujian cepat di level kode dan Playwright untuk simulasi pengguna nyata adalah pilihan yang praktis.

Artikel ini membahas cara menyusun strategi tersebut, contoh struktur folder, mock API, pengujian middleware, serta integrasi CI sederhana yang cocok untuk proyek tim.

Mengapa Perlu Strategi Testing Berlapis

Setiap level testing menjawab risiko yang berbeda.

  • Unit test cocok untuk composable, helper, formatter, validator, dan fungsi transformasi data.
  • Component test cocok untuk memverifikasi state UI, event, props, emit, dan perilaku interaktif komponen.
  • End-to-end test cocok untuk memeriksa integrasi penuh: login, routing, proteksi halaman, form submission, dan respons aplikasi di browser.

Dengan pembagian seperti ini, tim bisa memperoleh beberapa keuntungan:

  • Feedback cepat saat ada bug di level logika.
  • Bug regresi UI lebih cepat terdeteksi.
  • Alur kritis bisnis tetap diamankan oleh E2E test.
  • Biaya maintenance lebih rendah dibanding memaksa semua skenario masuk ke Playwright.

Prinsip praktisnya: semakin rendah level test, semakin cepat dan murah dijalankan. Semakin tinggi level test, semakin besar cakupan integrasi, tetapi semakin tinggi pula biaya eksekusinya.

Struktur Folder Testing yang Rapi

Pemisahan folder test membantu tim memahami jenis test, utilitas bersama, dan fixture yang digunakan. Salah satu struktur yang cukup umum untuk Nuxt 3 adalah seperti berikut:

project-root/
├─ components/
├─ composables/
├─ middleware/
├─ pages/
├─ server/
├─ utils/
├─ tests/
│  ├─ unit/
│  │  ├─ composables/
│  │  └─ utils/
│  ├─ component/
│  ├─ e2e/
│  │  ├─ auth/
│  │  ├─ navigation/
│  │  └─ forms/
│  ├─ fixtures/
│  ├─ mocks/
│  └─ setup/
├─ vitest.config.ts
└─ playwright.config.ts

Pemisahan ini memudahkan beberapa hal:

  • Test cepat dan test browser tidak bercampur.
  • Mock API atau data fixture bisa dipakai ulang.
  • Setup environment per jenis test lebih jelas.

Jika proyek masih kecil, struktur dapat disederhanakan. Namun untuk tim yang aktif mengembangkan fitur, struktur eksplisit biasanya lebih mudah dirawat.

Menyiapkan Vitest untuk Unit dan Component Test

Vitest cocok untuk Nuxt 3 karena cepat, modern, dan familiar bagi developer yang sudah memakai Vite. Umumnya Anda memerlukan konfigurasi environment DOM untuk component test dan setup file untuk mock global atau helper umum.

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./tests/setup/vitest.setup.ts'],
    include: ['tests/unit/**/*.spec.ts', 'tests/component/**/*.spec.ts']
  }
})

Pada file setup, Anda bisa meletakkan reset mock, polyfill sederhana, atau helper umum.

import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/vue'

afterEach(() => {
  cleanup()
})

Untuk komponen Vue/Nuxt, banyak tim memilih @testing-library/vue karena mendorong pengujian dari sudut pandang pengguna, bukan detail implementasi internal. Ini membantu test tetap stabil saat refactor dilakukan.

Unit Test untuk Composable dan Utility

Menguji Utility yang Deterministik

Utility adalah target paling ideal untuk unit test karena biasanya tidak bergantung pada DOM, router, atau network. Misalnya, fungsi validasi email:

export function isValidEmail(email: string) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
import { describe, it, expect } from 'vitest'
import { isValidEmail } from '~/utils/validation'

describe('isValidEmail', () => {
  it('mengembalikan true untuk email valid', () => {
    expect(isValidEmail('user@example.com')).toBe(true)
  })

  it('mengembalikan false untuk email tidak valid', () => {
    expect(isValidEmail('invalid-email')).toBe(false)
  })
})

Jenis test seperti ini sederhana, cepat, dan sangat efektif untuk mencegah bug kecil yang sering muncul kembali saat refactor.

Menguji Composable yang Memiliki State

Pada Nuxt 3, composable sering memuat logika inti aplikasi: state loading, pengolahan data API, filtering, atau formatting. Misalnya composable login sederhana:

export function useLogin() {
  const loading = ref(false)
  const error = ref<string | null>(null)

  const login = async (email: string, password: string) => {
    loading.value = true
    error.value = null

    try {
      const data = await $fetch('/api/login', {
        method: 'POST',
        body: { email, password }
      })
      return data
    } catch (err) {
      error.value = 'Login gagal'
      throw err
    } finally {
      loading.value = false
    }
  }

  return { loading, error, login }
}

Untuk menguji composable ini, fokus pada perubahan state dan hasil akhir, bukan implementasi internalnya.

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useLogin } from '~/composables/useLogin'

describe('useLogin', () => {
  beforeEach(() => {
    vi.restoreAllMocks()
  })

  it('set loading selama request berjalan', async () => {
    vi.stubGlobal('$fetch', vi.fn().mockResolvedValue({ token: 'abc' }))

    const { loading, login } = useLogin()
    const promise = login('user@example.com', 'secret')

    expect(loading.value).toBe(true)
    await promise
    expect(loading.value).toBe(false)
  })

  it('set error saat request gagal', async () => {
    vi.stubGlobal('$fetch', vi.fn().mockRejectedValue(new Error('Unauthorized')))

    const { error, login } = useLogin()

    await expect(login('user@example.com', 'wrong')).rejects.toThrow()
    expect(error.value).toBe('Login gagal')
  })
})

Mengapa pendekatan ini baik? Karena yang diuji adalah kontrak perilaku: kapan loading aktif, kapan error diisi, dan apa yang terjadi saat API gagal. Ini lebih tahan terhadap refactor dibanding menguji detail teknis yang terlalu spesifik.

Kesalahan Umum pada Unit Test

  • Terlalu banyak mock sampai perilaku nyata tidak lagi terwakili.
  • Menguji implementasi internal alih-alih hasil akhir.
  • Membiarkan state global bocor antar test karena mock tidak di-reset.
  • Menggabungkan terlalu banyak skenario dalam satu test case.

Component Test untuk UI Interaktif

Setelah logika dasar aman, langkah berikutnya adalah memverifikasi komponen yang dipakai pengguna. Contoh umum adalah form login, modal, dropdown, atau komponen pencarian.

Misalnya komponen LoginForm.vue memiliki input email, password, tombol submit, state loading, dan pesan error. Di level component test, Anda tidak perlu menjalankan browser penuh. Cukup render komponen, lakukan interaksi, lalu pastikan UI berubah sesuai harapan.

import { render, screen, fireEvent } from '@testing-library/vue'
import { vi, describe, it, expect } from 'vitest'
import LoginForm from '~/components/LoginForm.vue'

describe('LoginForm', () => {
  it('menampilkan error validasi jika field kosong', async () => {
    render(LoginForm)

    await fireEvent.click(screen.getByRole('button', { name: /masuk/i }))

    expect(screen.getByText(/email wajib diisi/i)).toBeInTheDocument()
    expect(screen.getByText(/password wajib diisi/i)).toBeInTheDocument()
  })

  it('emit submit saat input valid', async () => {
    const { emitted } = render(LoginForm)

    await fireEvent.update(screen.getByLabelText(/email/i), 'user@example.com')
    await fireEvent.update(screen.getByLabelText(/password/i), 'secret123')
    await fireEvent.click(screen.getByRole('button', { name: /masuk/i }))

    expect(emitted()).toHaveProperty('submit')
  })
})

Di sini, test memverifikasi perilaku yang benar-benar terlihat oleh pengguna: validasi, input, dan event submit. Pendekatan ini lebih bernilai daripada hanya memeriksa apakah method tertentu dipanggil.

Kapan Component Test Perlu Dipakai

  • Komponen punya interaksi non-trivial.
  • Ada conditional rendering berdasarkan props atau state.
  • Ada integrasi dengan composable lokal yang memengaruhi UI.
  • Bug regresi sering muncul pada perilaku form, modal, tab, atau filter.

Namun tidak semua komponen perlu diuji. Komponen presentasional yang sangat sederhana, misalnya hanya membungkus markup tanpa logika, sering kali tidak memberikan ROI tinggi bila dibuatkan test khusus.

Menguji Middleware Nuxt 3

Middleware pada Nuxt 3 sering dipakai untuk proteksi route, redirect berdasarkan autentikasi, atau pemeriksaan role. Karena middleware memengaruhi akses halaman, bug di sini biasanya berdampak besar.

Misalnya middleware auth sederhana:

export default defineNuxtRouteMiddleware(() => {
  const token = useCookie('token')

  if (!token.value) {
    return navigateTo('/login')
  }
})

Pengujiannya dapat dilakukan dengan mem-mock dependency seperti cookie dan redirect:

import authMiddleware from '~/middleware/auth'
import { describe, it, expect, vi } from 'vitest'

describe('auth middleware', () => {
  it('redirect ke /login jika token tidak ada', async () => {
    vi.stubGlobal('useCookie', vi.fn(() => ({ value: null })))
    vi.stubGlobal('navigateTo', vi.fn((path) => path))

    const result = await authMiddleware()
    expect(result).toBe('/login')
  })
})

Jika middleware lebih kompleks, misalnya berbasis role atau permission, buat test terpisah untuk tiap cabang logika. Ini jauh lebih murah daripada menunggu semua kasus hanya terdeteksi lewat E2E test.

Mock API dan Test Data yang Realistis

Salah satu sumber test yang rapuh adalah data palsu yang terlalu sederhana. Untuk menjaga test tetap berguna, gunakan fixture yang merepresentasikan struktur data produksi secara masuk akal.

Contoh fixture respons login:

{
  "user": {
    "id": 1,
    "name": "Demo User",
    "email": "user@example.com"
  },
  "token": "fake-jwt-token"
}

Untuk unit dan component test, mocking biasanya cukup dilakukan lewat vi.fn() atau stub global. Untuk E2E test, Playwright bisa melakukan network interception agar skenario lebih stabil dan tidak selalu bergantung pada backend nyata.

Trade-off-nya adalah sebagai berikut:

  • Mock API: lebih cepat, stabil, cocok untuk CI, tetapi tidak memverifikasi integrasi backend sesungguhnya.
  • API nyata: cakupan lebih realistis, tetapi lebih lambat, rentan flake, dan lebih sulit dikontrol data awalnya.

Pada praktik tim, kombinasi keduanya sering paling efektif: mayoritas test memakai mock, lalu sejumlah kecil smoke test dijalankan terhadap environment nyata.

End-to-End Test dengan Playwright

Playwright cocok untuk menguji alur pengguna utama pada browser sungguhan. Fokuskan E2E test pada skenario yang benar-benar bernilai bisnis dan sering rusak saat perubahan fitur dilakukan.

Konfigurasi Dasar

import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './tests/e2e',
  use: {
    baseURL: 'http://127.0.0.1:3000',
    headless: true
  },
  webServer: {
    command: 'npm run dev',
    url: 'http://127.0.0.1:3000',
    reuseExistingServer: !process.env.CI
  }
})

Jika aplikasi memerlukan backend terpisah, Anda dapat menyalakan service tersebut di pipeline CI, atau menggantinya dengan route mock agar test lebih deterministik.

Skenario Login

import { test, expect } from '@playwright/test'

test('user dapat login', async ({ page }) => {
  await page.route('**/api/login', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        user: { id: 1, name: 'Demo User', email: 'user@example.com' },
        token: 'fake-jwt-token'
      })
    })
  })

  await page.goto('/login')
  await page.getByLabel('Email').fill('user@example.com')
  await page.getByLabel('Password').fill('secret123')
  await page.getByRole('button', { name: 'Masuk' }).click()

  await expect(page).toHaveURL('/dashboard')
  await expect(page.getByText('Demo User')).toBeVisible()
})

Test ini memastikan integrasi antara form, request, state login, redirect, dan tampilan setelah autentikasi berhasil.

Skenario Navigasi dan Middleware

import { test, expect } from '@playwright/test'

test('halaman dashboard mengarahkan user anonim ke login', async ({ page }) => {
  await page.goto('/dashboard')
  await expect(page).toHaveURL(/\/login/)
})

Ini adalah cara paling langsung untuk memvalidasi middleware route protection dari sudut pandang pengguna.

Skenario Form Submission

import { test, expect } from '@playwright/test'

test('user dapat mengirim form kontak', async ({ page }) => {
  await page.route('**/api/contact', async (route) => {
    const request = route.request()
    const payload = request.postDataJSON()

    expect(payload.email).toBe('user@example.com')

    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ success: true })
    })
  })

  await page.goto('/contact')
  await page.getByLabel('Nama').fill('Demo User')
  await page.getByLabel('Email').fill('user@example.com')
  await page.getByLabel('Pesan').fill('Halo dari Playwright')
  await page.getByRole('button', { name: 'Kirim' }).click()

  await expect(page.getByText(/pesan berhasil dikirim/i)).toBeVisible()
})

Untuk menjaga suite tetap cepat, pilih hanya form dan alur yang paling kritis. Tidak semua halaman harus memiliki E2E test lengkap.

Integrasi CI Sederhana

Menjalankan test hanya di mesin lokal tidak cukup. Regresi sering muncul saat kode digabungkan dari banyak branch. Karena itu, minimal jalankan unit, component, dan E2E test di CI pada setiap pull request atau push ke branch utama.

Contoh workflow sederhana di GitHub Actions:

name: test

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run test:unit
      - run: npm run test:component
      - run: npx playwright install --with-deps
      - run: npm run test:e2e

Di proyek nyata, Anda mungkin ingin memisahkan job agar lebih cepat, misalnya unit/component berjalan paralel dengan E2E. Anda juga dapat menambahkan artifact untuk screenshot atau trace Playwright agar debugging lebih mudah saat test gagal.

Tips Praktis Agar Test Tidak Mudah Rapuh

  • Gunakan selector yang stabil seperti role, label, atau teks yang memang penting bagi pengguna.
  • Hindari terlalu bergantung pada class CSS atau struktur DOM detail.
  • Reset mock dan state global setelah setiap test.
  • Jangan memasukkan terlalu banyak skenario ke satu E2E test.
  • Gunakan fixture data yang konsisten dan mudah dipahami tim.
  • Simpan test berdasarkan fitur agar ownership lebih jelas.

Satu hal penting lainnya adalah meninjau test layaknya kode produksi. Test yang buruk bisa sama berbahayanya dengan kode aplikasi yang buruk: sulit dipahami, sering gagal acak, dan akhirnya diabaikan tim.

Penutup

Menguji aplikasi Nuxt 3 dengan Vitest dan Playwright akan lebih efektif jika dilakukan secara berlapis. Unit test menjaga composable dan utility tetap aman saat refactor. Component test memverifikasi perilaku UI interaktif tanpa biaya browser penuh. End-to-end test memastikan alur penting seperti login, navigasi terlindungi middleware, dan form submission benar-benar berjalan dari perspektif pengguna.

Untuk tim developer, pendekatan ini realistis karena menyeimbangkan kecepatan, cakupan, dan biaya maintenance. Mulailah dari alur bisnis yang paling sering rusak atau paling berdampak, susun struktur folder yang jelas, gunakan mock API secara disiplin, lalu jalankan semuanya di CI. Dengan begitu, bug regresi dapat ditekan tanpa membuat pipeline pengembangan menjadi lambat dan rapuh.