Mengapa Flaky Test Kerap Terjadi pada Pengujian Go Fiber

Flaky test adalah pengujian yang memberikan hasil tidak konsisten (kadang lolos, kadang gagal) tanpa adanya perubahan pada basis kode. Pada aplikasi backend berbasis Go Fiber, akar masalah pengujian tidak stabil ini umumnya bersumber dari tiga hal:

  • Shared State Antar-Pengujian: Penggunaan satu instance *fiber.App global untuk seluruh suite pengujian menyebabkan route, middleware, dan state storage saling bertumpuk antar-skenario.
  • Race Condition pada Eksekusi Paralel (t.Parallel()): Ketika runner pengujian Go menjalankan subtest secara bersamaan, goroutine yang membaca dan menulis data mock atau in-memory storage secara simultan memicu data race jika tidak diproteksi oleh mutual exclusion atau isolasi total.
  • Ketidakpastian Timeout pada Lingkungan CI: Mesin CI/CD sering kali memiliki resource CPU terbatas. Handler yang mengandalkan default timeout bawaan dapat mengalami batas waktu habis prematur saat antrean runner penuh.

Mekanisme Kerja app.Test: Menguji Tanpa Port TCP

Fiber menyediakan fungsi bawaan app.Test(*http.Request, ...int) (*http.Response, error). Fungsi ini tidak membuka socket TCP nyata dan tidak melakukan binding ke port jaringan (seperti :3000 atau :8080). Alih-alih melewati TCP stack sistem operasi, app.Test mengonversi http.Request standar menjadi fasthttp.Request, lalu mengeksekusinya langsung melalui pipeline routing Fiber di dalam memori.

Pendekatan ini memiliki keuntungan krusial:

  • Eksekusi pengujian berjalan jauh lebih cepat karena tidak ada overhead handshake TCP.
  • Menghilangkan galat "bind: address already in use" saat pengujian dijalankan paralel.
  • Kompatibel dengan ekosistem standar Go karena menerima parameter *http.Request dari paket net/http dan mengembalikan *http.Response standar.

Konfigurasi Timeout app.Test

Secara default, app.Test menerapkan timeout sebesar 1.000 milidetik (1 detik). Jika handler mengeksekusi operasi asinkron yang kompleks atau runner CI mengalami penurunan performa, pengujian akan gagal dengan galat timeout. Teruskan nilai timeout eksplisit dalam satuan milidetik, atau gunakan nilai -1 untuk menonaktifkan timeout bawaan dan menyerahkan manajemen batas waktu ke context bawaan request (req.Context()):

// Menonaktifkan internal timeout Fiber, biarkan context request yang mengatur
resp, err := app.Test(req, -1)

// Atau tetapkan timeout eksplisit yang lebih longgar untuk pipeline CI
resp, err := app.Test(req, 5000) // 5 detik

Pola Desain Test Harness Terisolasi

Untuk memastikan determinisme, setiap unit test harus memiliki dependensi dan instance aplikasi yang berdiri sendiri. Pisahkan pembuatan router ke dalam fungsi pabrik (factory function) yang menerima interface dependensi, bukan struct konkret.

1. Deklarasi Interface dan Handler

Definisikan kontrak service agar dapat diganti dengan mock saat pengujian:

package user

import (
	"context"
	"github.com/gofiber/fiber/v2"
)

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

type UserService interface {
	GetUser(ctx context.Context, id string) (*User, error)
}

type Handler struct {
	service UserService
}

func NewHandler(s UserService) *Handler {
	return &Handler{service: s}
}

func (h *Handler) GetUserHandler(c *fiber.Ctx) error {
	id := c.Params("id")
	user, err := h.service.GetUser(c.UserContext(), id)
	if err != nil {
		return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
	}
	return c.Status(fiber.StatusOK).JSON(user)
}

2. Implementasi Factory dan Test Suite

Buat instance *fiber.App baru di dalam setiap eksekusi pengujian untuk mengisolasi state:

package user_test

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gofiber/fiber/v2"
	"path/to/user"
)

// MockUserService mengisolasi ketergantungan database eksternal
type MockUserService struct {
	GetUserFn func(ctx context.Context, id string) (*user.User, error)
}

func (m *MockUserService) GetUser(ctx context.Context, id string) (*user.User, error) {
	return m.GetUserFn(ctx, id)
}

// createTestApp mengembalikan instance Fiber baru yang terisolasi
func createTestApp(svc user.UserService) *fiber.App {
	app := fiber.New(fiber.Config{
		DisableStartupMessage: true,
	})
	h := user.NewHandler(svc)
	app.Get("/users/:id", h.GetUserHandler)
	return app
}

func TestGetUserHandler(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name           string
		userID         string
		mockFn         func(ctx context.Context, id string) (*user.User, error)
		expectedStatus int
		expectedBody   string
	}{
		{
			name:   "Berhasil mengambil data user",
			userID: "123",
			mockFn: func(ctx context.Context, id string) (*user.User, error) {
				return &user.User{ID: "123", Name: "Budi"}, nil
			},
			expectedStatus: http.StatusOK,
			expectedBody:   `{"id":"123","name":"Budi"}`,
		},
		{
			name:   "User tidak ditemukan",
			userID: "999",
			mockFn: func(ctx context.Context, id string) (*user.User, error) {
				return nil, errors.New("not found")
			},
			expectedStatus: http.StatusNotFound,
			expectedBody:   `{"error":"user not found"}`,
		},
	}

	for _, tt := range tests {
		tt := tt // Pin variabel perulangan untuk go < 1.22
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			mockService := &MockUserService{GetUserFn: tt.mockFn}
			app := createTestApp(mockService)

			req := httptest.NewRequest(http.MethodGet, "/users/"+tt.userID, nil)
			req.Header.Set("Content-Type", "application/json")

			resp, err := app.Test(req, 2000)
			if err != nil {
				t.Fatalf("Gagal mengeksekusi request: %v", err)
			}
			defer resp.Body.Close()

			if resp.StatusCode != tt.expectedStatus {
				t.Errorf("Status tidak sesuai. Diharapkan: %d, Didapat: %d", tt.expectedStatus, resp.StatusCode)
			}

			bodyBytes, err := io.ReadAll(resp.Body)
			if err != nil {
				t.Fatalf("Gagal membaca response body: %v", err)
			}

			// Validasi payload JSON secara terkompresi tanpa terpengaruh whitespace
			var actualMap, expectedMap map[string]interface{}
			_ = json.Unmarshal(bodyBytes, &actualMap)
			_ = json.Unmarshal([]byte(tt.expectedBody), &expectedMap)

			actualJSON, _ := json.Marshal(actualMap)
			expectedJSON, _ := json.Marshal(expectedMap)

			if string(actualJSON) != string(expectedJSON) {
				t.Errorf("Body payload tidak sesuai.\nDiharapkan: %s\nDidapat:    %s", expectedJSON, actualJSON)
			}
		})
	}
}

Aturan Mutlak Mengeliminasi Flaky Test

  1. Hindari Instance Global: Jangan pernah mendeklarasikan var app *fiber.App di level package pengujian. Buat selalu instance baru di dalam setiap blok t.Run().
  2. Tutup Body Response: Selalu gunakan defer resp.Body.Close() pada response yang dikembalikan oleh app.Test untuk mencegah kebocoran resource buffer.
  3. Validasi Data Race: Jalankan selalu suite pengujian dengan detektor race condition aktif menggunakan perintah:
    go test -race -v ./...
  4. Normalisasi JSON Assertion: Jangan membandingkan string JSON langsung (raw string comparison) karena urutan key JSON pada struct map tidak bersifat deterministik. Selalu lakukan unmarshal ke tipe data Go sebelum memeriksa nilainya.