Mencegah flaky test Next.js di CI dengan mock jaringan yang stabil berarti menghilangkan sumber ketidakpastian yang tidak relevan dengan perilaku aplikasi yang sedang diuji. Dalam praktiknya, penyebab paling umum adalah dependensi API eksternal, timing asynchronous yang tidak dikontrol, data yang tidak deterministik, dan state global yang tertinggal dari test sebelumnya.

Untuk aplikasi Next.js, terutama yang memakai App Router, Server Component, atau Route Handler, strategi yang paling aman adalah memutus ketergantungan jaringan di level test yang tepat, lalu menggantinya dengan mock yang konsisten dan dapat diprediksi. Tujuannya bukan memalsukan semuanya, tetapi memastikan setiap jenis test memverifikasi hal yang benar: unit test menguji logika, integration test menguji integrasi internal dengan jaringan yang dimock, dan end-to-end test memverifikasi alur penting dengan jumlah dependensi eksternal seminimal mungkin.

Mengapa flaky test sering muncul di proyek Next.js

Flaky test adalah test yang kadang lolos, kadang gagal, tanpa perubahan kode yang relevan. Di CI, masalah ini lebih terlihat karena environment lebih lambat, lebih paralel, dan sering lebih ketat daripada mesin lokal.

1. Dependensi API eksternal

Jika test bergantung pada layanan pihak ketiga atau service internal yang tidak stabil, hasil test ikut menjadi tidak stabil. Bahkan API yang sehat pun bisa menambah variabilitas melalui latency, rate limit, perubahan data, atau error sementara.

2. Timing async yang rapuh

Masalah umum lain adalah assertion dijalankan sebelum state benar-benar selesai diperbarui. Ini sering terjadi saat komponen melakukan fetch, Route Handler mengakses resource async, atau UI berubah setelah event tertentu. Penggunaan timeout tetap yang terlalu pendek atau terlalu optimistis juga memicu flaky test.

3. Data tidak deterministik

Test yang memakai tanggal saat ini, angka acak, urutan data yang tidak tetap, atau data bersama dari environment CI akan mudah gagal secara acak. Jika output bergantung pada now, Math.random(), ID yang berubah-ubah, atau cache yang belum dibersihkan, hasil test sulit diprediksi.

4. State bocor antar test

State global yang tidak di-reset sering menjadi sumber gangguan tersembunyi. Contohnya: mock yang tidak dikembalikan ke kondisi awal, cache fetch yang tersisa, singleton yang menyimpan state, environment variable yang diubah oleh test lain, atau database/fixture yang dipakai bersama tanpa isolasi.

Menentukan jenis test: unit, integration, dan end-to-end

Flaky test sering berkurang drastis ketika batas tanggung jawab tiap jenis test dibuat jelas.

Kapan memakai unit test

Pakai unit test untuk fungsi murni, parser, formatter, validator, mapper response, atau logika domain yang tidak butuh jaringan nyata. Di sini, mock sebaiknya minimal atau bahkan tidak perlu. Unit test harus sangat cepat dan deterministik.

Kapan memakai integration test

Integration test cocok untuk menguji beberapa komponen atau modul yang berinteraksi, misalnya Route Handler yang memanggil service internal, atau komponen server/client yang memicu request HTTP. Di level ini, mock jaringan yang stabil sangat berguna karena Anda ingin memverifikasi integrasi aplikasi sendiri tanpa ketergantungan pada API sungguhan.

Kapan memakai end-to-end test

Gunakan end-to-end untuk alur utama yang benar-benar penting bagi pengguna, misalnya login, checkout, atau submit form. E2E tetap bisa memakai mock untuk layanan pihak ketiga yang tidak Anda kontrol. Jika semua E2E bergantung pada internet atau sandbox eksternal, CI akan rapuh dan mahal dipelihara.

Aturan praktis: semakin rendah level test, semakin sedikit alasan untuk bergantung pada jaringan nyata. Simpan verifikasi integrasi nyata hanya untuk skenario yang memang membutuhkan kontrak eksternal.

Strategi mock jaringan yang stabil untuk Next.js

Pendekatan yang baik adalah memilih satu pola mock utama per level test, lalu menerapkannya secara konsisten. Untuk Next.js, Anda biasanya akan berhadapan dengan fetch, Route Handler, dan kadang library HTTP seperti Axios. Prinsipnya tetap sama: intercept request, kembalikan response yang deterministik, dan reset state setelah test selesai.

Mock di level fetch

Jika kode Anda langsung memakai fetch, mock terhadap global.fetch cukup efektif untuk unit atau integration test ringan. Keuntungannya sederhana dan eksplisit. Kekurangannya, pendekatan ini bisa menjadi rapuh jika banyak test saling berbagi setup atau jika pola request makin kompleks.

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

async function getUserProfile(userId) {
  const res = await fetch(`https://api.example.test/users/${userId}`)
  if (!res.ok) throw new Error('Gagal mengambil profil')
  return res.json()
}

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

  afterEach(() => {
    vi.unstubAllGlobals()
    vi.restoreAllMocks()
  })

  it('mengembalikan data user dari response API', async () => {
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({ id: 'u1', name: 'Ayu' })
    })

    const data = await getUserProfile('u1')

    expect(fetch).toHaveBeenCalledWith('https://api.example.test/users/u1')
    expect(data).toEqual({ id: 'u1', name: 'Ayu' })
  })
})

Contoh di atas cocok ketika yang diuji adalah logika pemanggilan request dan pengolahan response. Jika test mulai membutuhkan banyak endpoint, status code berbeda, atau body yang bervariasi, mock di level fetch bisa cepat menjadi sulit dirawat.

Mock HTTP yang lebih terstruktur

Untuk integration test, lebih aman memakai interceptor HTTP yang memetakan request ke response berdasarkan method dan URL. Pendekatan ini lebih dekat ke perilaku jaringan nyata, tetapi tetap deterministik. Apa pun tool yang dipilih, pastikan aturan berikut dijaga:

  • setiap endpoint mock didefinisikan jelas per test atau per suite,
  • response punya status code, header, dan body yang realistis,
  • handler di-reset setelah test,
  • request tak dikenal dianggap error, bukan diam-diam lolos.

Prinsip terakhir penting. Jika ada request yang tidak dimock tetapi tetap lolos ke internet, CI bisa gagal secara acak dan sulit dilacak.

Contoh pola handler untuk Route Handler

Jika Anda punya Route Handler Next.js yang memanggil service eksternal, pisahkan logika HTTP ke modul service agar lebih mudah diuji. Route Handler cukup fokus pada request/response aplikasi.

// app/api/profile/route.js
import { getProfile } from '@/lib/profile-service'

export async function GET() {
  try {
    const profile = await getProfile()
    return Response.json(profile, { status: 200 })
  } catch {
    return Response.json({ message: 'Upstream error' }, { status: 502 })
  }
}
// lib/profile-service.js
export async function getProfile() {
  const res = await fetch('https://api.example.test/profile')
  if (!res.ok) throw new Error('Upstream error')
  return res.json()
}

Dengan pemisahan ini, Anda bisa:

  • menguji getProfile dengan mock jaringan,
  • menguji Route Handler untuk memastikan mapping error dan status code benar,
  • menghindari test yang terlalu kompleks dan sulit diisolasi.

Mengendalikan sumber ketidakstabilan lain selain jaringan

Mock jaringan saja tidak cukup jika sumber flaky test berasal dari waktu, data, atau state global.

Isolasi data test

Gunakan fixture yang tetap dan kecil. Hindari bergantung pada data bersama yang bisa diubah test lain. Jika memakai database di integration test, buat data per test atau per suite, lalu bersihkan dengan tegas. Jika tidak perlu database sungguhan, gunakan repository atau adapter yang bisa diganti dengan implementasi in-memory.

const fixedUser = {
  id: 'user-123',
  name: 'Ayu',
  email: 'ayu@example.test'
}

Fixture tetap membuat assertion lebih jelas dan mengurangi kegagalan akibat data berubah bentuk atau urutan.

Kontrol waktu

Jika aplikasi menghitung token expiration, jadwal publish, cache TTL, atau format tanggal, bekukan waktu di test. Jangan biarkan test bergantung pada jam sistem CI.

import { afterEach, beforeEach, vi } from 'vitest'

beforeEach(() => {
  vi.useFakeTimers()
  vi.setSystemTime(new Date('2024-01-01T00:00:00Z'))
})

afterEach(() => {
  vi.useRealTimers()
})

Kontrol waktu juga membantu saat menguji retry, debounce, polling, dan timeout. Daripada menunggu sungguhan, majukan timer secara eksplisit agar test cepat dan stabil.

Hindari data acak yang tidak dikunci

Jika butuh generator data, gunakan seed tetap atau nilai eksplisit. Data acak memang membantu eksplorasi, tetapi bukan dasar yang baik untuk test CI harian kecuali Anda benar-benar mengontrol urutan dan inputnya.

Reset state global

Setelah setiap test, reset mock, cache, storage, dan environment yang diubah. Untuk komponen UI, pastikan render sebelumnya dibersihkan. Untuk module singleton, pertimbangkan desain yang mendukung dependency injection agar state tidak tersembunyi di import global.

Menulis test async yang tidak rapuh

Banyak flaky test bukan karena jaringan semata, melainkan karena pola assertion yang salah.

Tunggu kondisi, bukan menebak waktu

Hindari sleep atau timeout tetap sebagai cara utama menunggu hasil async. Lebih baik tunggu event, state, atau perubahan UI yang spesifik. Dalam test UI, gunakan utilitas yang memang dirancang untuk menunggu kondisi sampai benar atau timeout secara terukur.

Jangan assert terlalu dini

Jika ada request, re-render, dan state update berantai, assertion langsung setelah trigger sering terlalu cepat. Pastikan promise diselesaikan atau UI sudah berada pada state akhir yang diharapkan.

Retry boleh, tapi hati-hati

Retry di test bisa menutupi bug nyata. Gunakan retry hanya untuk skenario yang memang punya variabilitas environment dan sudah dipahami penyebabnya. Jika Anda menambahkan retry di CI, jadikan itu alat diagnosis sementara, bukan solusi permanen.

Pedoman praktis:

  • jangan tambahkan retry untuk seluruh suite secara membabi buta,
  • catat test mana yang butuh retry dan kenapa,
  • buat ticket perbaikan akar masalah,
  • hapus retry setelah sumber flakiness diperbaiki.

Contoh struktur test yang lebih mudah dipelihara

Struktur folder yang jelas membantu mencegah setup tersembunyi dan state yang bocor.

src/
  app/
    api/
      profile/
        route.js
  lib/
    profile-service.js

test/
  fixtures/
    profile.js
  mocks/
    server.js
    handlers.js
  setup/
    test-env.js
  unit/
    profile-service.test.js
  integration/
    profile-route.test.js
  e2e/
    profile-flow.spec.js

Pembagian ini memberi manfaat berikut:

  • fixtures menyimpan data statis yang dapat dipakai ulang,
  • mocks menyimpan handler jaringan terpusat,
  • setup menangani reset global, fake timer, dan konfigurasi environment,
  • unit/integration/e2e memisahkan tujuan test agar tidak bercampur.

Contoh kebijakan setup global

// test/setup/test-env.js
import { afterEach, beforeAll, afterAll } from 'vitest'

beforeAll(() => {
  // start mock server jika tool Anda membutuhkannya
})

afterEach(() => {
  // reset handlers, restore mocks, clear storage/cache
})

afterAll(() => {
  // stop mock server
})

Yang penting bukan nama filenya, melainkan konsistensi: setiap test berjalan dari kondisi awal yang sama.

Workflow verifikasi di CI agar flaky test cepat terdeteksi

CI yang baik tidak hanya menjalankan test, tetapi juga membantu menemukan pola ketidakstabilan.

1. Pastikan test gagal jika ada request tak dikenal

Jangan izinkan akses jaringan keluar kecuali memang diizinkan. Ini salah satu cara paling efektif mencegah kebocoran dependensi eksternal ke pipeline.

2. Jalankan test dalam kondisi yang mirip CI saat lokal

Sediakan command lokal yang meniru CI: mode non-interaktif, environment variable yang sama, dan jumlah worker yang sebanding bila memungkinkan. Tujuannya agar bug yang muncul di CI lebih mudah direproduksi.

3. Pisahkan test cepat dan test mahal

Jalankan unit dan integration test yang stabil di setiap commit. Jalankan E2E yang lebih mahal secara selektif, tetapi tetap cukup sering untuk menangkap regresi penting.

4. Simpan log request dan artefak saat gagal

Untuk test yang berinteraksi dengan mock jaringan, log request yang masuk, endpoint yang tidak cocok, response status, dan body ringkas. Pada E2E, simpan screenshot atau trace jika tool Anda mendukungnya.

5. Ulangi suite tertentu untuk mendeteksi flakiness

Sebagai langkah diagnosis, Anda bisa menjalankan subset test berulang beberapa kali di branch atau job terpisah. Bukan untuk menutupi masalah, tetapi untuk mengonfirmasi bahwa perbaikan benar-benar membuat test stabil.

Trade-off mock vs test nyata

Tidak ada satu pendekatan yang cocok untuk semua level.

Kelebihan mock jaringan

  • lebih cepat dan stabil di CI,
  • mudah menguji error path yang sulit direproduksi,
  • menghilangkan ketergantungan pada layanan eksternal,
  • membuat test lebih deterministik.

Kekurangan mock jaringan

  • bisa menyimpang dari perilaku API nyata jika kontrak berubah,
  • mudah memberi rasa aman palsu jika response mock terlalu disederhanakan,
  • perlu disiplin pemeliharaan fixture dan handler.

Kapan tetap perlu test nyata

Tetap sediakan sejumlah kecil test kontrak atau smoke test terhadap environment yang nyata jika integrasi eksternal bersifat kritis. Namun, tempatkan test ini secara terpisah dari suite utama CI yang harus cepat dan stabil.

Prinsip seimbang: mayoritas test harian sebaiknya deterministik dengan mock stabil. Test nyata dipakai secukupnya untuk memverifikasi bahwa asumsi mock masih sesuai dengan sistem eksternal.

Checklist anti-flaky untuk Next.js di CI

  • Semua request HTTP di unit/integration test dimock secara eksplisit.
  • Request tak dikenal menyebabkan test gagal.
  • Mock, cache, timer, storage, dan environment di-reset setelah tiap test.
  • Fixture data tetap, kecil, dan tidak bergantung pada waktu nyata.
  • Jam sistem dibekukan untuk logika yang sensitif terhadap waktu.
  • Assertion async menunggu kondisi yang benar, bukan timeout sembarang.
  • Retry dipakai terbatas dan didokumentasikan alasannya.
  • Unit, integration, dan E2E dipisahkan sesuai tujuan.
  • Route Handler dan service dipisah agar mudah diuji.
  • CI menyimpan log/artefak saat test gagal.

Langkah pencegahan regresi

Setelah flaky test mulai berkurang, tantangannya adalah mencegah masalah yang sama kembali muncul.

  1. Buat aturan review code: setiap request jaringan baru harus punya strategi test yang jelas.
  2. Standarkan helper test: satu cara untuk membuat mock response, satu tempat untuk reset state, satu pola fixture.
  3. Audit test yang sering gagal: identifikasi pola berulang seperti timeout, data dinamis, atau dependency global.
  4. Batasi side effect di module scope: hindari inisialisasi jaringan, cache, atau singleton berat saat file di-import.
  5. Tambahkan smoke test seperlunya: verifikasi integrasi nyata secara terpisah agar perubahan kontrak eksternal tidak luput.

Penutup

Untuk mencegah flaky test Next.js di CI dengan mock jaringan yang stabil, fokus utama Anda adalah membuat test deterministik: putus ketergantungan API eksternal di level yang tepat, kontrol waktu dan data, serta reset semua state setelah test selesai. Pada aplikasi Next.js, terutama yang memakai App Router dan Route Handler, pemisahan antara handler, service, dan mock HTTP akan sangat membantu menjaga suite tetap cepat, jelas, dan dapat dipercaya.

Jika Anda hanya mengambil satu langkah setelah membaca artikel ini, mulailah dari dua hal: blokir request jaringan yang tidak dimock dan reset seluruh state global setelah tiap test. Dua perubahan tersebut sering memberi dampak terbesar dalam mengurangi flaky test di CI.