Go 如何編寫出可測試的代碼

之前寫了幾篇文章,介紹在 Go 中如何編寫測試代碼,以及如何解決被測試代碼中的外部依賴問題。但其實在編寫測試代碼之前,還有一個很重要的點,容易被忽略,就是什麼樣的代碼是可測試的代碼?爲了更方便的編寫測試,我們在編碼階段就應該要考慮到,自己寫出來的代碼是否能夠被測試。本文就來聊一聊在 Go 中如何寫出可測試的代碼。

本文不講理論,只講我在實際開發過程中的經驗和思考,使用幾個實際的案例,來演示怎樣從根上解決測試代碼難以編寫的問題。

使用變量來定義函數

假設我們編寫了一個 Login 函數,用來實現用戶登錄,示例代碼如下:

func Login(u User) (string, error) {
 // ...
 token, err := GenerateToken(32)
 if err != nil {
  // ...
 }
 // ...
 return token, nil
}

Login 函數接收 User 信息,並在內部通過 GenerateToken(32) 函數生成一個 32 位長度的隨機 token 作爲認證信息,最終返回 token

這個函數只編寫了大體框架,具體細節沒有實現,但我們可以發現,Login 函數內部依賴了 GenerateToken 函數。

GenerateToken 函數定義如下:

func GenerateToken(n int) (string, error) {
 token := make([]byte, n)
 _, err := rand.Read(token)
 if err != nil {
  return "", err
 }
 return base64.URLEncoding.EncodeToString(token)[:n], nil
}

現在我們要爲 Login 函數編寫單元測試,可以寫出如下測試代碼:

func TestLogin(t *testing.T) {
 u := User{
  ID:     1,
  Name:   "test1",
  Mobile: "13800001111",
 }
 token, err := Login(u)
 assert.NoError(t, err)
 assert.Equal(t, 32, len(token))
}

可以發現,在調用 Login 函數後,我們只能斷言獲得的 token 長度,而無法斷言 token 具體內容,因爲 GenerateToken 函數每次隨機生成的 token 值是不一樣的。

這看起來似乎沒什麼問題,但通常情況下,我們應該儘量避免測試代碼中出現隨機性的值。並且,有可能被測試代碼較爲複雜,比如我們要測試的是調用 Login 函數的上層函數,那麼這個函數可能還會使用 token 去做其他的事情。此時,就會出現代碼無法被測試的情況。

所以,在編寫測試時,我們應該讓 GenerateToken 函數的返回結果固定下來,但現在定義的 GenerateToken 函數顯然無法做到這一點。

要解決這個問題,我們需要重新定義下 GenerateToken 函數:

var GenerateToken = func(n int) (string, error) {
 token := make([]byte, n)
 _, err := rand.Read(token)
 if err != nil {
  return "", err
 }
 return base64.URLEncoding.EncodeToString(token)[:n], nil
}

GenerateToken 函數內部邏輯沒變,不過換了一種定義方式。GenerateToken 不再是函數名,而是一個變量名,這個變量指向了一個匿名函數。

現在我們就有機會在測試 Login 的時候,將 GenerateToken 變量進行替換,實現一個只會返回固定輸出的 GenerateToken 函數。

新版單元測試代碼實現如下:

func TestLogin(t *testing.T) {
 u := User{
  ID:     1,
  Name:   "test1",
  Mobile: "13800001111",
 }
 token, err := Login(u)
 assert.NoError(t, err)
 assert.Equal(t, 32, len(token))
 assert.Equal(t, "jCnuqKnsN5UAM9-LgEGS_COvJWp15RDv", token)
}

func init() {
 GenerateToken = func(n int) (string, error) {
  return "jCnuqKnsN5UAM9-LgEGS_COvJWp15RDv", nil
 }
}

我們利用 init 函數,在測試文件執行一開始就替換了 GenerateToken 變量的指向,新的匿名函數返回固定的 token。這樣一來,在測試時 Login 函數內部調用的就是 GenerateToken 變量所指向的函數了,其返回值已經被固定,因此,我們可以對其進行斷言操作。

使用依賴注入來解決外部依賴

現在我們有一個 GenerateJWT 函數,用來生成 JSON Web Token,其實現如下:

func GenerateJWT(issuer string, userId string, expire time.Duration, privateKey *rsa.PrivateKey) (string, error) {
 nowSec := time.Now().Unix()
 token := jwt.NewWithClaims(jwt.SigningMethodRS512, jwt.MapClaims{
  "expiresAt": nowSec + int64(expire.Seconds()),
  "issuedAt":  nowSec,
  "issuer":    issuer,
  "subject":   userId,
 })

 return token.SignedString(privateKey)
}

這個函數使用當前時間戳作爲 payload,並且使用了 RS512,來生成 JWT。

此時,我們要爲這個函數編寫一個單元測試,代碼如下:

func TestGenerateJWT(t *testing.T) {
 key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKey))
 assert.NoError(t, err)

 token, err := GenerateJWT("jianghushinian""1234", 2*time.Hour, key)
 assert.NoError(t, err)
 assert.Equal(t, 499, len(token))
}

因爲 GenerateJWT 函數生成 token 所使用的 payload 是依賴當前時間的(time.Now().Unix()),故每次生成的 token 都會不同。所以同之前的 GenerateToken 函數一樣,我們也無法斷言 GenerateJWT 返回的 token 內容,只能斷言其長度。

但這是不合理的,斷言 token 長度僅能表示這個 token 生成出來了,但是不保證正確。因爲 JWT 有很多算法,假如在編寫 GenerateJWT 函數時選錯了算法,比如選成了 RS256,那麼 TestGenerateJWT 函數就無法測試出來這個 BUG。

爲了提高 GenerateJWT 函數的測試覆蓋率,我們需要解決 time.Now().Unix() 依賴問題。

這次我們不再採用變量 + init 函數的方式,而是採用依賴注入的思想,將外部依賴當做函數的參數傳遞進來:

func GenerateJWT(issuer string, userId string, nowFunc func() time.Time, expire time.Duration, privateKey *rsa.PrivateKey) (string, error) {
 nowSec := nowFunc().Unix()
 token := jwt.NewWithClaims(jwt.SigningMethodRS512, jwt.MapClaims{
  "expiresAt": nowSec + int64(expire.Seconds()),
  "issuedAt":  nowSec,
  "issuer":    issuer,
  "subject":   userId,
 })

 return token.SignedString(privateKey)
}

可以發現,所謂的依賴注入,就是當 GenerateJWT 函數依賴當前時間時,我們不再通過 GenerateJWT 函數內部直接調用 time.Now() 來獲取,而是使用參數(nowFunc)的方式,將 time.Now 函數傳遞進來,當函數內部需要獲取當前時間時,就調用傳遞進來的函數參數。

這樣,我們便實現了將依賴移動到函數外部,在調用函數時,將依賴從外部注入到函數內部來使用。

現在實現的單元測試代碼就可以斷言生成的 token 是否正確了:

func TestGenerateJWT(t *testing.T) {
 key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKey))
 assert.NoError(t, err)

 nowFunc := func() time.Time {
  return time.Unix(1689815972, 0)
 }

 actual, err := GenerateJWT("jianghushinian""1234", nowFunc, 2*time.Hour, key)
 assert.NoError(t, err)

 expected := "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJleHBpcmVzQXQiOjE2ODk4MjMxNzIsImlzc3VlZEF0IjoxNjg5ODE1OTcyLCJpc3N1ZXIiOiJqaWFuZ2h1c2hpbmlhbiIsInN1YmplY3QiOiIxMjM0In0.NmCDxFaBfAPPgWQ0zVMl8ON1UQMeIVNgFCn1vtbppsunb-VrOMCdnJlguvPnNc6fMD9EkzMYM3Ux8zFnTiICDMRX23UlhAo2Zb3DorThdrBcNWHMUd26DBNI9n_oUY5B6NPqtrutvqCex9lQH0vUYOt2O5dOyZ-H9cVNY1r3fJHNkYuNWxmoZRfka5o1oSWvUw8hBJfgjANOzZ5ACIi0q5hnou5hQ8VljjFsP4zj2a2lU6w5Db8_rOA04BxilkfurdExcPeaAVCtA-Km0zNwL3gGwJB21gwyb4MRHsEf-ra-4-V7O5_JGiSOQgfkNB63RoASljRXpD6q-gakm0e0fA"
 assert.Equal(t, expected, actual)
}

在單元測試中,調用 GenerateJWT 函數時,我們可以使用一個返回固定值的 nowFunc 函數來作爲 time.Now 的替代品。這樣當前時間就被固定下來,因而 GenerateJWT 函數的返回結果也就被固定下來,就可以斷言 GenerateJWT 函數生成的 token 是否正確了。

提示:expected 的值可以在這個網站 生成,測試所用到的 private.pempublic.pem 文件我都放在了這裏。

對於 GenerateJWT 函數,我還編寫了一個 JWT.GenerateToken 方法版本,代碼如下:

type JWT struct {
 privateKey *rsa.PrivateKey
 issuer     string
 // nowFunc is used to mock time in tests
 nowFunc func() time.Time
}

func NewJWT(issuer string, privateKey *rsa.PrivateKey) *JWT {
 return &JWT{
  privateKey: privateKey,
  issuer:     issuer,
  nowFunc:    time.Now,
 }
}

func (j *JWT) GenerateToken(userId string, expire time.Duration) (string, error) {
 nowSec := j.nowFunc().Unix()
 token := jwt.NewWithClaims(jwt.SigningMethodRS512, jwt.MapClaims{
  // map 會對其進行重新排序,排序結果影響簽名結果,簽名結果驗證網址:https://jwt.io/
  "issuer":    j.issuer,
  "issuedAt":  nowSec,
  "expiresAt": nowSec + int64(expire.Seconds()),
  "subject":   userId,
 })

 return token.SignedString(j.privateKey)
}

對於 TestJWT_GenerateToken 單元測試函數的實現,就交給你自己來完成了。

使用接口來解耦代碼

我們有一個 GetChangeLog 函數可以返回項目的 ChangeLog,實現如下:

var version = "dev"

type ChangeLogSpec struct {
 Version   string
 ChangeLog string
}

func GetChangeLog(f *os.File) (ChangeLogSpec, error) {
 data, err := io.ReadAll(f)
 if err != nil {
  return ChangeLogSpec{}, err
 }

 return ChangeLogSpec{
  Version:   version,
  ChangeLog: string(data),
 }, nil
}

GetChangeLog 函數接收一個文件對象 *os.File,使用 io.ReadAll(f) 從文件對象中讀取全部的 ChangeLog 內容並返回。

如果要測試這個函數,我們需要在單元測試中創建一個臨時文件,測試完成後還要對臨時文件進行清理,實現代碼如下:

func TestGetChangeLog(t *testing.T) {
 expected := ChangeLogSpec{
  Version: "v0.1.1",
  ChangeLog: `
# Changelog
All notable changes to this project will be documented in this file.
`,
 }

 f, err := os.CreateTemp("""TEST_CHANGELOG")
 assert.NoError(t, err)
 defer func() {
  _ = f.Close()
  _ = os.RemoveAll(f.Name())
 }()

 data := `
# Changelog
All notable changes to this project will be documented in this file.
`
 _, err = f.WriteString(data)
 assert.NoError(t, err)
 _, _ = f.Seek(0, 0)

 actual, err := GetChangeLog(f)
 assert.NoError(t, err)
 assert.Equal(t, expected, actual)
}

在測試時,爲了構造一個 *os.File 對象,我們不得不創建一個真正的文件。好在 Go 提供了 os.CreateTemp 方法能夠在操作系統的臨時目錄創建文件,方便清理工作。

其實,我們還有更好的方式來實現這個 GetChangeLog 函數:

func GetChangeLog(reader io.Reader) (ChangeLogSpec, error) {
 data, err := io.ReadAll(reader)
 if err != nil {
  return ChangeLogSpec{}, err
 }

 return ChangeLogSpec{
  Version:   version,
  ChangeLog: string(data),
 }, nil
}

我對 GetChangeLog 函數進行了小改造,函數參數不再是一個具體的文件對象,而是一個 io.Reader 接口類型。

GetChangeLog 函數內部代碼無需改變,函數和它的外部依賴,就已經通過接口完成了解耦。

現在,測試過程中我們可以使用 Fake obejct 或者 Mock object 來替換真實的 *os.File 對象。

使用 Fake obejct 實現測試代碼如下:

type fakeReader struct {
 data   string
 offset int
}

func NewFakeReader(input string) io.Reader {
 return &fakeReader{
  data:   input,
  offset: 0,
 }
}

func (r *fakeReader) Read([]byte) (int, error) {
 if r.offset >= len(r.data) {
  return 0, io.EOF // 表示數據已讀取完畢
 }

 n := copy(p, r.data[r.offset:]) // 將數據從字符串複製到 p 中
 r.offset += n

 return n, nil
}

func TestGetChangeLogByIOReader(t *testing.T) {
 expected := ChangeLogSpec{
  Version: "v0.1.1",
  ChangeLog: `
# Changelog
All notable changes to this project will be documented in this file.
`,
 }

 data := `
# Changelog
All notable changes to this project will be documented in this file.
`
 reader := NewFakeReader(data)
 actual, err := GetChangeLogByIOReader(reader)
 assert.NoError(t, err)
 assert.Equal(t, expected, actual)
}

這一次,我們沒有直接創建一個真實的文件對象,而是提供一個實現了 io.Reader 接口的 fakeReader 對象。

在測試時,可以使用這個 fakeReader 來替代文件對象,而不必在操作系統中創建文件。

此外,因爲使用了接口來解耦,我們還可以使用 Mock 技術來編寫測試代碼。

不過 io.Reader 是一個 Go 語言內置接口,gomock 無法直接爲其生成 Mock 代碼。

解決辦法是,我們可以爲其起一個別名:

type IReader io.Reader

然後再爲 IReader 接口實現 Mock 代碼。

還可以對 io.Reader 進行一層包裝:

type ReaderWrapper interface {
 io.Reader
}

然後再爲 ReaderWrapper 接口實現 Mock 代碼。

兩種方式都可行,你可以根據自己的喜好進行選擇。

Mock 測試代碼就交給你自己來完成了。

總結

如何編寫測試代碼,不僅僅是在業務代碼實現以後,寫單元測試時纔要考慮的問題。而是在編寫業務代碼的過程中,時刻都要思考的問題。好的代碼,能夠大大降低編寫測試的難度和週期。

在編寫測試時,我們應該儘量固定所依賴對象的返回值,這就要求依賴對象的代碼能夠方便替換。如果依賴對象是一個函數,我們可以將其定義爲一個變量,測試時將變量替換成返回固定值的臨時對象。

我們也可以採用依賴注入的思想,將被測試代碼內部的依賴,移動到函數參數中來,這樣在測試時,可以將依賴對象進行替換。

在 Go 語言中,使用接口來對代碼進行解耦,是慣用方法,同時也是解決測試依賴的突破口,使用接口,我們纔有機會使用 Fake 和 Mock 測試。

此外,在我們自己編寫業務代碼時,如果代碼實現方能夠提供 Fake object,那麼也能爲編寫測試代碼的人提供便利。這一點可以參考 K8s client-go 項目,K8s 團隊在實現 client-go 時提供了對應的 Fake object,如果我們的代碼依賴了 client-go,那麼就可以直接使用 K8s 提供的 Fake object 了,而不必自己來創建 Fake object,非常方便,值得借鑑。

本文完整代碼示例我放在了 GitHub 上,歡迎點擊查看。

希望此文能對你有所幫助。

參考

聯繫我

微信:jianghushinian

郵箱:jianghushinian007@outlook.com

博客地址:https://jianghushinian.cn

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/JZSTF9cPCirzgNowqVZHNg