Testing di Next.js modern tidak lagi cukup dipandang sebagai satu aktivitas yang seragam. Dengan adanya Server Components, Client Components, route handler, dan alur rendering yang banyak bergantung pada async data fetching, pendekatan pengujian perlu dibedakan berdasarkan lapisan aplikasi. Jika semua diuji dengan cara yang sama, hasilnya sering rapuh, lambat, atau tidak benar-benar memverifikasi perilaku yang penting.

Pada Next.js 16, tantangan utamanya bukan sekadar memilih framework test, tetapi menentukan apa yang sebaiknya diuji di level unit, integration, dan end-to-end. Artikel ini membahas strategi praktis untuk menguji tiap jenis komponen, kapan memakai Vitest atau Jest, kapan Testing Library cukup, dan kapan Playwright lebih tepat. Fokusnya adalah membuat test yang stabil, bermakna, dan mudah dirawat.

Memahami batas pengujian di Next.js 16

Sebelum menulis test, penting untuk memahami bahwa tidak semua bagian aplikasi Next.js berjalan di lingkungan yang sama.

  • Server Components dieksekusi di server. Komponen ini dapat mengakses data backend, cookies, headers, dan API server-side lain tanpa membocorkannya ke browser.
  • Client Components berjalan di browser dan biasanya memuat interaksi UI, event handler, state lokal, dan penggunaan hook seperti useState atau useEffect.
  • Route handler adalah endpoint HTTP pada folder app/api yang menangani request dan response.
  • Integrasi halaman mencakup gabungan rendering server, hidrasi client, navigasi, data fetching, dan perilaku nyata di browser.

Karena lingkungan eksekusinya berbeda, satu jenis tool biasanya tidak ideal untuk semua kebutuhan. Kesalahan umum adalah memaksa semua pengujian memakai DOM test runner, padahal logika server lebih cocok diuji sebagai fungsi async biasa.

Memilih alat yang tepat: Vitest, Jest, Testing Library, dan Playwright

Vitest atau Jest untuk unit dan integration test

Baik Vitest maupun Jest dapat dipakai untuk menguji logika aplikasi. Jika proyek Anda sudah menggunakan ekosistem Vite atau menginginkan startup test yang cepat, Vitest sering lebih nyaman. Jika basis kode lama sudah stabil di Jest atau banyak bergantung pada plugin Jest, tetap tidak masalah menggunakan Jest.

Untuk aplikasi Next.js, keduanya paling cocok untuk:

  • fungsi utilitas
  • service layer untuk akses data
  • mapper, validator, formatter
  • Client Component yang dirender di lingkungan DOM
  • route handler sebagai fungsi request-response

Namun, untuk Server Component, fokuskan test pada logika yang diekstrak dari komponen, bukan memaksa seluruh mekanisme rendering React Server Components diuji seperti komponen DOM biasa.

Testing Library untuk perilaku UI

Testing Library sangat tepat untuk Client Component karena mendorong pengujian dari sudut pandang pengguna. Anda memverifikasi teks, peran elemen, interaksi, dan perubahan state, bukan detail implementasi internal.

Testing Library kurang ideal untuk menguji Server Component secara langsung jika komponen tersebut banyak bergantung pada API server Next seperti cookies(), headers(), atau redirect(). Dalam kasus itu, lebih baik pecah logikanya ke modul yang dapat diuji secara terpisah.

Playwright untuk end-to-end

Playwright adalah pilihan kuat untuk menguji integrasi nyata: request ke server, rendering halaman, navigasi, submit form, autentikasi, middleware, dan perilaku browser. Ini adalah tempat terbaik untuk memverifikasi bahwa Server Component dan Client Component benar-benar bekerja bersama.

Rule praktisnya:

  • Unit test: cepat, fokus, banyak jumlahnya.
  • Integration test: menguji kerja sama beberapa modul.
  • E2E test: lebih sedikit, tapi memverifikasi alur bisnis penting dari ujung ke ujung.

Strategi menguji Server Component

Uji logika server, bukan hanya output JSX

Server Component sering berisi dua hal sekaligus: logika pengambilan data dan representasi UI. Agar mudah diuji, pisahkan kedua tanggung jawab tersebut.

Contoh struktur yang lebih mudah dites:

// app/dashboard/page.tsx
import { getDashboardData } from '@/lib/dashboard'

export default async function DashboardPage() {
  const data = await getDashboardData()
  return <section><h1>Dashboard</h1><p>{data.totalUsers}</p></section>
}
// lib/dashboard.ts
import { cookies, headers } from 'next/headers'

export async function getDashboardData() {
  const cookieStore = await cookies()
  const headerStore = await headers()
  const token = cookieStore.get('session')?.value
  const tenant = headerStore.get('x-tenant-id')

  const res = await fetch(`https://internal-api.example.com/dashboard`, {
    headers: {
      Authorization: `Bearer ${token}`,
      'x-tenant-id': tenant ?? ''
    },
    cache: 'no-store'
  })

  if (!res.ok) throw new Error('Gagal memuat dashboard')
  return res.json()
}

Daripada memaksa test merender DashboardPage secara penuh, lebih berguna menguji getDashboardData(). Ini memberi keuntungan:

  • lebih mudah mem-mock fetch
  • lebih mudah mengontrol cookies dan headers
  • lebih jelas memverifikasi perilaku error
  • lebih kecil risiko test rapuh akibat detail rendering

Contoh unit test untuk service server

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getDashboardData } from '@/lib/dashboard'

vi.mock('next/headers', () => ({
  cookies: vi.fn(async () => ({
    get: (name: string) => name === 'session' ? { value: 'token-123' } : undefined
  })),
  headers: vi.fn(async () => new Headers({ 'x-tenant-id': 'tenant-a' }))
}))

describe('getDashboardData', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', vi.fn())
  })

  it('mengirim token dan tenant ke API internal', async () => {
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({ totalUsers: 42 })
    })

    const result = await getDashboardData()

    expect(fetch).toHaveBeenCalledWith(
      'https://internal-api.example.com/dashboard',
      expect.objectContaining({
        headers: expect.objectContaining({
          Authorization: 'Bearer token-123',
          'x-tenant-id': 'tenant-a'
        })
      })
    )
    expect(result.totalUsers).toBe(42)
  })
})

Yang diuji di sini bukan JSX, tetapi kontrak server-side: apakah data diambil dengan header yang benar dan apakah hasilnya diproses sesuai harapan.

Tantangan umum pada Server Component

  • Mocking cookies dan headers: API seperti cookies() dan headers() bergantung pada request context. Pada unit test, mocking manual biasanya lebih sederhana daripada mencoba mereplikasi runtime penuh Next.
  • Behavior async: Server Component dan fungsi pendukungnya hampir selalu async. Pastikan test benar-benar await promise, dan uji juga jalur gagal seperti fetch timeout atau response non-200.
  • Redirect dan notFound: Jika logika memanggil redirect() atau notFound(), perlakukan itu sebagai side effect framework. Uji kondisi yang memicunya dengan mocking fungsi tersebut, atau verifikasi pada level E2E bahwa browser benar-benar diarahkan.

Prinsip penting: jika sebuah Server Component sulit diuji, sering kali masalahnya ada pada desain. Biasanya logika bisnis terlalu menempel pada lapisan presentasi.

Menguji Client Component dengan Testing Library

Fokus pada perilaku, bukan implementasi

Client Component cocok diuji menggunakan Testing Library karena perilakunya tampak di DOM. Anda sebaiknya memverifikasi apa yang dilihat dan dilakukan pengguna: klik tombol, isi input, muncul loading state, atau pesan error.

'use client'

import { useState } from 'react'

export function SearchBox({ onSearch }: { onSearch: (q: string) => Promise<void> }) {
  const [query, setQuery] = useState('')
  const [loading, setLoading] = useState(false)

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    setLoading(true)
    try {
      await onSearch(query)
    } finally {
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        aria-label="Kata kunci"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Mencari...' : 'Cari'}
      </button>
    </form>
  )
}
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { SearchBox } from './SearchBox'

describe('SearchBox', () => {
  it('mengirim query dan menampilkan loading state', async () => {
    const user = userEvent.setup()
    const onSearch = vi.fn(() => Promise.resolve())

    render(<SearchBox onSearch={onSearch} />)

    await user.type(screen.getByLabelText('Kata kunci'), 'nextjs')
    await user.click(screen.getByRole('button', { name: 'Cari' }))

    expect(onSearch).toHaveBeenCalledWith('nextjs')
  })
})

Hal yang sebaiknya diuji pada Client Component:

  • interaksi pengguna
  • validasi form
  • loading, success, dan error state
  • aksesibilitas dasar melalui role dan label
  • perubahan UI akibat state lokal atau props

Hal yang biasanya tidak perlu diuji di level ini:

  • detail internal hook yang bukan bagian dari kontrak perilaku
  • implementasi CSS spesifik
  • fungsi library pihak ketiga yang sudah punya test sendiri

Menguji route handler

Route handler pada app/api pada dasarnya adalah handler HTTP. Ia layak diuji sebagai fungsi yang menerima request dan mengembalikan response. Ini termasuk level integration ringan karena sering melibatkan parsing request, autentikasi, validasi, dan akses ke service layer.

// app/api/profile/route.ts
import { NextResponse } from 'next/server'
import { getUserFromSession } from '@/lib/auth'

export async function GET(req: Request) {
  const user = await getUserFromSession(req)

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  return NextResponse.json({ id: user.id, email: user.email })
}
import { describe, it, expect, vi } from 'vitest'
import { GET } from '@/app/api/profile/route'
import * as auth from '@/lib/auth'

vi.mock('@/lib/auth', () => ({
  getUserFromSession: vi.fn()
}))

describe('GET /api/profile', () => {
  it('mengembalikan 401 jika tidak ada session', async () => {
    vi.mocked(auth.getUserFromSession).mockResolvedValueOnce(null)

    const req = new Request('http://localhost/api/profile')
    const res = await GET(req)
    const body = await res.json()

    expect(res.status).toBe(401)
    expect(body.error).toBe('Unauthorized')
  })
})

Yang penting diuji pada route handler:

  • status code yang benar
  • struktur response JSON
  • validasi input query, body, dan header
  • cabang autentikasi/otorisasi
  • penanganan error yang dapat diprediksi

Jika route handler menyentuh database, Anda bisa memilih salah satu dari dua pendekatan:

  1. Mock database/service untuk unit atau integration test ringan.
  2. Pakai database test sungguhan untuk integration test yang memverifikasi query dan skema.

Pendekatan kedua lebih realistis, tetapi lebih lambat dan butuh setup isolasi data.

Menguji integrasi halaman dan alur penuh dengan Playwright

Beberapa perilaku hanya benar-benar terlihat ketika aplikasi dijalankan penuh: render Server Component, hidrasi Client Component, navigasi antarhalaman, cookies autentikasi, dan request ke route handler. Di sinilah Playwright sangat berguna.

Contoh skenario E2E yang layak diuji:

  • pengguna login lalu melihat dashboard sesuai session cookie
  • halaman produk memuat data server lalu filter client bekerja
  • route handler merespons benar saat dipanggil dari UI
  • redirect ke login terjadi saat cookie session hilang
import { test, expect } from '@playwright/test'

test('pengguna tanpa session diarahkan ke login', async ({ page }) => {
  await page.goto('/dashboard')
  await expect(page).toHaveURL(/\/login/)
})

test('pengguna dapat mencari data dari halaman dashboard', async ({ page, context }) => {
  await context.addCookies([
    {
      name: 'session',
      value: 'valid-session',
      domain: 'localhost',
      path: '/'
    }
  ])

  await page.goto('/dashboard')
  await page.getByLabel('Kata kunci').fill('laporan')
  await page.getByRole('button', { name: 'Cari' }).click()
  await expect(page.getByText('Hasil pencarian')).toBeVisible()
})

Untuk E2E, jangan mencoba mencakup semua kombinasi data. Fokuslah pada alur bisnis inti dan risiko regresi tertinggi. Test E2E yang terlalu banyak akan memperlambat CI dan meningkatkan biaya perawatan.

Apa yang diuji di unit, integration, dan e2e?

Unit test

  • formatter, parser, validator
  • service server seperti pembentukan request ke backend
  • logika branch berdasarkan cookie/header
  • Client Component sederhana dengan interaksi lokal

Integration test

  • route handler dengan autentikasi dan validasi
  • interaksi Client Component dengan callback async
  • service yang terhubung ke database test atau API mock
  • komposisi beberapa modul server dalam satu alur

E2E test

  • login/logout
  • proteksi route
  • navigasi halaman penting
  • submit form utama
  • alur checkout, dashboard, atau CRUD inti aplikasi

Pola yang sehat biasanya berbentuk piramida: unit test banyak, integration secukupnya, E2E sedikit namun strategis.

Tips mocking data, cookies, headers, dan async behavior

Mock data sedekat mungkin dengan kontrak nyata

Gunakan fixture yang mencerminkan bentuk data produksi. Jangan membuat mock terlalu minimal jika struktur aslinya kompleks, karena test bisa lolos padahal integrasi nyata gagal.

Jangan terlalu agresif mem-mock framework

Mock cookies() dan headers() jika memang perlu, tetapi hindari mem-mock terlalu banyak bagian Next dalam satu test. Jika jumlah mock mulai besar, itu sinyal bahwa pengujian lebih cocok dinaikkan ke level integration atau E2E.

Perhatikan cache dan side effect async

Pada server, fetch dapat memiliki perilaku caching atau deduplikasi tergantung konfigurasi. Saat mengetes, tentukan ekspektasi secara eksplisit dan gunakan stub yang konsisten. Jangan mengasumsikan hasil request berulang tanpa memahami bagaimana data di-cache di aplikasi Anda.

Gunakan helper untuk request context

Jika banyak fungsi server memerlukan cookie dan header, buat helper pembungkus agar test lebih sederhana. Misalnya, injeksikan dependency seperti token atau tenant ID ke service layer, lalu biarkan adapter Next yang membaca cookies() dan headers() tetap tipis.

Kesalahan umum dan cara debugging

  • Menguji terlalu banyak sekaligus: jika satu test melibatkan DOM, fetch, cookies, router, dan database, debugging akan sulit. Pecah berdasarkan lapisan.
  • Snapshot berlebihan: snapshot besar untuk halaman server biasanya cepat basi dan kurang memberi sinyal error yang berguna.
  • Tidak menguji jalur gagal: banyak bug muncul dari response 401, 500, timeout, data kosong, atau cookie hilang.
  • Asumsi sinkron pada proses async: gunakan await, helper async dari Testing Library, dan pastikan promise benar-benar selesai sebelum assertion.

Saat test gagal, tanyakan tiga hal:

  1. Apakah level test sudah tepat?
  2. Apakah mock merepresentasikan perilaku nyata?
  3. Apakah desain kodenya terlalu terikat pada runtime Next?

Penutup

Menguji aplikasi Next.js 16 dengan efektif berarti memahami perbedaan tanggung jawab antara Server Component, Client Component, route handler, dan integrasi halaman. Server Component paling aman diuji lewat logika server yang diekstrak ke service layer. Client Component ideal diuji dengan Testing Library dari perspektif pengguna. Route handler cocok diuji sebagai endpoint HTTP terisolasi. Sementara itu, Playwright sebaiknya dipakai untuk memverifikasi alur nyata yang melibatkan rendering server, hidrasi client, autentikasi, dan navigasi.

Dengan pembagian seperti ini, Anda akan mendapatkan test suite yang lebih cepat, lebih stabil, dan lebih akurat dalam menangkap bug yang benar-benar penting. Kuncinya bukan menambah jumlah test sebanyak mungkin, tetapi memastikan setiap lapisan diuji dengan alat dan strategi yang sesuai.