Hỏi - đáp Nơi cung cấp thông tin nghề nghiệp và giải đáp những thắc mắc thường gặp của bạn

Golang và Unit test

Một số framwork dùng cho viết UT

Một số bài viết có nêu ra một số lib hỗ trợ cho việc viết Unit test

  • Go standard library
  • Testify
  • gocheck
  • gopwt
  • go-testdeep
  • Ginkgo and Gomega
  • Goblin
  • GoConvey

Bạn có thể tham khảo thêm tại https://bmuschko.com/blog/go-testing-frameworks/ ở đây mô tả khá rõ công dụng và lợi ích của từng loại khi viết unit test để có thể sử dụng đúng với yêu cầu của dự án đang làm.

Ở bài viết này mình sẽ nói cụ thể hơn cho Testify vì mình đang dùng nó cho việc viết unit test vì mình thấy việc dùng testify khá tiện lợi, dễ hiểu và còn hỗ trợ việc mock data

Go Testify

Sử dụng go testify

Nếu bạn đang sử dụng Go module thì việc sử dụng testify thì chỉ cần import pakage cần thiết ở trên đầu file test *_test.go và khi chạy lệnh go test thì sẽ được thực hiện. Còn nếu vẫn còn sử dụng GOPATH thì bạn cần chạy lệnh sau để tải về package cần thiết

go get github.com/stretchr/testify

Một trường hợp đơn giản

Ví dụ mình có một hàm đơn giản để tính toán tổng của 2 số

func Add(firstNumber, secondNumber int) int {
    return firstNumber + secondNumber
}

Như vậy khi viết UT thì chúng ta sẽ tạo ra một hàm cho việc test này như sau, và đương nhiên là cần phải import package testing để thực hiện việc test.

package main
import "testing"
func testAddFailed(t *testing.T) {
    if Add(1,2) != 3 {
        t.Errorf("Result is incorrect")
    }
}

hoặc chúng ta có thể so sánh kết quả của UT như thế này

package main
import "testing"
func testAddFailed(t *testing.T) {
    assert.Equal(, Add(1,2), 3)
}

Như vậy là chúng ta test một trường hợp của hàm Add nhưng mà đương nhiên đây là trong trường hợp đơn giản, hàm được test thì chỉ thực hiện việc tính toán chứ chưa đụng gì đến việc gọi đến DB hay là gọi tới API khác, lúc đó chúng ta sẽ đề cập đến việc mock data ở mục chi tiết hơn.

Negative Test Cases và Nil Tests

Khi viết UT thì không phải trường hợp nào chúng ta cũng mong muốn kết quả trả về sẽ bằng một kết quả nào đó, sẽ có nhiều trường hợp chúng ta muốn so sánh không bằng. Ví dụ khi chúng ta gọi tới một API và mong muốn status trả về không phải là 500

func TestStatusNotError(t *testing.T) {
  assert.NotEqual(t, status, 500)
}

Hoặc trường hợp bạn gọi tới API và mong muốn trả về một đối tượng khác Nil

func TestStatusNotError(t *testing.T) {
  assert.NotNil(object)
}

Kết hợp Testify với Table-Driven test

Việc test một function sẽ có rất nhiều trường hợp xảy ra, nếu chúng ta phải định nghĩa ra tất cả trường hợp thì sẽ bị duplicate code ở nhiều chỗ

package main

import (
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestAdd(t *testing.T) {
	assert := assert.New(t)

	var tests = []struct {
		firstNumber int
        secondNumber int
		expected int
	}{
		{1, 2, 3},
		{-1, 1, 0},
		{1, -1, 2},
		{-1, -1, -2},
		{11111, 11111, 22222},
	}

	for _, test := range tests {
		assert.Equal(Add(test.infirstNumberput, test.secondNumber), test.expected)
	}
}

Trong trường hợp test hàm Add mình giả sử rất nhiều trường hợp có thể xảy ra, khi đó sẽ định nghĩa 3 tham số, firstNumber, secondNumber và expected như vậy chúng ta chỉ cần định nghĩa một lần cho các trường hợp, và đương nhiên trong trường hợp có sự thay đổi thì chúng ta cũng dễ dàng thay đổi các case mà không ảnh hưởng tới các chỗ khác Sau khi định nghĩa các case chúng ta chỉ cần lặp qua các trường hợp tương ứng. Trong trường hợp này mình dùng lệnh assert.New(t) để có thể thực hiện được lệnh essert.Equal nhiều lần.

Mocking

Giả sử bây giờ chúng ta cần gọi tới một API nào đó để lấy dữ liệu trả về và phục vụ cho việc tính toán, hoặc thực hiện việc tạo thêm một đối tượng, hay xoá một đối tượng nào đó thì sẽ gây ra những vấn đề như là việc tạo một đối tượng sẽ gây ra lỗi cho cùng một id khi tạo lần thứ 2, hay tương tự như việc xoá một đối tượng mà mình đã thực hiện xoá ở lần trước đó. Như vậy việc mocking giúp cho chúng ta có thể thực hiện việc giả một Request chẳng hạn, Thay vì gọi trực tiếp tới DB hặc là tới API khác thì chúng ta sẽ giả định giá trị trả về và dùng dữ liệu đó để thực hiện tiếp cho các công việc tiếp theo của hàm hiện tại. Ví dụ bây giờ chúng ta có một service dùng để gửi tin nhắn tới các tài khoản của ứng dụng, và chúng ta không muốn mỗi lần test thì lại gửi tin nhắn tới người dùng gây cảm giác khó chịu

package main

import (
	"fmt"
)

type MessageService interface {
	SendMessageToUser(string) error
}
// struct implement cho MessageService để thực hiện việc gửi message
type SMSService struct{}

// MyService sẽ dùng MessageService để gửi tin nhắn tới user
type MyService struct {
	messageService MessageService
}

//implement lại interface MessageService
func (sms SMSService) SendMessageToUser(message string) error {
	fmt.Println("Sending Message")
	return nil
}

// service dùng MessageSerice để gửi message
func (a MyService) CalculateUser(message string) error {
	a.messageService.SendMessageToUser(message)
	fmt.Printf("Message send to user and do something else")
	return nil
}

func main() {
	smsService := SMSService{}
	myService := MyService{smsService}
	myService.SendMessage("hello")
}

Như vậy thì chúng ta sẽ gửi message hello tới user khi mỗi lần chạy Bây giờ chúng ta muốn test function CalculateUser thì chúng ta lại phải gửi message tới User Thay vào đó chúng ta chỉ cần mock MessageService và giả định Service này đã gửi message tới user và phần công việc tiếp theo vẫn được thực hiện

package main

import (
	"fmt"
	"testing"

	"github.com/stretchr/testify/mock"
)
// smsServiceMock để giả định cho một MessageService
type smsServiceMock struct {
	mock.Mock
}

// và đương nhiên là chúng ta phải implement lại service MessageService để giả định cho việc gửi message tới user
// như vậy thay vì phải gọi tới SMSService để gửi message thì bây giờ chúng ta sẽ gọi smsServiceMock
func (m *smsServiceMock) SendMessageToUser(message string) bool {
	fmt.Println("Mocked function")
  return true
}

func TestSendMessage(t *testing.T) {
// new một smsServiceMock để thay thế cho SMSService
	smsService := new(smsServiceMock)

  // giả định cho một hàm
	smsService.On("SendMessageToUser", "hello").Return(true)

  // sử dụng smsServiceMock để giả định
  myService := MyService{smsService}
  // hàm chính 
	myService.SendMessage("hello")

	smsService.AssertExpectations(t)
}

Như vậy chúng ta đã có thể test một function với mocking data

Generating Mocks với Mockery

Việc tạo ra các file Mocking là gần như là như nhau, và chúng ta hoàn toàn có thể gen các file mock này dựa nào file chính cần mock với Mokery, Mockery sẽ sử dụng các inteface name mà chúng ta muốn dùng để mock và sau đó generate ra các struct mock tương ứng. Và kết quả sau khi generate sẽ nằm ở mocks/InterfaceName.go

Tài liệu tham khảo

https://bmuschko.com/blog/go-testing-frameworks/#go-testdeephttps://tutorialedge.net/golang/improving-your-tests-with-testify-go/