- Published on
API Service with Go: API Testing
- Authors
- Name
- Somprasong Damyos
- @somprasongd
API Testing
จากบทความเรื่องออกแบบโครงสร้างโปรเจค ในแต่ละโมดูลนั้นได้นำเอาหลักการของ Hexagonal Architecture มาใช้ ซึ่งมีการแยกส่วนการทำงานที่เป็นอิสระต่อกัน ทำใช้เราสามารถเขียนทดสอบโปรแกรมของเราได้ง่ายขึ้นด้วย
แล้วจะต้องทดสอบอะไรบ้าง
การเขียนทดสอบในภาษา Go ดูได้จากที่นี่
Unit Test
- Service เป็นส่วนของ business logic ของเรา ซึ่งการทำงานจริงของ service นั้นจะมีการต่อฐานข้อมูลจริงผ่าน Repository แต่ในการทำ unit test นั้นเราจะทำการ mock repository ขึ้นมาแทน
- Handler เป็นส่วนในการจัดการ request กับ response โดยจะทำการทดสอบว่าส่ง response ออกมาถูกต้องหรือไม่ ซึ่งจะสร้าง mock service ส่งเข้าไปแทน
Integration Test
- Service เราสามารถเขียนเหมือน unit test ได้เลย เพียงแค่เปลี่ยนจาก mock repository เป็น database repository ของจริง
- Handler เราสามารถเขียนเหมือน unit test ได้เลย เพียงแค่เปลี่ยนจาก mock service เป็น servce ของจริงแทน
แล้วจะ Mock ยังไง
การสร้าง mock คือ การกำหนดข้อมูลที่ return กลับออกมาให้ตรงตามที่เราต้องการ ซึ่งเราจะใช้ mock package ของ testify มาช่วยในการเขียน
วิธีการใช้งาน testify
- สร้าง mock struct
- ติดตั้ง testify
go get github.com/stretchr/testify
- สร้าง struct ที่มี field
mock.Mock
type MyMockedObject struct {
mock.Mock
}
- ส่วน function ที่จะ mock ให้สร้าง receiver function แบบ pointer เพื่อจะได้
mock.Mock
ตัวเดียวกัน
type MyMockedObject struct {
mock.Mock
}
func (m *MyMockedObject) DoSomething(number int) (string, int, bool, error) {
args := m.Called(number)
return args.String(0), args.Int(1), args.Bool(2), args.Error(3)
}
- ใช้
m.Called()
เพื่อเรียกใช้ parameter ที่ส่งเข้ามา จะได้args
ซึ่งเป็น slice ของค่าที่ต้อง return กลับไป ได้มาจากการตอนเรียกใช้งาน
- เรียกใช้งาน โดยใช้ฟังก์ชัน
On
ในการกำหนดว่าฟังก์ชันที่จะใช้งานนั้นชื่ออะไร และส่ง parameter อะไรไปบ้าง และฟังก์ชันReturn
ในการกำหนดสิ่งที่ต้องการได้กลับออกมา
- ตัวอย่างกรณีทำงานถูกต้อง
func TestSomething(t *testing.T) {
// create an instance of our test object
testObj := new(MyMockedObject)
// setup expectations
testObj.On("DoSomething", 123).Return("abc", 456, true, nil)
// call the code we are testing
targetFuncThatDoesSomethingWithObj(testObj)
// assert that the expectations were met
testObj.AssertExpectations(t)
}
- ตัวอย่างกรณีทำงานผิดพลาด
func TestSomethingError(t *testing.T) {
// create an instance of our test object
testObj := new(MyMockedObject)
// setup expectations
testObj.On("DoSomething", 2).Return("", 0, false, errors.New("something error"))
// call the code we are testing
targetFuncThatDoesSomethingWithObj(testObj)
// assert that the expectations were met
testObj.AssertExpectations(t)
}
Unit Test Service
เราจะใช้โปรเจคจาก goapi-project-structure มาใช้ในการเขียนทดสอบ โดยการทดสอบ business logic ใน service นั้น ให้เริ่มจากการทำ mock repository ก่อน
สร้าง Mock Repository
สำหรับการ mock จะเขียนรวมไว้ที่ pkg/module/todo/mocks
package mocks
import (
"goapi-testing/pkg/common"
"goapi-testing/pkg/module/todo/core/dto"
"goapi-testing/pkg/module/todo/core/model"
"goapi-testing/pkg/module/todo/core/ports"
"github.com/stretchr/testify/mock"
)
type todoRepositoryMock struct {
mock.Mock
}
// สำหรับตรวจสอบว่า todoRepositoryMock conform ports.TodoRepository หรือไม่
var _ ports.TodoRepository = &todoRepositoryMock{}
func NewTodoRepositoryMock() *todoRepositoryMock {
return &todoRepositoryMock{}
}
func (m *todoRepositoryMock) Create(t *model.Todo) error {
args := m.Called(t)
return args.Error(0)
}
func (m *todoRepositoryMock) Find(page common.PagingRequest, filters dto.ListTodoFilter) (model.Todos, *common.PagingResult, error) {
args := m.Called(page, filters)
// กรณีส่งค่า nil มา ต้องตรวจสอบก่อน
var r0 model.Todos
if args.Get(0) != nil {
r0 = args.Get(0).(model.Todos)
}
var r1 *common.PagingResult
if args.Get(1) != nil {
r1 = args.Get(1).(*common.PagingResult)
}
return r0, r1, args.Error(2)
}
func (m *todoRepositoryMock) FindById(id string) (*model.Todo, error) {
args := m.Called(id)
var r0 *model.Todo
if args.Get(0) != nil {
r0 = args.Get(0).(*model.Todo)
}
return r0, args.Error(1)
}
func (m *todoRepositoryMock) UpdateStatusById(id string, status bool) (*model.Todo, error) {
args := m.Called(id, status)
var r0 *model.Todo
if args.Get(0) != nil {
r0 = args.Get(0).(*model.Todo)
}
return r0, args.Error(1)
}
func (m *todoRepositoryMock) DeleteById(id string) error {
args := m.Called(id)
return args.Error(0)
}
ทดสอบ todoService
การทดสอบจะใช้วิธีทดสอบแบบ black box คือ จะใช้ชื่อ package ตามด้วย _test
- ทดสอบการบันทึกรายการใหม่สำเร็จ ขั้นตอนคือ
- Arrage ให้เตรียมข้อมูลที่ต้องจะใช้ ข้อมูลที่คาดหวังจะได้ และ mock ให้ repo.Create()
return nil
- Act ให้เรียก service.Create() โดยส่งค่าตามที่เตรียมไว้เข้าไป
- Assert ต้องตรวจสอบ 2 อย่าง คือ ต้องไม่มี error และข้อมูลได้ตรงตามที่คาดหวังไว้หรือไม่
- Arrage ให้เตรียมข้อมูลที่ต้องจะใช้ ข้อมูลที่คาดหวังจะได้ และ mock ให้ repo.Create()
package service_test
import (
// ...
)
func TestTodo(t *testing.T) {
t.Run("Add Todo Service", func(t *testing.T) {
// ทดสอบกรณีสร้าง todo สำเร็จ
t.Run("Success", func(t *testing.T) {
// Arrage
// เตรียมข้อมูลที่ต้องจะใช้
mockForm := dto.NewTodoForm{
Text: "Test new todo",
}
mockModel := mapper.CreateTodoFormToModel(mockForm)
// กำหนดค่าที่คาดหวังให้ได้ออกมา
want := mapper.TodoToDto(mockModel)
repo := mocks.NewTodoRepositoryMock()
// กรณีบันทึกสำเร็จ จะส่ง error เป็น nil กลับมา
repo.On("Create", mockModel).Return(nil)
// ส่ง mock repo ไปให้ todoService
svc := service.NewTodoService(repo)
// Act เรียกใช้งาน service
got, err := svc.Create(mockForm, "")
// Assert
// ตรวจสอบว่าต้องไม่มี error
assert.NoError(t, err)
// ตรวจสอบว่าข้อมูลที่ได้จาก service ได้ตามที่คาดหวังไว้มั้ย
assert.Equal(t, want, got)
})
}
- ทดสอบกรณีตรวจสอบ json body ไม่ผ่าน ขั้นตอนคือ
- Arrage ให้เตรียมข้อมูลที่ต้องจะใช้ และไม่ต้อง mock repo.Create() เพราะไม่ได้เรียกใช้งาน
- Act ให้เรียก service.Create() โดยส่งค่าว่างเข้าไป
- Assert ต้องตรวจสอบ 2 อย่าง คือ ได้ error ตรงตามที่คาดหวังไว้หรือไม่ และ repo.Create() ต้องไม่ถูกเรียกใช้
package service_test
import (
// ...
)
func TestTodo(t *testing.T) {
t.Run("Add Todo Service", func(t *testing.T) {
// ทดสอบกรณีตรวจสอบ json body ไม่ผ่าน
t.Run("Invalid JSON Boby", func(t *testing.T) {
// Arrage
// service มีการตรวจสอบว่า text ต้องไม่เป็นค่าว่าง ให้ส่งค่าว่างเข้าไป
mockForm := dto.NewTodoForm{
Text: "",
}
// ไม่มีการเรียกใช้ repo ก็ไม่ต้อง mock ฟังก์ชัน
repo := mocks.NewTodoRepositoryMock()
svc := service.NewTodoService(repo)
// Act
_, err := svc.Create(mockForm, "")
// Assert
// ตรวจสอบว่าได้ error ถูกต้องหรือไม่
assert.ErrorIs(t, err, common.NewInvalidError("text: text is a required field"))
// ถ้าตรวจสอบไม่ผ่าน จะต้องไม่มีการเรียก repo.Create
repo.AssertNotCalled(t, "Create")
})
}
- ทดสอบกรณีบันทึกรายการผิดพลาด ขั้นตอนคือ
- Arrage ให้เตรียมข้อมูลที่ต้องจะใช้ และ mock repo.Create() ให้ return error ออกมา
- Act ให้เรียก service.Create() โดยส่งค่าว่างเข้าไป
- Assert ต้องตรวจสอบว่าได้ error ตรงตามที่คาดหวังไว้หรือไม่
package service_test
import (
// ...
)
func TestTodo(t *testing.T) {
t.Run("Add Todo Service", func(t *testing.T) {
t.Run("Error", func(t *testing.T) {
// Arrage
mockForm := dto.NewTodoForm{
Text: "Test new todo",
}
repo := mocks.NewTodoRepositoryMock()
repo.On("Create", mock.AnythingOfType("*model.Todo")).Return(errors.New("Some error down call chain"))
svc := service.NewTodoService(repo)
// Act
_, err := svc.Create(mockForm, "")
assert.ErrorIs(t, err, common.ErrDbInsert)
})
}
Unit Test TodoHandler
การทดสอบจะใช้วิธีทดสอบแบบ black box เหมือนกัน ซึ่งส่วนการทดสอบ handler นั้น จะต้องมีการสร้าง routes ขึ้นมา และสร้าง request โดยใช้ http.NewRequest()
และส่ง request นั้นไปทดสอบ ซึ่งถ้าใช้ fiber จะใช้ app.Test()
ในการทดสอบ
t.Run("test_handler_with_fiber", func(t *testing.T) {
// Arrange
// ...
app := fiber.New()
// ใส่ middleware ที่ต้องใช้
app.Use(requestid.New()) // reponse ต้องใช้ request id
cfg := todo.RouteConfig{
Router: router,
TodoService: svc,
}
// สร้าง todo routes
todo.SetupRoutes(cfg)
// สร้าง reqesut body
reqBody, _ := json.Marshal(map[string]string{
"text": "New todo",
})
// สร้าง request
req, _ := http.NewRequest("POST", "/todos", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-ID", mock.Anything)
// Act ทดสอบ handler
resp, err := app.Test(req)
// Assert
}
- ทดสอบการบันทึกรายการใหม่สำเร็จ ขั้นตอนคือ
- Arrage ให้เตรียมข้อมูลที่ต้องจะใช้ ข้อมูลที่คาดหวังจะได้ และ mock ให้ service.Create()
return mockDto
ออกมา และเตรียม request ที่จะส่งไป - Act ให้เรียก
app.Test(req)
- Assert ต้องตรวจสอบ 2 อย่าง คือ ได้ response code และ response json ตามที่คาดหวังไว้หรือไม่
- Arrage ให้เตรียมข้อมูลที่ต้องจะใช้ ข้อมูลที่คาดหวังจะได้ และ mock ให้ service.Create()
package handler_test
import (
// ...
)
func TestTodo(t *testing.T) {
t.Run("Add Todo", func(t *testing.T) {
t.Run("success_200", func(t *testing.T) {
//Arrange
mockDto := dto.TodoResponse{
ID: "bfbc2a69-9825-4a0e-a8d6-ffb985dc719c",
Text: "New todo",
Completed: false,
}
expectedCode := http.StatusCreated
expectedResp := common.Response{
Status: expectedCode,
Data: map[string]interface{}{
"todo": mockDto,
},
RequestId: mock.Anything,
}
svc := mocks.NewTaskServiceMock()
svc.On("Create", mock.AnythingOfType("dto.NewTodoForm"), mock.Anything).Return(&mockDto, nil)
//http://localhost:8000/todos
app := fiber.New()
app.Use(requestid.New())
cfg := todo.RouteConfig{
Router: router,
TodoService: svc,
}
todo.SetupRoutes(cfg)
reqBody, err := json.Marshal(map[string]string{
"text": "New todo",
})
assert.NoError(t, err)
req, err := http.NewRequest("POST", "/todos", bytes.NewReader(reqBody))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-ID", mock.Anything)
//Act
resp, err := app.Test(req)
assert.NoError(t, err)
//Assert
if assert.Equal(t, expectedCode, resp.StatusCode) {
body, _ := io.ReadAll(resp.Body)
expected, _ := json.Marshal(expectedResp)
assert.JSONEq(t, string(expected), string(body))
}
})
}
โค้ดสำหรับการทดสอบทั้งหมด ทั้ง service และ handler สามารถดูได้จากที่นี่
สรุป
เมื่อเราใช้หลักการของ Hexagonal Architecture มาใช้ จะทำให้เราสามารถเขียนทดสอบได้ง่ายขึ้น เพราะมี ports เป็น dependency เราเพียงแค่สร้าง mock ที่ implement ตาม port แล้วส่งไปแทน port ของจริง เมื่อต้องการทดสอบ