詳解如何在Go中如何編寫出可測試的代碼
之前寫了幾篇文章,介紹在 Go 中如何編寫測試代碼,以及如何解決被測試代碼中的外部依賴問題。但其實在編寫測試代碼之前,還有一個很重要的點,容易被忽略,就是什么樣的代碼是可測試的代碼?為了更方便的編寫測試,我們在編碼階段就應該要考慮到,自己寫出來的代碼是否能夠被測試。本文就來聊一聊在 Go 中如何寫出可測試的代碼。
本文不講理論,只講我在實際開發(fā)過程中的經驗和思考,使用幾個實際的案例,來演示怎樣從根上解決測試代碼難以編寫的問題。
使用變量來定義函數
假設我們編寫了一個 Login 函數,用來實現用戶登錄,示例代碼如下:
func Login(u User) (string, error) {
// ...
token, err := GenerateToken(32)
if err != nil {
// ...
}
// ...
return token, nil
}Login 函數接收 User 信息,并在內部通過 GenerateToken(32) 函數生成一個 32 位長度的隨機 token 作為認證信息,最終返回 token。
這個函數只編寫了大體框架,具體細節(jié)沒有實現,但我們可以發(fā)現,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))
}可以發(fā)現,在調用 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 函數,在測試文件執(zhí)行一開始就替換了 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)
}可以發(fā)現,所謂的依賴注入,就是當 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.pem 和 public.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 內容并返回。
如果要測試這個函數,我們需要在單元測試中創(chuàng)建一個臨時文件,測試完成后還要對臨時文件進行清理,實現代碼如下:
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 對象,我們不得不創(chuàng)建一個真正的文件。好在 Go 提供了 os.CreateTemp 方法能夠在操作系統(tǒng)的臨時目錄創(chuàng)建文件,方便清理工作。
其實,我們還有更好的方式來實現這個 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(p []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)
}這一次,我們沒有直接創(chuàng)建一個真實的文件對象,而是提供一個實現了 io.Reader 接口的 fakeReader 對象。
在測試時,可以使用這個 fakeReader 來替代文件對象,而不必在操作系統(tǒng)中創(chuàng)建文件。
此外,因為使用了接口來解耦,我們還可以使用 Mock 技術來編寫測試代碼。
不過 io.Reader 是一個 Go 語言內置接口,gomock 無法直接為其生成 Mock 代碼。
解決辦法是,我們可以為其起一個別名:
type IReader io.Reader
然后再為 IReader 接口實現 Mock 代碼。
還可以對 io.Reader 進行一層包裝:
type ReaderWrapper interface {
io.Reader
}然后再為 ReaderWrapper 接口實現 Mock 代碼。
兩種方式都可行,你可以根據自己的喜好進行選擇。
Mock 測試代碼就交給你自己來完成了。
總結
如何編寫測試代碼,不僅僅是在業(yè)務代碼實現以后,寫單元測試時才要考慮的問題。而是在編寫業(yè)務代碼的過程中,時刻都要思考的問題。好的代碼,能夠大大降低編寫測試的難度和周期。
在編寫測試時,我們應該盡量固定所依賴對象的返回值,這就要求依賴對象的代碼能夠方便替換。如果依賴對象是一個函數,我們可以將其定義為一個變量,測試時將變量替換成返回固定值的臨時對象。
我們也可以采用依賴注入的思想,將被測試代碼內部的依賴,移動到函數參數中來,這樣在測試時,可以將依賴對象進行替換。
在 Go 語言中,使用接口來對代碼進行解耦,是慣用方法,同時也是解決測試依賴的突破口,使用接口,我們才有機會使用 Fake 和 Mock 測試。
此外,在我們自己編寫業(yè)務代碼時,如果代碼實現方能夠提供 Fake object,那么也能為編寫測試代碼的人提供便利。這一點可以參考 K8s client-go 項目,K8s 團隊在實現 client-go 時提供了對應的 Fake object,如果我們的代碼依賴了 client-go,那么就可以直接使用 K8s 提供的 Fake object 了,而不必自己來創(chuàng)建 Fake object,非常方便,值得借鑒。
以上就是詳解如何在Go中如何編寫出可測試的代碼的詳細內容,更多關于Go編寫可測試代碼的資料請關注腳本之家其它相關文章!
相關文章
golang gopm get -g -v 無法獲取第三方庫的解決方案
這篇文章主要介紹了golang gopm get -g -v 無法獲取第三方庫的解決方案,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-05-05

