Perubahan internal struct pada Go sering memicu breaking changes tanpa disadari. Mengubah tag JSON, menghapus field, atau mengganti tipe data primitif dapat merusak konsumsi data oleh client frontend maupun microservice lain jika spesifikasi OpenAPI publik tidak diperbarui secara bersamaan.

Artikel ini membahas teknik validasi kontrak API pada framework Go Fiber menggunakan pustaka kin-openapi. Pengujian dijalankan langsung melalui app.Test tanpa membuka port TCP, memungkinkan verifikasi skema OpenAPI v3 berlangsung cepat dalam pipeline Continuous Integration (CI).

Akar Masalah: Schema Drift antara Struct Go dan OpenAPI

Schema drift terjadi saat implementasi kode backend menyimpang dari dokumentasi kontrak yang disepakati. Pada ekosistem Go, akar masalah umumnya bersumber dari:

  • Modifikasi Struct Tag: Pengembang mengubah json:"user_id" menjadi json:"id" tanpa memperbarui dokumen OpenAPI.
  • Ketidaksesuaian Nullability: Penggunaan pointer (misal *string) pada Go yang menghasilkan null, sementara skema OpenAPI mendefinisikannya sebagai tipe non-nullable.
  • Omit Empty Side-Effects: Tag omitempty menghilangkan field saat bernilai zero value, memicu kegagalan parsing pada client jika OpenAPI menandai field tersebut sebagai required.
  • Generasi Dokumentasi Manual: OpenAPI dirawat terpisah tanpa mekanisme validasi otomatis di tingkat unit/integration test.

Solusi deterministik untuk masalah ini adalah contract testing: menjadikan dokumen OpenAPI sebagai sumber kebenaran (source of truth) dan memverifikasi response aktual dari Fiber handler secara langsung terhadap dokumen tersebut.

Arsitektur Pengujian: Fiber app.Test dan kin-openapi

Alih-alih menjalankan server HTTP nyata dengan app.Listen() yang memerlukan alokasi port dan overhead jaringan, Fiber menyediakan metode app.Test(). Metode ini memanfaatkan implementasi in-memory berbasis fasthttp, menerima objek *http.Request standar, dan mengembalikan *http.Response.

Untuk memvalidasi response tersebut terhadap dokumen OpenAPI v3, kita mengintegrasikan pustaka github.com/getkin/kin-openapi. Alur verifikasinya adalah:

  1. Memuat dokumen OpenAPI (YAML/JSON) menggunakan parser kin-openapi.
  2. Membuat router OpenAPI in-memory untuk memetakan rute pengujian ke operasi skema yang sesuai.
  3. Mengeksekusi handler via app.Test().
  4. Mengonversi response Fiber ke input validasi openapi3filter untuk memeriksa status code, header, dan payload JSON.

Implementasi Kode Pengujian Kontrak

Berikut adalah implementasi pengujian kontrak. Pertama, definisikan spesifikasi kontrak minimal dalam format OpenAPI v3.

# openapi.yaml
openapi: 3.0.3
info:
  title: User Service API
  version: 1.0.0
paths:
  /api/v1/users/{id}:
    get:
      summary: Get user by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: User detail found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '404':
          description: User not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
components:
  schemas:
    UserResponse:
      type: object
      required:
        - id
        - email
      properties:
        id:
          type: string
        email:
          type: string
          format: email
        bio:
          type: string
          nullable: true
    ErrorResponse:
      type: object
      required:
        - code
        - message
      properties:
        code:
          type: integer
        message:
          type: string

Selanjutnya, buat file pengujian Go yang memetakan rute Fiber dan mengeksekusi validasi kontrak.

package test

import (
	"bytes"
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/getkin/kin-openapi/openapi3"
	"github.com/getkin/kin-openapi/openapi3filter"
	"github.com/getkin/kin-openapi/routers/gorillamux"
	"github.com/gofiber/fiber/v2"
	"github.com/stretchr/testify/require"
)

type UserPayload struct {
	ID    string  `json:"id"`
	Email string  `json:"email"`
	Bio   *string `json:"bio"`
}

type ErrorPayload struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

func setupFiberApp() *fiber.App {
	app := fiber.New()

	app.Get("/api/v1/users/:id", func(c *fiber.Ctx) error {
		id := c.Params("id")
		if id == "unknown" {
			return c.Status(fiber.StatusNotFound).JSON(ErrorPayload{
				Code:    404,
				Message: "User not found",
			})
		}

		bio := "Software Engineer"
		return c.Status(fiber.StatusOK).JSON(UserPayload{
			ID:    id,
			Email: "[email protected]",
			Bio:   &bio,
		})
	})

	return app
}

func validateResponseContract(
	t *testing.T,
	ctx context.Context,
	doc *openapi3.T,
	httpReq *http.Request,
	resp *http.Response,
) {
	t.Helper()

	router, err := gorillamux.NewRouter(doc)
	require.NoError(t, err, "Gagal inisialisasi router OpenAPI")

	route, pathParams, err := router.FindRoute(httpReq)
	require.NoError(t, err, "Rute tidak terdefinisi di OpenAPI")

	respBody, err := io.ReadAll(resp.Body)
	require.NoError(t, err)

	requestValidationInput := &openapi3filter.RequestValidationInput{
		Request:    httpReq,
		PathParams: pathParams,
		Route:      route,
	}

	responseValidationInput := &openapi3filter.ResponseValidationInput{
		RequestValidationInput: requestValidationInput,
		Status:                 resp.StatusCode,
		Header:                 resp.Header,
		Body:                   io.NopCloser(bytes.NewReader(respBody)),
	}

	err = openapi3filter.ValidateResponse(ctx, responseValidationInput)
	require.NoError(t, err, "Payload response melanggar kontrak OpenAPI")
}

func TestUsers_Contract(t *testing.T) {
	ctx := context.Background()
	loader := openapi3.NewLoader()
	doc, err := loader.LoadFromFile("openapi.yaml")
	require.NoError(t, err)
	require.NoError(t, doc.Validate(ctx))

	app := setupFiberApp()

	t.Run("Success 200 - Valid Contract", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/api/v1/users/usr-123", nil)
		resp, err := app.Test(req, -1)
		require.NoError(t, err)
		defer resp.Body.Close()

		require.Equal(t, fiber.StatusOK, resp.StatusCode)
		validateResponseContract(t, ctx, doc, req, resp)
	})

	t.Run("Error 404 - Valid Contract", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/api/v1/users/unknown", nil)
		resp, err := app.Test(req, -1)
		require.NoError(t, err)
		defer resp.Body.Close()

		require.Equal(t, fiber.StatusNotFound, resp.StatusCode)
		validateResponseContract(t, ctx, doc, req, resp)
	})
}

Menangani Field Opsional dan Error Response

Terdapat dua skenario kritis yang sering memunculkan false-positive atau false-negative saat validasi kontrak:

1. Field Opsional dan Nilai Null

Pada spesifikasi OpenAPI v3, sebuah properti dapat bersifat opsional (tidak tercantum di array required) atau bersifat nullable. Jika struct Go Anda mengembalikan null untuk field yang tidak memiliki deklarasi nullable: true di dokumen OpenAPI, openapi3filter akan melempar error:

// Error jika skema tidak mendefinisikan 'nullable: true'
response body doesn't match schema: value is not nullable

Pastikan penanganan pointer pada Go diselaraskan dengan atribut skema:

properties:
  bio:
    type: string
    nullable: true # Mengizinkan string atau null

2. Status Code Error

Banyak pengujian hanya berfokus pada status 200 OK. Uji kontrak wajib mencakup skenario error (400, 404, 500). Kontrak harus menjamin format struktur error konsisten, sehingga client dapat melakukan error handling secara deterministik.

Integrasi CI Pipeline: Menolak Breaking Changes

Agar validasi ini efektif, uji kontrak harus dijalankan pada setiap pull request sebelum proses penggabungan kode (merge). Jika ada developer yang memodifikasi tipe data struct Go tanpa memperbarui OpenAPI, atau memperbarui OpenAPI dengan breaking change yang tidak sesuai handler, pipeline CI akan gagal secara otomatis.

Contoh konfigurasi GitHub Actions:

name: Contract Testing

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  test-contract:
    name: Verify API Contracts
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'
          cache: true

      - name: Install Dependencies
        run: go mod download

      - name: Run Contract Tests
        run: go test -v -race ./test/...

Kesimpulan dan Trade-Off

Mengintegrasikan kin-openapi dengan app.Test Fiber memberikan jaminan kepatuhan payload tanpa dependensi infrastruktur eksternal. Pendekatan ini menghilangkan kebutuhan untuk menjalankan mock server berbasis container atau tools eksternal seperti Prism di tahap testing awal.

Trade-off: Validasi skema runtime menambahkan waktu eksekusi pengujian mikrodetik lebih tinggi dibanding unit test standar. Namun, biaya komputasi ini sangat kecil dibandingkan risiko production outage akibat inkonsistensi payload antara backend dan client service.