Kembali ke Blog

Mengurangi Boilerplate Integration Test di Go dengan Table-Driven Test

Salah satu hal yang paling cepat membuat file pengujian menjadi berantakan adalah integration test.

Bukan karena logikanya rumit, tetapi karena pola yang sama terus diulang.

Setiap endpoint biasanya membutuhkan berbagai skenario:

  • Belum login
  • Data tidak valid
  • Resource tidak ditemukan
  • Akses ditolak
  • Berhasil diproses

Pada awalnya tidak terasa menjadi masalah. Namun ketika jumlah endpoint mulai bertambah, file pengujian akan dipenuhi blok kode yang hampir identik.

Perbedaannya sering kali hanya terletak pada input dan ekspektasi hasil.

Saya pernah berada pada kondisi tersebut ketika menulis integration test untuk salah satu endpoint backend berbasis Go. Setiap skenario membutuhkan proses yang sama:

  • Membuat HTTP request
  • Menyiapkan form-data
  • Menambahkan cookie autentikasi
  • Mengatur subdomain tenant
  • Memeriksa response
  • Memvalidasi perubahan data di database

Setelah beberapa endpoint, pola pengulangan mulai terlihat jelas.

Saat itulah saya mulai memanfaatkan pendekatan Table-Driven Test.


Apa yang Salah dengan Copy-Paste Test?

Secara teknis tidak ada yang salah.

Masalahnya muncul ketika jumlah skenario mulai bertambah.

Misalnya sebuah endpoint memiliki empat skenario pengujian:

  • Authentication error
  • Validation error
  • Resource not found
  • Success case

Jika setiap skenario dibuat sebagai fungsi test terpisah, maka proses pembentukan request dan validasi response akan terus diulang.

Akibatnya:

  • File test menjadi panjang
  • Sulit dibaca
  • Sulit dirawat
  • Mudah terjadi inkonsistensi assertion

Bahkan menambahkan satu validation case sederhana terasa seperti pekerjaan yang tidak perlu.


Table-Driven Test sebagai Pusat Data Pengujian

Ide dasar Table-Driven Test sebenarnya sederhana.

Alih-alih membuat banyak fungsi test, seluruh skenario disimpan dalam sebuah slice.

Logika eksekusi hanya ditulis satu kali.

Dengan pendekatan ini, data dan eksekusi dipisahkan secara jelas.

Data pengujian menjelaskan:

  • Apa yang diuji
  • Input yang digunakan
  • Hasil yang diharapkan

Sedangkan runner test bertanggung jawab menjalankan semuanya.


Studi Kasus

Berikut adalah salah satu integration test yang saya gunakan untuk endpoint pembuatan layanan.

Endpoint ini harus menangani beberapa kondisi:

  • User belum login
  • Halaman tidak ditemukan
  • Validasi request gagal
  • Data berhasil disimpan

Selain itu, pada skenario sukses saya juga ingin memastikan bahwa benar terjadi perubahan state pada database.

t.Run("POST /dashboard/layanans/create", func(t *testing.T) {
	testCases := []struct {
		name                 string
		authCookie           *http.Cookie
		body                 dto.CreateLayananRequest
		countLayananEntities func() (int64, error)

		subdomain        string
		expectStatusCode int
		expectRedirect   string
		expectContain    string
	}{
		{
			name:             "auth_error",
			subdomain:        tenant.Subdomain,
			expectStatusCode: 303,
			expectRedirect:   "/login",
		},
		{
			name:             "page_not_found_error",
			expectStatusCode: 404,
			expectContain:    "Halaman Terpeleset",
		},
		{
			name:             "validation_error",
			authCookie:       sessionCookie,
			subdomain:        tenant.Subdomain,
			body:             dto.CreateLayananRequest{},
			expectStatusCode: 400,
			expectContain:    "Nama harus diisi",
		},
		{
			name:       "success",
			authCookie: sessionCookie,
			subdomain:  tenant.Subdomain,
			body: dto.CreateLayananRequest{
				Nama:      faker.Name(),
				Satuan:    faker.Name()[:2],
				Harga:     faker.Price(0, 300000),
				Prioritas: uint16(faker.Number(10, 50)),
				DurasiJam: faker.Number(10, 50),
			},

			expectStatusCode: 200,
			expectRedirect:   "/dashboard/layanans",
			countLayananEntities: func() (int64, error) {
				var count int64
				err := db.Model(&model.Layanan{}).Count(&count).Error
				return count, err
			},
		},
	}

	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			var countLayananBeforeRequest int64
			var err error
			if testCase.countLayananEntities != nil {
				countLayananBeforeRequest, err = testCase.countLayananEntities()
				assert.Nil(t, err)
			}

			formData := url.Values{}
			formData.Set("nama", testCase.body.Nama)
			formData.Set("satuan", testCase.body.Satuan)
			formData.Set("harga", strconv.Itoa(int(testCase.body.Harga)))
			formData.Set("prioritas", strconv.Itoa(int(testCase.body.Prioritas)))
			formData.Set("durasi_jam", strconv.Itoa(testCase.body.DurasiJam))
			formDataEncoded := formData.Encode()

			req := httptest.NewRequest("POST", "/dashboard/layanans/create", strings.NewReader(formDataEncoded))
			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

			if testCase.authCookie != nil {
				req.AddCookie(testCase.authCookie)
			}

			if testCase.subdomain != "" {
				req.Host = testCase.subdomain + ".cleansaas.test"
			}

			resp, err := app.Test(req)
			assert.Nil(t, err)
			defer resp.Body.Close()

			assert.Equal(t, testCase.expectStatusCode, resp.StatusCode)

			if testCase.expectContain != "" {
				body, err := io.ReadAll(resp.Body)
				assert.Nil(t, err)
				assert.Contains(t, string(body), testCase.expectContain)
			}

			if testCase.expectRedirect != "" {
				redirectTo := resp.Header.Get("HX-Redirect")
				if redirectTo == "" {
					redirectTo = resp.Header.Get("Location")
				}

				assert.Equal(t, testCase.expectRedirect, redirectTo)
			}

			var countLayananAfterRequest int64
			if testCase.countLayananEntities != nil {
				countLayananAfterRequest, err = testCase.countLayananEntities()
				assert.Nil(t, err)
				assert.Greater(t, countLayananAfterRequest, countLayananBeforeRequest)
			}
		})
	}
})

Yang Menarik dari Pendekatan Ini

Sekilas kode di atas terlihat cukup panjang.

Namun sebenarnya seluruh pengujian hanya memiliki satu alur eksekusi.

Perbedaan antar skenario hanya berada pada isi testCases.

Menambahkan kasus baru menjadi sangat mudah.

Misalnya saya ingin menambahkan validasi harga minimum.

Saya cukup menambahkan satu objek baru ke dalam slice tanpa menyentuh runner pengujian.

Pendekatan ini membuat pertumbuhan jumlah test case tidak berbanding lurus dengan pertumbuhan kompleksitas kode.


Menguji Lebih dari Sekadar Response

Salah satu bagian yang saya sukai dari pola ini adalah kemampuan untuk menguji efek samping aplikasi.

Banyak integration test berhenti pada:

assert.Equal(t, 200, resp.StatusCode)

Padahal status code 200 tidak selalu berarti operasi benar-benar berhasil.

Pada contoh di atas, skenario sukses menyimpan fungsi:

countLayananEntities func() (int64, error)

yang digunakan untuk menghitung jumlah data sebelum dan sesudah request dijalankan.

Dengan cara ini saya dapat memastikan bahwa:

  • Request berhasil
  • Response sesuai ekspektasi
  • Database benar-benar berubah

Pengujian menjadi lebih dekat dengan perilaku nyata aplikasi.


Bonus: Pengujian Multi-Tenant Menjadi Lebih Mudah

Karena aplikasi saya menggunakan pendekatan subdomain tenant, setiap request perlu mengetahui tenant mana yang sedang aktif.

Daripada membuat setup berbeda untuk setiap test, informasi tersebut cukup disimpan sebagai data:

subdomain string

Runner kemudian bertugas mengonfigurasi host request berdasarkan nilai tersebut.

Pola ini membuat pengujian multi-tenant tetap sederhana meskipun jumlah skenario terus bertambah.


Kapan Table-Driven Test Tidak Cocok?

Meskipun sangat berguna, saya tidak menggunakannya untuk semua jenis pengujian.

Jika setiap skenario memiliki flow yang benar-benar berbeda, memaksakan Table-Driven Test justru dapat membuat kode lebih sulit dipahami.

Biasanya saya menggunakan pendekatan ini ketika:

  • Endpoint memiliki banyak variasi input
  • Struktur assertion relatif sama
  • Perbedaan hanya berada pada data pengujian

Untuk workflow yang kompleks dan bercabang jauh, test terpisah sering kali lebih jelas.


Kesimpulan

Table-Driven Test bukan sekadar pola populer di komunitas Go.

Pendekatan ini membantu menjaga integration test tetap sederhana ketika jumlah skenario mulai bertambah.

Dengan memusatkan seluruh data pengujian ke dalam satu struktur, kita memperoleh beberapa keuntungan sekaligus:

  • Mengurangi boilerplate code
  • Mempermudah penambahan test case
  • Menjaga konsistensi assertion
  • Meningkatkan keterbacaan
  • Mempermudah pemeliharaan jangka panjang

Pada proyek kecil manfaatnya mungkin belum terasa.

Namun ketika jumlah endpoint mulai bertambah dan kebutuhan validasi semakin banyak, pola ini dapat menghemat waktu pemeliharaan yang tidak sedikit.