blog.1.image
Programming
June 17, 2022

Golang Mock HTTP Server : Solusi Unit Test 100% Coverage

Unit testing sudah menjadi bagian yang sangat umum untuk membantu memastikan program kita sudah berjalan dengan baik. Terkadang membuat test scenario itu bisa memakan waktu lebih lama daripada membuat fungsi utamanya itu sendiri, akan tetapi unit testing yang baik akan membantu mencegah masalah lain di kemudian hari apalagi jika dibantu oleh software CI/CD yang baik juga.

Selama pembuatan unit-testing, seringkali kita kesulitan membuat skenario yang dibutuhkan oleh fungsi-fungsi yang sudah kita buat. Salah satu penyebab yang paling umum adalah karena di fungsi yang ingin kita test melakukan pemanggilan / HTTP request ke endpoint lain, sehingga kita merasa kesulitan untuk membuat skenario karena bergantung pada response dari endpoint lain tersebut. Untungnya golang menyediakan package net/http/httptest yang bisa kita gunakan untuk membuat mock server dan melakukan testing dengan seluruh skenario yang dibutuhkan.

Sebagai contoh, saya akan membuat sebuah project API sederhana dengan framework Fiber. Nama projectnya adalah "latihan-httptest", dan struktur direktorinya seperti berikut ini : 

  • main.go -> File utama
  • routes/routes.go -> Router 
  • services/http_services.go -> Service yang akan dibuatkan unit-testing

 

main.go

package main

import (
	"latihan-httptest/routes"
	"log"

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

func main() {
	app := fiber.New()
	routes.Handle(app)
	log.Fatal(app.Listen(":3000"))
}

 

services/http_service.go

package services

import (
	"bytes"
	"crypto/tls"
	"fmt"
	"log"
	"net/http"
	"strings"
	"time"
)

func CallAnotherURL(hitURLExample string) (httpBody string, httpStatus int) {
	req, _ := http.NewRequest("GET", hitURLExample, strings.NewReader(""))
	req.Close = true

	// Create HTTP Connection
	client := &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{
				InsecureSkipVerify: true,
			},
		},
		Timeout: time.Duration(5) * time.Second,
	}

	// Now hit to destionation endpoint
	res, err := client.Do(req)
	if err != nil {
		log.Printf("Call URL Failed : %s", err.Error())
		httpBody = "Call URL Failed : " + err.Error()
		return
	}
	defer res.Body.Close()

	// return body string & OK http code
	buff := new(bytes.Buffer)
	buff.ReadFrom(res.Body)
	httpBody = buff.String()
	httpStatus = res.StatusCode

	if httpStatus < 200 || httpStatus >= 400 {
		httpBody = fmt.Sprintf("Call URL Failed expected 200 but get %d ", httpStatus)
	}
	return

}

 

routes/routes.go

package routes

import (
	"latihan-httptest/services"

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

// Handle all request to route to controller
func Handle(app *fiber.App) {
	app.Use(cors.New())

	// Dummy Root Endpoint
	app.Get("/", func(c *fiber.Ctx) error {
		return c.Status(200).JSON(map[string]string{
			"version": "1.0.0",
			"name":    "Latihan HTTP Test",
		})
	})

	// Target test route endpoint
	app.Get("/service", func(c *fiber.Ctx) error {
		// karena ini cuma latihan test sederhana jadi dihardcode.
		// normalnya base url targetEndpoint ini harus diambil dari env
		targetEndpoint := "https://httpbin.org/get"

		svcBody, svcHttpCode := services.CallAnotherURL(targetEndpoint)
		// HTTP Code > 200 and < 400 is a success response
		if svcHttpCode >= 200 && svcHttpCode < 400 {
			return c.Status(200).JSON(fiber.Map{
				"type":            "success",
				"serviceBody":     svcBody,
				"serviceHttpCode": svcHttpCode,
			})
		}

		// other than that : return Server Error
		return c.Status(500).JSON(fiber.Map{
			"type":            "error",
			"serviceBody":     svcBody,
			"serviceHttpCode": svcHttpCode,
		})
	})

}

 

Sangat sederhana, API ini jika dijalankan dengan memanggil http://localhost:3000/service akan memberi respon seperti ini yang menunjukkan proses sudah berhasil: 

 

Setelah memastikan endpoint berjalan, Ssekarang kita dapat membuat sebuah unit testing di services/http_service_test.go. Pertama-tama kita akan membuat skenario pemanggilan service biasa seperti ini : 

services/http_service_test.go

package services

import (
	"testing"
)

func TestCallAnotherURL(t *testing.T) {
	_, httpCode := CallAnotherURL("https://httpbin.org/get")
	if httpCode != 200 {
		t.Errorf("Error CallAnotherURL HTTP Code must be 200! (Response : %d)", httpCode)
	}
}

 

Sangat mudah bukan? Saat TestCallAnotherURL dipanggil, unit-testing akan melakukan hit ke url tersebut dan membandingkan response HTTP codenya. Apabila test ini dijalankan dengan command go test ./... , hasilnya adalah sukses seperti ini .

$ go test ./...
?       latihan-httptest        [no test files]
?       latihan-httptest/routes [no test files]
ok      latihan-httptest/services       0.079s

Akan tetapi, jika kita menambahkan parameter -cover untuk melihat coverage testnya, kita akan melihat hasil seperti ini : 

$ go test ./... -cover
?       latihan-httptest        [no test files]
?       latihan-httptest/routes [no test files]
ok      latihan-httptest/services       0.068s  coverage: 75% of statements

 

Perhatikan ada nilai coverage 75% pada direktori service dimana kita membuat unit test tadi. 75% itu berarti unit test yang dijalankan di folder tersebut hanya mengcover 75% dari keseluruhan code yang kita buat. Dengan kata lain, ada 25% bagian code yang belum teruji/tercover. Untuk mengetahui lebih detail, kita bisa menjalankan test ulang lalu menampilkan coverage profile dengan command berikut ini :

go test -v -coverprofile cover.out ./...
go tool cover -html=cover.out -o cover.html

Command tersebut akan membuat file cover.html yang berisi profil coverage testing di project kita, dan apabila dibuka kita akan melihat baris kode berwarna merah yang menandakan baris kode yang belum tercover/teruji.

Skenario yang sudah dibuat adalah skenario hit HTTP normal yang berhasil, karena itulah ada 2 bagian yang berwarna merah tidak tercover karena program tidak akan melewati baris kode tersebut. blok if err != nil tidak akan dieksekusi karena fungsi tersebut dipanggil tanpa terjadi error, dan blok if httpStatus < 200 || httpStatus >= 400 juga tidak terpanggil karena response server sukses, alias HTTP 200.

Karena kita tidak bisa mengatur response server, disinilah saatnya kita membuat mock server di unit testing untuk membuat test skenario tersebut. Berdasarkan line berwarna merah tadi, setidaknya kita tahu bahwa kita harus membuat 2 skenario baru lagi yaitu : Skenario error saat hit client, dan skenario HTTP error response (0 atau > 400). Untuk melakukan hal tersebut, kita akan menggunakan package "net/http/httptest" di unit testing yang tadi seperti dibawah ini : 

services/http_service_test.go

package services

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

// -- TEST YANG AWAL TIDAK USAH DIPAKAI
// func TestCallAnotherURL(t *testing.T) {
// 	_, httpCode := CallAnotherURL("https://httpbin.org/get")
// 	if httpCode != 200 {
// 		t.Errorf("Error CallAnotherURL HTTP Code must be 200! (Response : %d)", httpCode)
// 	}
// }

func TestCallAnotherURL(t *testing.T) {
	// CASE 1 : Normal succes server response
	serverCase1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{
			"type" : "success",
			"description" : "We can fill anything that represent the current testing flow needed"
		}`))
	}))
	defer serverCase1.Close()

	_, httpCode := CallAnotherURL(serverCase1.URL)
	if httpCode != 200 {
		t.Errorf("Normal HTTP Code must be 200! (Response : %d)", httpCode)
	}

	// CASE 2 : Normal success but not-expected server response
	serverCase2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(`{
			"type" : "error",
			"description" : "This is just a dummy 400 HTTP Response"
		}`))
	}))
	defer serverCase1.Close()

	_, httpCode = CallAnotherURL(serverCase2.URL)
	if httpCode >= 200 && httpCode < 400 {
		t.Errorf("Error HTTP Code must be non-200! (Response : %d)", httpCode)
	}

	// CASE 3 : Not working URL that expect HTTP 0 response
	_, httpCode = CallAnotherURL("NOT-WORKING-URL")
	if httpCode != 0 {
		t.Errorf("Error wrong URL HTTP Code must be 0! (Response : %d)", httpCode)
	}
}

Jika kita perhatikan line code di atas, kita dapat membuat mock server dengan memanggil httptest.NewServer, dimana kita bisa mengatur sendiri di dalamnya akan menampilkan response seperti apa. Dalam contoh kompleksnya bahkan kita bisa membuat router sendiri di mock server tersebut, tetapi sebagai contoh sederhana kita langsung menampilkan response yang dibutuhkan skenario saja. 

Setelah mock server disimpan ke variabel serverCase1, kita dapat membypass URL mock server tersebut melalui struct serverCase1.URL. Dalam contoh production, mungkin nilai URL ini akan dibypass ke .env terlebih dahulu agar service memanggil endpoint dummy ini dan bukan memanggil endpoint normal. Jangan lupa melakukan defer untuk menutup mock server tersebut untuk mencegah memory leak karena mock server yang lupa diclose. 

Sekarang, apabila unit testing dijalankan kembali dengan tambahan command cover yang sama go test ./... -cover akan menampilkan hasil seperti ini : 

$ go test ./... -cover
?       latihan-httptest        [no test files]
?       latihan-httptest/routes [no test files]
ok      latihan-httptest/services       0.068s  coverage: 100% of statements

Jika nilai coverage sudah 100%, artinya unit-testing yang kita buat sudah baik dan lebih bisa diandalkan karena sudah mengcover seluruh test-case yang mungkin terjadi. Source code lengkap tutorial ini dapat didownload di link ini.

 

Sekian sharing singkat unit testing golang yang bisa saya bagikan, jangan pernah lelah untuk membuat test-scenario yang lengkap. Selelah-lelahnya membuat test scenario masih lebih baik daripada melakukan tracing error karena lalai membuat unit-test yang 100% coverage. 

Share this article:

New Updates are available

Oops, your internet is disconnected. Please check your signal
Icon