I. Mô tả bài toán

Giả sử ta đã có 1 REST API và API này có các endpoint là GetGetEntries, GetEntryByID, CreateEntry, UpdateEntryDeleteEntry. Tương ứng với chúng là các đường dẫn như bên dưới:

GetEntries -> "/entries" -> Method GET

GetEntryByID -> "/entry?id=1234" -> Method GET

CreateEntry -> "/entry" -> Method POST

UpdateEntry -> "/entry" -> Method PUT

DeleteEntry -> "/entry" -> Method DELETE

Những endpoint này sẽ giúp ta tương tác với 1 struct có tên là entry

type entry struct {
	ID           int    `json:"id,omitempty"`
	FirstName    string `json:"first_name,omitempty"`
	LastName     string `json:"last_name,omitempty"`
	EmailAddress string `json:"email_address,omitempty"`
	PhoneNumber  string `json:"phone_number,omitempty"`
}

Vậy thì trong trường hợp sử dụng ngôn ngữ Golang, làm thế nào để biết Unit test cho API này ?

II. Giải quyết bài toán

Để giải quyết bài toán này ta sẽ sử dụng 2 package sau:

  • testing: là 1 built-in package được dùng để implement và run test. Ta sẽ sử dụng câu lệnh go test kết hợp với 1 số tham số tùy chọn để chạy test.
  • net/http/httptest: cũng là 1 built-in package khác. Dùng để test những thứ liên quan đến HTTP.

File test có thể cùng hoặc khác package với file cần test. Tiếp theo là quy tắc đặt tên hết sức quan trọng. Chỉ khi ta đặt đúng theo quy tắc thì thì package testing mới hiểu được đâu là file unit test mà nó cần phải test:

  • Tên file test phải kết thúc bằng _test. Ví dụ ta cần test file endpoints.go thì file test của chúng ta sẽ tên là endpoints_test.go
  • Tên của phương thức test phải bắt đầu bằng Test. Ví dụ ta cần test phương thức GetEntries thì phương thức test của chúng ta sẽ tên là TestGetEntries
package example

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

func TestGetEntries(t *testing.T) {
	
}
func TestGetEntryByID(t *testing.T) {
	
}
func TestGetEntryByIDNotFound(t *testing.T) {
	
}
func TestCreateEntry(t *testing.T) {
	
}
func TestEditEntry(t *testing.T) {
	
}
func TestDeleteEntry(t *testing.T) {
	
}

Phương thức test trong Golang sẽ nhận vào tham số và có thể sẽ sử dụng 1 số phương thức như bên dưới:

  • Tham số đầu tiên và duy nhất phải là t *testing.T
  • t.Log có thể được dùng nếu ta có nhu cầu in ra thông tin, sau đó tiếp tục chạy tiếp
  • t.Fail được dùng để đánh dấu là hàm test đã bị fail nhưng vẫn tiếp tục chạy tiếp.
  • t.Error là cách viết gọn, nó tương đương với việc gọi t.Log, sau đó gọi thêm t.Fail. Nếu hàm này được dùng khi ta muốn chạy tiếp để debug thêm thông tin. t.Errorf cũng tương tự như t.Error nhưng được hỗ trợ thêm fomart khi log thông tin.
  • t.Fatal được dùng khi log thông tin ra, đánh dấu là test đã bị fail và dừng luôn tại đó vì nếu có chạy tiếp thì không có gì đáng quan tâm cả.

1. Testcase cho GetEntries

func TestGetEntries(t *testing.T) {
	req, err := http.NewRequest("GET", "/entries", nil)
	if err != nil {
		t.Fatal(err)
	}
	rr := httptest.NewRecorder()
	handler := http.HandlerFunc(GetEntries)
	handler.ServeHTTP(rr, req)
	if status := rr.Code; status != http.StatusOK {
		t.Errorf("handler returned wrong status code: got %v want %v",
			status, http.StatusOK)
	}

	// Check the response body is what we expect.
	expected := `[{"id":1,"first_name":"Krish","last_name":"Bhanushali","email_address":"krishsb@g.com","phone_number":"0987654321"},{"id":2,"first_name":"xyz","last_name":"pqr","email_address":"xyz@pqr.com","phone_number":"1234567890"},{"id":6,"first_name":"FirstNameSample","last_name":"LastNameSample","email_address":"lr@gmail.com","phone_number":"1111111111"}]`
	if rr.Body.String() != expected {
		t.Errorf("handler returned unexpected body: got %v want %v",
			rr.Body.String(), expected)
	}
}
  • Khởi tạo 1 request mới sử dụng phương thức GET và đường dẫn là /entries
  • Kiểm tra xem việc đó lỗi xảy ra hay không? Nếu có thì gọi t.Fatal vì ngay cả việc new request còn bị lỗi thì những thông tin phía sau không có gì đáng quan tâm.
  • Gọi phương thức cần test. Lúc này sẽ có thể phát sinh lỗi do nhiều nguyên nhân khác nhau và ta quan tâm nguyên nhân đó là gì nên sử dụng Errorf.
  • So sánh status thực tế nhận được và status mong muốn. Nếu khác nhau thì gọi Errorf
  • So sánh response thực tế và response mong muốn. Nếu khác nhau thì gọi Errorf

2. Testcase cho GetEntryByID

func TestGetEntryByID(t *testing.T) {

	req, err := http.NewRequest("GET", "/entry", nil)
	if err != nil {
		t.Fatal(err)
	}
	q := req.URL.Query()
	q.Add("id", "1")
	req.URL.RawQuery = q.Encode()
	rr := httptest.NewRecorder()
	handler := http.HandlerFunc(GetEntryByID)
	handler.ServeHTTP(rr, req)
	if status := rr.Code; status != http.StatusOK {
		t.Errorf("handler returned wrong status code: got %v want %v",
			status, http.StatusOK)
	}

	// Check the response body is what we expect.
	expected := `{"id":1,"first_name":"Krish","last_name":"Bhanushali","email_address":"krishsb2405@gmail.com","phone_number":"0987654321"}`
	if rr.Body.String() != expected {
		t.Errorf("handler returned unexpected body: got %v want %v",
			rr.Body.String(), expected)
	}
}

2. Testcase cho GetEntryByIDNotFound

func TestGetEntryByIDNotFound(t *testing.T) {
	req, err := http.NewRequest("GET", "/entry", nil)
	if err != nil {
		t.Fatal(err)
	}
	q := req.URL.Query()
	q.Add("id", "123")
	req.URL.RawQuery = q.Encode()
	rr := httptest.NewRecorder()
	handler := http.HandlerFunc(GetEntryByID)
	handler.ServeHTTP(rr, req)
	if status := rr.Code; status == http.StatusOK {
		t.Errorf("handler returned wrong status code: got %v want %v",
			status, http.StatusBadRequest)
	}
}

3. Testcase cho CreateEntry

func TestCreateEntry(t *testing.T) {

	var jsonStr = []byte(`{"id":4,"first_name":"xyz","last_name":"pqr","email_address":"xyz@pqr.com","phone_number":"1234567890"}`)

	req, err := http.NewRequest("POST", "/entry", bytes.NewBuffer(jsonStr))
	if err != nil {
		t.Fatal(err)
	}
	req.Header.Set("Content-Type", "application/json")
	rr := httptest.NewRecorder()
	handler := http.HandlerFunc(CreateEntry)
	handler.ServeHTTP(rr, req)
	if status := rr.Code; status != http.StatusOK {
		t.Errorf("handler returned wrong status code: got %v want %v",
			status, http.StatusOK)
	}
	expected := `{"id":4,"first_name":"xyz","last_name":"pqr","email_address":"xyz@pqr.com","phone_number":"1234567890"}`
	if rr.Body.String() != expected {
		t.Errorf("handler returned unexpected body: got %v want %v",
			rr.Body.String(), expected)
	}
}
  • jsonStr là data mà ta cần post đi.
  • Khởi tạo 1 request mới để post đi dữ liệu là jsonStr. Nếu có lỗi gọi Fatal
  • Sau đó sau khi thực hiện request đó. Tương tự, từ giờ có nhiều nguyên nhân gây ra lỗi khác nhau và ta quan tâm là có thể gặp phải những lỗi gì nên sẽ dùng Errorf.
  • Nếu status khác với mong đợi. In ra lỗi bằng Errorf.
  • Nếu body khác với mong đợi. In ra lỗi bằng Errorf.

4. Testcase cho EditEntry

func TestEditEntry(t *testing.T) {

	var jsonStr = []byte(`{"id":4,"first_name":"xyz change","last_name":"pqr","email_address":"xyz@pqr.com","phone_number":"1234567890"}`)

	req, err := http.NewRequest("PUT", "/entry", bytes.NewBuffer(jsonStr))
	if err != nil {
		t.Fatal(err)
	}
	req.Header.Set("Content-Type", "application/json")
	rr := httptest.NewRecorder()
	handler := http.HandlerFunc(UpdateEntry)
	handler.ServeHTTP(rr, req)
	if status := rr.Code; status != http.StatusOK {
		t.Errorf("handler returned wrong status code: got %v want %v",
			status, http.StatusOK)
	}
	expected := `{"id":4,"first_name":"xyz change","last_name":"pqr","email_address":"xyz@pqr.com","phone_number":"1234567890"}`
	if rr.Body.String() != expected {
		t.Errorf("handler returned unexpected body: got %v want %v",
			rr.Body.String(), expected)
	}
}

Logic test tương tự như CreateEntry nhưng lần này phương thức là PUT

5. Testcase cho DeleteEntry

func TestDeleteEntry(t *testing.T) {
	req, err := http.NewRequest("DELETE", "/entry", nil)
	if err != nil {
		t.Fatal(err)
	}
	q := req.URL.Query()
	q.Add("id", "4")
	req.URL.RawQuery = q.Encode()
	rr := httptest.NewRecorder()
	handler := http.HandlerFunc(DeleteEntry)
	handler.ServeHTTP(rr, req)
	if status := rr.Code; status != http.StatusOK {
		t.Errorf("handler returned wrong status code: got %v want %v",
			status, http.StatusOK)
	}
	expected := `{"id":4,"first_name":"xyz change","last_name":"pqr","email_address":"xyz@pqr.com","phone_number":"1234567890"}`
	if rr.Body.String() != expected {
		t.Errorf("handler returned unexpected body: got %v want %v",
			rr.Body.String(), expected)
	}
}

Logic test tương tự như GetEntryByID nhưng lần này method là DELETE

6. Thực thi các testcase vừa viết

Ta thực hiện lệnh go test -v để chạy toàn bộ các testcase.
uc?id=16qtOu0R3Me1WjxJoxUB4BAFxZXtzhD8W&export=download
Hoặc sử dụng lệnh go test -v -run <Test Function Name> để chạy 1 testcase cụ thể
uc?id=1p6hLFggIEhPWB06e8dvaebVaa2CzET3o&export=download

III. Nguồn tham khảo

https://golang.org/pkg/testing/
https://golang.org/pkg/net/http/httptest/
https://blog.alexellis.io/golang-writing-unit-tests/
https://codeburst.io/unit-testing-for-rest-apis-in-go-86c70dada52d