詳解如何在Go中如何編寫出可測(cè)試的代碼
之前寫了幾篇文章,介紹在 Go 中如何編寫測(cè)試代碼,以及如何解決被測(cè)試代碼中的外部依賴問題。但其實(shí)在編寫測(cè)試代碼之前,還有一個(gè)很重要的點(diǎn),容易被忽略,就是什么樣的代碼是可測(cè)試的代碼?為了更方便的編寫測(cè)試,我們?cè)诰幋a階段就應(yīng)該要考慮到,自己寫出來的代碼是否能夠被測(cè)試。本文就來聊一聊在 Go 中如何寫出可測(cè)試的代碼。
本文不講理論,只講我在實(shí)際開發(fā)過程中的經(jīng)驗(yàn)和思考,使用幾個(gè)實(shí)際的案例,來演示怎樣從根上解決測(cè)試代碼難以編寫的問題。
使用變量來定義函數(shù)
假設(shè)我們編寫了一個(gè) Login
函數(shù),用來實(shí)現(xiàn)用戶登錄,示例代碼如下:
func Login(u User) (string, error) { // ... token, err := GenerateToken(32) if err != nil { // ... } // ... return token, nil }
Login
函數(shù)接收 User
信息,并在內(nèi)部通過 GenerateToken(32)
函數(shù)生成一個(gè) 32 位長(zhǎng)度的隨機(jī) token
作為認(rèn)證信息,最終返回 token
。
這個(gè)函數(shù)只編寫了大體框架,具體細(xì)節(jié)沒有實(shí)現(xiàn),但我們可以發(fā)現(xiàn),Login
函數(shù)內(nèi)部依賴了 GenerateToken
函數(shù)。
GenerateToken
函數(shù)定義如下:
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 }
現(xiàn)在我們要為 Login
函數(shù)編寫單元測(cè)試,可以寫出如下測(cè)試代碼:
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ā)現(xiàn),在調(diào)用 Login
函數(shù)后,我們只能斷言獲得的 token
長(zhǎng)度,而無法斷言 token
具體內(nèi)容,因?yàn)?GenerateToken
函數(shù)每次隨機(jī)生成的 token
值是不一樣的。
這看起來似乎沒什么問題,但通常情況下,我們應(yīng)該盡量避免測(cè)試代碼中出現(xiàn)隨機(jī)性的值。并且,有可能被測(cè)試代碼較為復(fù)雜,比如我們要測(cè)試的是調(diào)用 Login
函數(shù)的上層函數(shù),那么這個(gè)函數(shù)可能還會(huì)使用 token
去做其他的事情。此時(shí),就會(huì)出現(xiàn)代碼無法被測(cè)試的情況。
所以,在編寫測(cè)試時(shí),我們應(yīng)該讓 GenerateToken
函數(shù)的返回結(jié)果固定下來,但現(xiàn)在定義的 GenerateToken
函數(shù)顯然無法做到這一點(diǎn)。
要解決這個(gè)問題,我們需要重新定義下 GenerateToken
函數(shù):
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
函數(shù)內(nèi)部邏輯沒變,不過換了一種定義方式。GenerateToken
不再是函數(shù)名,而是一個(gè)變量名,這個(gè)變量指向了一個(gè)匿名函數(shù)。
現(xiàn)在我們就有機(jī)會(huì)在測(cè)試 Login
的時(shí)候,將 GenerateToken
變量進(jìn)行替換,實(shí)現(xiàn)一個(gè)只會(huì)返回固定輸出的 GenerateToken
函數(shù)。
新版單元測(cè)試代碼實(shí)現(xiàn)如下:
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
函數(shù),在測(cè)試文件執(zhí)行一開始就替換了 GenerateToken
變量的指向,新的匿名函數(shù)返回固定的 token
。這樣一來,在測(cè)試時(shí) Login
函數(shù)內(nèi)部調(diào)用的就是 GenerateToken
變量所指向的函數(shù)了,其返回值已經(jīng)被固定,因此,我們可以對(duì)其進(jìn)行斷言操作。
使用依賴注入來解決外部依賴
現(xiàn)在我們有一個(gè) GenerateJWT
函數(shù),用來生成 JSON Web Token,其實(shí)現(xiàn)如下:
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) }
這個(gè)函數(shù)使用當(dāng)前時(shí)間戳作為 payload
,并且使用了 RS512
,來生成 JWT。
此時(shí),我們要為這個(gè)函數(shù)編寫一個(gè)單元測(cè)試,代碼如下:
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)) }
因?yàn)?GenerateJWT
函數(shù)生成 token
所使用的 payload
是依賴當(dāng)前時(shí)間的(time.Now().Unix()
),故每次生成的 token
都會(huì)不同。所以同之前的 GenerateToken
函數(shù)一樣,我們也無法斷言 GenerateJWT
返回的 token
內(nèi)容,只能斷言其長(zhǎng)度。
但這是不合理的,斷言 token
長(zhǎng)度僅能表示這個(gè) token
生成出來了,但是不保證正確。因?yàn)?JWT 有很多算法,假如在編寫 GenerateJWT
函數(shù)時(shí)選錯(cuò)了算法,比如選成了 RS256
,那么 TestGenerateJWT
函數(shù)就無法測(cè)試出來這個(gè) BUG。
為了提高 GenerateJWT
函數(shù)的測(cè)試覆蓋率,我們需要解決 time.Now().Unix()
依賴問題。
這次我們不再采用變量 + init
函數(shù)的方式,而是采用依賴注入的思想,將外部依賴當(dāng)做函數(shù)的參數(shù)傳遞進(jìn)來:
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ā)現(xiàn),所謂的依賴注入,就是當(dāng) GenerateJWT
函數(shù)依賴當(dāng)前時(shí)間時(shí),我們不再通過 GenerateJWT
函數(shù)內(nèi)部直接調(diào)用 time.Now()
來獲取,而是使用參數(shù)(nowFunc
)的方式,將 time.Now
函數(shù)傳遞進(jìn)來,當(dāng)函數(shù)內(nèi)部需要獲取當(dāng)前時(shí)間時(shí),就調(diào)用傳遞進(jìn)來的函數(shù)參數(shù)。
這樣,我們便實(shí)現(xiàn)了將依賴移動(dòng)到函數(shù)外部,在調(diào)用函數(shù)時(shí),將依賴從外部注入到函數(shù)內(nèi)部來使用。
現(xiàn)在實(shí)現(xiàn)的單元測(cè)試代碼就可以斷言生成的 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) }
在單元測(cè)試中,調(diào)用 GenerateJWT
函數(shù)時(shí),我們可以使用一個(gè)返回固定值的 nowFunc
函數(shù)來作為 time.Now
的替代品。這樣當(dāng)前時(shí)間就被固定下來,因而 GenerateJWT
函數(shù)的返回結(jié)果也就被固定下來,就可以斷言 GenerateJWT
函數(shù)生成的 token
是否正確了。
提示:expected
的值可以在這個(gè)網(wǎng)站 生成,測(cè)試所用到的 private.pem
和 public.pem
文件我都放在了這里。
對(duì)于 GenerateJWT
函數(shù),我還編寫了一個(gè) 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 會(huì)對(duì)其進(jìn)行重新排序,排序結(jié)果影響簽名結(jié)果,簽名結(jié)果驗(yàn)證網(wǎng)址:https://jwt.io/ "issuer": j.issuer, "issuedAt": nowSec, "expiresAt": nowSec + int64(expire.Seconds()), "subject": userId, }) return token.SignedString(j.privateKey) }
對(duì)于 TestJWT_GenerateToken
單元測(cè)試函數(shù)的實(shí)現(xiàn),就交給你自己來完成了。
使用接口來解耦代碼
我們有一個(gè) GetChangeLog
函數(shù)可以返回項(xiàng)目的 ChangeLog,實(shí)現(xiàn)如下:
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
函數(shù)接收一個(gè)文件對(duì)象 *os.File
,使用 io.ReadAll(f)
從文件對(duì)象中讀取全部的 ChangeLog 內(nèi)容并返回。
如果要測(cè)試這個(gè)函數(shù),我們需要在單元測(cè)試中創(chuàng)建一個(gè)臨時(shí)文件,測(cè)試完成后還要對(duì)臨時(shí)文件進(jìn)行清理,實(shí)現(xiàn)代碼如下:
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) }
在測(cè)試時(shí),為了構(gòu)造一個(gè) *os.File
對(duì)象,我們不得不創(chuàng)建一個(gè)真正的文件。好在 Go 提供了 os.CreateTemp
方法能夠在操作系統(tǒng)的臨時(shí)目錄創(chuàng)建文件,方便清理工作。
其實(shí),我們還有更好的方式來實(shí)現(xiàn)這個(gè) GetChangeLog
函數(shù):
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 }
我對(duì) GetChangeLog
函數(shù)進(jìn)行了小改造,函數(shù)參數(shù)不再是一個(gè)具體的文件對(duì)象,而是一個(gè) io.Reader
接口類型。
GetChangeLog
函數(shù)內(nèi)部代碼無需改變,函數(shù)和它的外部依賴,就已經(jīng)通過接口完成了解耦。
現(xiàn)在,測(cè)試過程中我們可以使用 Fake obejct 或者 Mock object 來替換真實(shí)的 *os.File
對(duì)象。
使用 Fake obejct 實(shí)現(xiàn)測(cè)試代碼如下:
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 // 表示數(shù)據(jù)已讀取完畢 } n := copy(p, r.data[r.offset:]) // 將數(shù)據(jù)從字符串復(fù)制到 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)建一個(gè)真實(shí)的文件對(duì)象,而是提供一個(gè)實(shí)現(xiàn)了 io.Reader
接口的 fakeReader
對(duì)象。
在測(cè)試時(shí),可以使用這個(gè) fakeReader
來替代文件對(duì)象,而不必在操作系統(tǒng)中創(chuàng)建文件。
此外,因?yàn)槭褂昧私涌趤斫怦?,我們還可以使用 Mock 技術(shù)來編寫測(cè)試代碼。
不過 io.Reader
是一個(gè) Go 語言內(nèi)置接口,gomock 無法直接為其生成 Mock 代碼。
解決辦法是,我們可以為其起一個(gè)別名:
type IReader io.Reader
然后再為 IReader
接口實(shí)現(xiàn) Mock 代碼。
還可以對(duì) io.Reader
進(jìn)行一層包裝:
type ReaderWrapper interface { io.Reader }
然后再為 ReaderWrapper
接口實(shí)現(xiàn) Mock 代碼。
兩種方式都可行,你可以根據(jù)自己的喜好進(jìn)行選擇。
Mock 測(cè)試代碼就交給你自己來完成了。
總結(jié)
如何編寫測(cè)試代碼,不僅僅是在業(yè)務(wù)代碼實(shí)現(xiàn)以后,寫單元測(cè)試時(shí)才要考慮的問題。而是在編寫業(yè)務(wù)代碼的過程中,時(shí)刻都要思考的問題。好的代碼,能夠大大降低編寫測(cè)試的難度和周期。
在編寫測(cè)試時(shí),我們應(yīng)該盡量固定所依賴對(duì)象的返回值,這就要求依賴對(duì)象的代碼能夠方便替換。如果依賴對(duì)象是一個(gè)函數(shù),我們可以將其定義為一個(gè)變量,測(cè)試時(shí)將變量替換成返回固定值的臨時(shí)對(duì)象。
我們也可以采用依賴注入的思想,將被測(cè)試代碼內(nèi)部的依賴,移動(dòng)到函數(shù)參數(shù)中來,這樣在測(cè)試時(shí),可以將依賴對(duì)象進(jìn)行替換。
在 Go 語言中,使用接口來對(duì)代碼進(jìn)行解耦,是慣用方法,同時(shí)也是解決測(cè)試依賴的突破口,使用接口,我們才有機(jī)會(huì)使用 Fake 和 Mock 測(cè)試。
此外,在我們自己編寫業(yè)務(wù)代碼時(shí),如果代碼實(shí)現(xiàn)方能夠提供 Fake object,那么也能為編寫測(cè)試代碼的人提供便利。這一點(diǎn)可以參考 K8s client-go 項(xiàng)目,K8s 團(tuán)隊(duì)在實(shí)現(xiàn) client-go
時(shí)提供了對(duì)應(yīng)的 Fake object,如果我們的代碼依賴了 client-go
,那么就可以直接使用 K8s 提供的 Fake object 了,而不必自己來創(chuàng)建 Fake object,非常方便,值得借鑒。
以上就是詳解如何在Go中如何編寫出可測(cè)試的代碼的詳細(xì)內(nèi)容,更多關(guān)于Go編寫可測(cè)試代碼的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
golang項(xiàng)目如何上線部署到Linu服務(wù)器(方法詳解)
這篇文章主要介紹了golang項(xiàng)目如何上線部署到Linu服務(wù)器,本文通過兩種方法給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-10-10Go事務(wù)中止時(shí)是否真的結(jié)束事務(wù)解析
這篇文章主要為大家介紹了Go事務(wù)中止時(shí)是否真的結(jié)束事務(wù)實(shí)例解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-04-04golang版本升級(jí)的簡(jiǎn)單實(shí)現(xiàn)步驟
個(gè)人感覺Go在眾多高級(jí)語言中,是在各方面都比較高效的,下面這篇文章主要給大家介紹了關(guān)于golang版本升級(jí)的簡(jiǎn)單實(shí)現(xiàn)步驟,文中通過實(shí)例代碼介紹的非常詳細(xì),需要的朋友可以參考下2023-02-02golang gopm get -g -v 無法獲取第三方庫(kù)的解決方案
這篇文章主要介紹了golang gopm get -g -v 無法獲取第三方庫(kù)的解決方案,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2021-05-05