Flaky test integrasi di pipeline CI biasanya bukan masalah kecil pada test runner, tetapi gejala bahwa lingkungan test tidak deterministik. Pada aplikasi Go Fiber, sumbernya sering berulang: state yang bocor antar test, waktu yang sulit diprediksi, data uji yang tidak terisolasi, dependency jaringan, atau asumsi bahwa test berjalan dalam urutan tertentu.

Solusi yang paling konsisten bukan sekadar menambah retry, melainkan membangun test harness yang mengendalikan setup, teardown, data seed, dependency eksternal, dan cara verifikasi respons HTTP. Dengan pendekatan ini, test integrasi tetap realistis, tetapi lebih stabil saat dijalankan paralel maupun di CI yang resource-nya berubah-ubah.

Mengapa flaky test integrasi sering muncul di aplikasi Go Fiber

Pada level integrasi, test tidak hanya memanggil fungsi, tetapi juga menyentuh HTTP layer, middleware, database, cache, clock, atau service lain. Kombinasi ini membuat test mudah bergantung pada kondisi lingkungan.

1. Ketergantungan waktu

Test yang memakai time.Now(), expiry token, timeout yang terlalu pendek, atau perbandingan timestamp mentah sering gagal secara acak. Di laptop lokal test bisa lolos, tetapi di CI yang lebih lambat hasilnya berubah.

2. Shared state antar test

Masalah ini muncul ketika beberapa test memakai instance app global, database yang sama tanpa reset, file sementara yang tidak dibersihkan, atau environment variable yang dimodifikasi tanpa dikembalikan. Satu test bisa memengaruhi test lain tanpa terlihat jelas.

3. Data tidak terisolasi

Jika seed data memakai ID yang sama, email yang sama, atau tabel yang tidak dibersihkan, hasil test akan bergantung pada test sebelumnya. Ini sangat umum saat test insert data lalu berasumsi database masih kosong.

4. Port dinamis dan startup server

Menjalankan server sungguhan di port tertentu dalam test integrasi sering menimbulkan konflik port, terutama saat test berjalan paralel. Selain itu, test bisa menembak request sebelum server benar-benar siap menerima koneksi.

5. Race condition

Race condition bisa muncul saat handler mengakses state bersama tanpa sinkronisasi, atau ketika test membaca hasil asynchronous job terlalu cepat. Gejalanya sering tidak konsisten: kadang lolos, kadang gagal di CI.

6. Network dependency

Test yang memanggil service eksternal nyata—misalnya auth, payment, atau API internal lain—secara langsung akan terpengaruh latensi, rate limit, DNS, atau outage sesaat. Ini salah satu sumber flaky test terbesar.

7. Asumsi urutan eksekusi

Jika test A harus jalan sebelum test B, maka suite tersebut rapuh. CI dapat mengubah urutan test, menjalankannya di package berbeda, atau mengeksekusi subset tertentu saja.

Prinsip test harness yang stabil untuk Go Fiber

Test harness adalah lapisan utilitas yang menyiapkan aplikasi dan dependency test secara terpusat. Tujuannya bukan menyembunyikan detail penting, tetapi memastikan setiap test mulai dari kondisi yang jelas dan dapat diulang.

Setup dan teardown terpusat

Daripada setiap test membuat app, database, logger, dan dependency sendiri dengan cara berbeda, buat satu jalur setup yang konsisten. Ini mengurangi duplikasi dan mencegah perbedaan perilaku antar test.

  • Buat factory untuk membangun instance Fiber dan dependency.
  • Reset database atau storage sebelum setiap test, bukan hanya sekali per package.
  • Kembalikan perubahan environment variable saat test selesai.
  • Tutup resource seperti koneksi database, file, atau goroutine background.

Fixture deterministik

Fixture sebaiknya eksplisit dan stabil. Hindari data acak kecuali benar-benar perlu, dan jika perlu gunakan seed yang tetap agar hasil dapat direproduksi. Deterministik lebih penting daripada terlihat realistis.

Seed data terisolasi

Setiap test harus memiliki data minimal yang dibutuhkan, bukan bergantung pada data dari test lain. Strategi yang umum dipakai:

  • Reset tabel lalu insert seed untuk tiap test.
  • Gunakan transaksi per test jika storage mendukung dan aplikasi mudah dihubungkan ke transaksi itu.
  • Pakai identifier unik per test bila reset penuh terlalu mahal.

Untuk tim backend, reset terkontrol biasanya lebih mudah dipahami daripada skema fixture besar yang dipakai bersama.

Mock atau fake service untuk dependency eksternal

Untuk test integrasi HTTP aplikasi sendiri, tidak semua dependency harus nyata. Database mungkin tetap asli, tetapi service eksternal sebaiknya diganti dengan fake atau stub agar hasil test tidak bergantung pada jaringan.

Prinsip praktis: integrasikan komponen yang ingin Anda uji, lalu putus dependency yang membuat hasil test tidak deterministik.

Timeout yang masuk akal

Timeout terlalu pendek membuat test sensitif terhadap variasi performa CI. Timeout terlalu panjang membuat kegagalan lambat terdeteksi. Gunakan timeout yang cukup untuk variasi normal, lalu pastikan ada logging atau pesan error yang membantu diagnosis saat timeout terlampaui.

Verifikasi respons HTTP yang relevan

Jangan mengunci test pada detail yang tidak penting, seperti urutan field JSON bila tidak dijamin, header yang berubah dinamis, atau timestamp presisi tinggi. Verifikasi hal yang memang menjadi kontrak API:

  • status code,
  • header penting,
  • struktur dan nilai field utama,
  • efek samping yang relevan di database atau queue.

Contoh struktur test harness untuk handler atau route Fiber

Contoh berikut menunjukkan pendekatan praktis: Fiber app dibuat per test, dependency disuntikkan, dan request dikirim lewat metode testing HTTP bawaan aplikasi. Pendekatan ini biasanya lebih stabil daripada menyalakan server sungguhan di port acak.

package app_test

import (
    "bytes"
    "context"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "time"

    "github.com/gofiber/fiber/v2"
)

type UserStore interface {
    Reset(ctx context.Context) error
    SeedUser(ctx context.Context, u User) error
    FindByEmail(ctx context.Context, email string) (User, error)
    Create(ctx context.Context, u User) error
}

type Mailer interface {
    SendWelcome(ctx context.Context, email string) error
}

type User struct {
    ID    string `json:"id"`
    Email string `json:"email"`
    Name  string `json:"name"`
}

type FakeMailer struct {
    Sent []string
}

func (m *FakeMailer) SendWelcome(ctx context.Context, email string) error {
    m.Sent = append(m.Sent, email)
    return nil
}

type TestHarness struct {
    App    *fiber.App
    Store  UserStore
    Mailer *FakeMailer
}

func NewTestHarness(t *testing.T) *TestHarness {
    t.Helper()

    store := newTestStore(t) // gunakan storage test nyata atau in-memory yang konsisten
    if err := store.Reset(context.Background()); err != nil {
        t.Fatalf("reset store: %v", err)
    }

    mailer := &FakeMailer{}
    app := buildApp(store, mailer)

    return &TestHarness{
        App:    app,
        Store:  store,
        Mailer: mailer,
    }
}

func buildApp(store UserStore, mailer Mailer) *fiber.App {
    app := fiber.New()

    app.Post("/users", func(c *fiber.Ctx) error {
        var req struct {
            Email string `json:"email"`
            Name  string `json:"name"`
        }
        if err := c.BodyParser(&req); err != nil {
            return c.Status(http.StatusBadRequest).JSON(fiber.Map{
                "error": "invalid request",
            })
        }

        ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
        defer cancel()

        if _, err := store.FindByEmail(ctx, req.Email); err == nil {
            return c.Status(http.StatusConflict).JSON(fiber.Map{
                "error": "email already exists",
            })
        }

        user := User{
            ID:    "user-1",
            Email: req.Email,
            Name:  req.Name,
        }
        if err := store.Create(ctx, user); err != nil {
            return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
                "error": "failed to create user",
            })
        }

        if err := mailer.SendWelcome(ctx, user.Email); err != nil {
            return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
                "error": "failed to send welcome email",
            })
        }

        return c.Status(http.StatusCreated).JSON(user)
    })

    return app
}

func TestCreateUser(t *testing.T) {
    h := NewTestHarness(t)

    payload := map[string]string{
        "email": "[email protected]",
        "name":  "Alice",
    }
    body, _ := json.Marshal(payload)

    req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
    req.Header.Set("Content-Type", "application/json")

    resp, err := h.App.Test(req, -1)
    if err != nil {
        t.Fatalf("app test request failed: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusCreated {
        t.Fatalf("expected status %d, got %d", http.StatusCreated, resp.StatusCode)
    }

    var got User
    if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
        t.Fatalf("decode response: %v", err)
    }

    if got.Email != "[email protected]" {
        t.Fatalf("expected email [email protected], got %s", got.Email)
    }

    if len(h.Mailer.Sent) != 1 || h.Mailer.Sent[0] != "[email protected]" {
        t.Fatalf("welcome email was not sent as expected")
    }
}

func TestCreateUserConflict(t *testing.T) {
    h := NewTestHarness(t)

    ctx := context.Background()
    if err := h.Store.SeedUser(ctx, User{
        ID:    "seed-1",
        Email: "[email protected]",
        Name:  "Existing",
    }); err != nil {
        t.Fatalf("seed user: %v", err)
    }

    payload := map[string]string{
        "email": "[email protected]",
        "name":  "Alice",
    }
    body, _ := json.Marshal(payload)

    req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
    req.Header.Set("Content-Type", "application/json")

    resp, err := h.App.Test(req, -1)
    if err != nil {
        t.Fatalf("app test request failed: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusConflict {
        t.Fatalf("expected status %d, got %d", http.StatusConflict, resp.StatusCode)
    }
}

Ada beberapa hal penting dari struktur di atas:

  • Tidak membuka port jaringan. Request dikirim langsung ke app untuk menghindari konflik port dan masalah readiness server.
  • Setup per test. Setiap test membuat harness sendiri sehingga state tidak bocor.
  • Fake service dipakai untuk email agar test tidak bergantung pada jaringan.
  • Seed eksplisit untuk skenario konflik, bukan mengandalkan data sisa test lain.

Pola implementasi yang efektif di CI

1. Bedakan test integrasi lokal dan CI, tetapi jangan ubah perilakunya

Tim sering tergoda membuat test lebih longgar di CI dan lebih ketat di lokal. Ini justru menyembunyikan bug. Yang boleh berbeda adalah konfigurasi resource, logging, atau lokasi service test; kontrak verifikasinya tetap sama.

2. Gunakan environment test yang minimal dan terkontrol

Semakin banyak dependency nyata yang diikutkan, semakin besar peluang flaky. Jika tujuan test adalah memverifikasi route, middleware, validasi, dan persistensi data, Anda tidak perlu memanggil provider email atau API pihak ketiga sungguhan.

3. Hindari test yang bergantung pada jam sistem secara langsung

Bungkus akses waktu dalam interface sederhana jika logika API sensitif terhadap waktu. Dengan begitu, test bisa memakai clock tetap.

type Clock interface {
    Now() time.Time
}

type FixedClock struct {
    T time.Time
}

func (c FixedClock) Now() time.Time { return c.T }

Ini berguna untuk token expiry, sorting berbasis waktu, rate limiting, atau field created_at yang diuji ketat.

4. Jalankan race detector secara terpisah bila perlu

Jika ada indikasi race condition, jalankan suite tertentu dengan race detector pada pipeline yang sesuai. Race detector dapat memperlambat eksekusi, jadi tidak selalu ideal untuk semua job, tetapi sangat membantu menemukan shared memory yang tidak aman.

go test ./... -race

5. Jangan langsung memakai parallel test tanpa audit state

t.Parallel() berguna, tetapi akan memperparah flaky test jika storage, environment variable, atau singleton belum aman. Pastikan dulu setiap test benar-benar independen.

Checklist diagnosis flaky test pada Go Fiber

Saat satu test gagal acak di CI, jangan langsung menambah retry. Gunakan checklist ini untuk mempersempit akar masalah.

  1. Apakah test bergantung pada waktu sekarang?
    Cari penggunaan time.Now(), sleep, expiry, backoff, atau timeout yang sangat pendek.
  2. Apakah ada state global?
    Periksa singleton, cache package-level, config global, logger global, environment variable, dan app instance yang dipakai bersama.
  3. Apakah data test dibersihkan?
    Pastikan tabel, file, atau queue tidak menyisakan data antar test.
  4. Apakah test membuka port jaringan?
    Jika iya, cek konflik port, readiness server, dan cleanup listener.
  5. Apakah ada panggilan ke service eksternal?
    Lihat DNS, network latency, rate limit, dan credential CI.
  6. Apakah test sensitif terhadap urutan eksekusi?
    Jalankan test secara terpisah dan dalam urutan berbeda untuk membuktikan asumsi tersembunyi.
  7. Apakah ada goroutine background yang belum selesai?
    Periksa worker asynchronous, channel, atau queue yang masih aktif saat assert dijalankan.
  8. Apakah assertion terlalu ketat pada field yang dinamis?
    Misalnya membandingkan seluruh body JSON ketika hanya beberapa field yang merupakan kontrak penting.

Kesalahan umum yang justru membuat flaky test bertahan

Menambah retry tanpa memperbaiki akar masalah

Retry bisa berguna sebagai sinyal sementara untuk mengurangi noise, tetapi jika dijadikan solusi utama, bug integrasi asli akan tetap tersembunyi. Tim akhirnya kehilangan kepercayaan pada hasil CI.

Memakai sleep sebagai sinkronisasi

time.Sleep sering dipakai untuk menunggu proses asynchronous selesai. Ini rapuh karena asumsi durasi di laptop belum tentu cocok di CI. Lebih baik expose sinyal selesai, polling dengan batas waktu yang jelas, atau cek efek samping yang terukur.

Mengunci test pada detail implementasi

Jika test memverifikasi terlalu banyak detail internal, perubahan kecil yang aman bisa dianggap gagal. Test integrasi seharusnya fokus pada kontrak perilaku API dan efek samping yang penting.

Strategi pencegahan agar bug tidak lolos sebagai regresi

Mengurangi flaky test bukan berarti melonggarkan kualitas verifikasi. Tujuannya justru membuat sinyal test lebih dapat dipercaya, sehingga kegagalan benar-benar menunjukkan regresi.

  • Standarkan factory test harness di seluruh package HTTP agar semua route mengikuti pola setup yang sama.
  • Buat helper assertion untuk respons API agar verifikasi tetap konsisten, misalnya status code, content type, dan field error.
  • Pisahkan dependency eksternal di balik interface supaya mudah diganti fake pada test integrasi.
  • Dokumentasikan fixture minimum per skenario agar test baru tidak diam-diam bergantung pada data bersama.
  • Audit state global sebelum mengaktifkan parallel execution.
  • Tambahkan test negatif dan edge case seperti konflik data, payload invalid, timeout dependency, dan respons error downstream.
  • Jadikan flaky test sebagai bug, bukan noise yang diterima. Jika satu test flaky, prioritaskan perbaikannya sebelum jumlahnya bertambah.

Kapan memakai server sungguhan, kapan cukup app testing internal

Untuk sebagian besar test integrasi route di Go Fiber, pengujian langsung ke instance app lebih stabil dan lebih cepat. Anda tidak perlu membuka port, menunggu startup, atau menangani konflik listener.

Namun, ada kasus tertentu di mana server sungguhan tetap relevan, misalnya jika Anda perlu menguji integrasi dengan reverse proxy, TLS termination, atau perilaku network stack yang memang berada di luar app. Gunakan pendekatan ini hanya ketika tujuannya jelas, karena kompleksitas dan potensi flakiness lebih tinggi.

Penutup

Inti dari Go Fiber: mengurangi flaky test integrasi di CI dengan test harness adalah membuat test berjalan di lingkungan yang terkendali dan dapat diulang. Fokus utamanya bukan pada tool tambahan, tetapi pada disiplin engineering: setup/teardown terpusat, data terisolasi, dependency eksternal yang dimock atau difake, timeout yang masuk akal, dan assertion yang memeriksa kontrak API secara relevan.

Jika tim backend Anda sering melihat test kadang hijau kadang merah tanpa perubahan kode, hampir selalu ada sumber nondeterminisme yang bisa dihilangkan. Begitu test harness stabil, CI akan kembali menjadi alat deteksi regresi yang bisa dipercaya, bukan sekadar lotere hasil build.