Go項(xiàng)目實(shí)戰(zhàn)-學(xué)會(huì)對(duì)代碼邏輯層進(jìn)行BDD測(cè)試
前面兩節(jié)我們的單元測(cè)試主要集中在對(duì)項(xiàng)目基礎(chǔ)設(shè)施層的代碼進(jìn)行單元測(cè)試,針對(duì)Dao數(shù)據(jù)操作層我們講解了如何在不實(shí)際對(duì)項(xiàng)目數(shù)據(jù)庫(kù)進(jìn)行CURD的情況下使用了sqlmock的方式進(jìn)行單元測(cè)試。而對(duì)于外部API對(duì)接層則是教會(huì)大家用gock實(shí)現(xiàn)無(wú)侵入的HTTP Mock,對(duì)有API請(qǐng)求的代碼進(jìn)行單元測(cè)試。
今天我們更進(jìn)一步,從項(xiàng)目代碼的基礎(chǔ)設(shè)施層來(lái)到邏輯層和用戶(hù)接口層。邏輯層的代碼肯定更注重邏輯,所以我們?cè)谶@里會(huì)引入goconvey 這個(gè)庫(kù)實(shí)現(xiàn),讓它幫助我們實(shí)現(xiàn)BDD(行為驅(qū)動(dòng)測(cè)試),goconvey支持樹(shù)形結(jié)構(gòu)方便構(gòu)造各種場(chǎng)景,讓我們能更容易地基于 goconvey 來(lái)組織的單測(cè)。本文大綱如下:
圖片
goconvey 的 安裝命令如下:
go get github.com/smartystreets/goconvey
輸入命令后,安裝過(guò)程如下所示:
圖片
關(guān)于goconvey的使用方法詳解,這里就不在給大家舉簡(jiǎn)單的例子進(jìn)行說(shuō)明了,還是按照前面幾篇的風(fēng)格,給大家提供一個(gè)我在公眾號(hào)上寫(xiě)的 goconvey 入門(mén)詳解。
- 使用 Go Convey 做BDD測(cè)試的入門(mén)指南
邏輯層單元測(cè)試實(shí)戰(zhàn)
我們項(xiàng)目各業(yè)務(wù)的核心邏輯都主要集中在領(lǐng)域服務(wù) domainservice 中,按照我們?yōu)轫?xiàng)目做的的單元測(cè)試目錄規(guī)劃,它的單元測(cè)試_test.go 文件都應(yīng)該放在test/domainservice 目錄中。
.
|---test
| |---controller # controller 的測(cè)試用例
| |---dao # dao 的測(cè)試用例
| |---domainservice # 邏輯層領(lǐng)域服務(wù)的測(cè)試用例
| |---library # 外部API對(duì)接的測(cè)試用例
TestMain 入口設(shè)置
依照慣例,在每個(gè)要寫(xiě)單元測(cè)試的package中,我門(mén)都需要在包內(nèi)測(cè)試的統(tǒng)一入口TestMain中做一些公共基礎(chǔ)性的工作。
我們?cè)赥estMain中加上Convey 的SuppressConsoleStatistics和PrintConsoleStatistics,用于在測(cè)試完成后輸出測(cè)試結(jié)果。
package domainservice
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestMain(m *testing.M) {
// convey在TestMain場(chǎng)景下的入口
SuppressConsoleStatistics()
result := m.Run()
// convey在TestMain場(chǎng)景下的結(jié)果打印
PrintConsoleStatistics()
os.Exit(result)
}
這么設(shè)置后,輸出的測(cè)試結(jié)果會(huì)按照單測(cè)中Convey書(shū)寫(xiě)的層級(jí)分層級(jí)顯示,這個(gè)輸出結(jié)果我會(huì)在下面的實(shí)戰(zhàn)案例中展示給大家。
注意這里convey包的導(dǎo)入方式使用了 import . 的語(yǔ)法,import . "github.com/smartystreets/goconvey/convey"
,這樣是為了方便大家直接使用 convey 包中的各種定義,無(wú)需再像 convey.Convey 這樣加包前綴。
實(shí)戰(zhàn)案例一:密碼復(fù)雜度的BDD測(cè)試
在案例一種我們找一個(gè)相對(duì)簡(jiǎn)單的工具函數(shù)來(lái)演示怎么用convey幫助我們組織用例。我們?cè)谟脩?hù)注冊(cè)和重設(shè)密碼種使用過(guò)一個(gè)檢查用戶(hù)密碼復(fù)雜度的工具函數(shù)。
func PasswordComplexityVerify(s string) bool {
var (
hasMinLen = false
hasUpper = false
hasLower = false
hasNumber = false
hasSpecial = false
)
iflen(s) >= 8 {
hasMinLen = true
}
for _, char := range s {
switch {
case unicode.IsUpper(char):
hasUpper = true
case unicode.IsLower(char):
hasLower = true
case unicode.IsNumber(char):
hasNumber = true
case unicode.IsPunct(char) || unicode.IsSymbol(char):
hasSpecial = true
}
}
return hasMinLen && hasUpper && hasLower && hasNumber && hasSpecial
}
接下來(lái)我們就給 PasswordComplexityVerify 函數(shù)編寫(xiě)測(cè)試用例。
func TestPasswordComplexityVerify(t *testing.T) {
Convey("Given a simple password", t, func() {
password := "123456"
Convey("When run it for password complexity checking", func() {
result := util.PasswordComplexityVerify(password)
Convey("Then the checking result should be false", func() {
So(result, ShouldBeFalse)
})
})
})
Convey("Given a complex password", t, func() {
password := "123@1~356Wrx"
Convey("When run it for password complexity checking", func() {
result := util.PasswordComplexityVerify(password)
Convey("Then the checking result should be true", func() {
So(result, ShouldBeTrue)
})
})
})
}
在這個(gè)測(cè)試函數(shù)中,首先我們從正向和負(fù)向兩個(gè)方面對(duì)函數(shù)進(jìn)行單元測(cè)試,正向測(cè)試和負(fù)向測(cè)試都是什么呢,用通俗易懂的文字解釋就是:
- 正向測(cè)試:提供正確的入?yún)?,期待被測(cè)對(duì)象返回正確的結(jié)果。
- 負(fù)向測(cè)試:提供錯(cuò)誤的入慘,期待被測(cè)對(duì)象返回錯(cuò)誤的結(jié)果或者對(duì)應(yīng)的異常。
通過(guò)這個(gè)例子,正好說(shuō)一下在使用goconvy的過(guò)程中需要注意的幾個(gè)點(diǎn):
- Convey 可以嵌套的,這樣我們就可以構(gòu)造出來(lái)一條測(cè)試的場(chǎng)景路徑,幫助我們寫(xiě)出BDD風(fēng)格的單測(cè)。
- Convey 嵌套使用時(shí)函數(shù)的參數(shù)有區(qū)別。
最上層Convey 為Convey(description string, t *testing.T, action func())
其他層級(jí)的嵌套 Convey 不需要傳入 *testing.T,為Convey(description string, action func())
結(jié)合我們?cè)?description 參數(shù)中的描述,我們就可以建立起來(lái)類(lèi)似 BDD (行為驅(qū)動(dòng)測(cè)試)的語(yǔ)義:
- Given【給定某些初始條件】
Given a simple passowrd 給定一個(gè)簡(jiǎn)單密碼
- When 【當(dāng)一些動(dòng)作發(fā)生后】
- When run it for password complexity checking 當(dāng)對(duì)它進(jìn)行復(fù)雜度檢查時(shí)
- Then 【結(jié)果應(yīng)該是】
- Then the checking result should be false 結(jié)果應(yīng)該是 false
BDD測(cè)試中的描述信息通常使用的是Given、When、Then引導(dǎo)的狀語(yǔ)從句,如果喜歡用中文寫(xiě)描述信息也要記得使用類(lèi)似語(yǔ)境的句子。
咱們用 go test -v 命令來(lái)看看測(cè)試運(yùn)行的效果,我們可以看到輸出的測(cè)試結(jié)果會(huì)按照單測(cè)中Convey書(shū)寫(xiě)的層級(jí),分層級(jí)顯示。
圖片
實(shí)戰(zhàn)案例二:用戶(hù)注冊(cè)的BDD測(cè)試
通過(guò)上面一個(gè)相對(duì)簡(jiǎn)單的例子,相信大家對(duì)goconvey庫(kù)的使用已經(jīng)有所了解,那么接下來(lái)我們?cè)賮?lái)看一下,怎么為邏輯層中那些復(fù)雜的代碼邏輯編寫(xiě)單元測(cè)試。
我選用的是用戶(hù)注冊(cè)的領(lǐng)域服務(wù)方法,來(lái)給大家展示為業(yè)務(wù)邏輯代碼編寫(xiě)單元測(cè)試,整個(gè)測(cè)試用 goconvey 組織用例的行為路徑,使用 gomonkey 對(duì) RegisterUser 方法中依賴(lài)的其他方法進(jìn)行Mock,整個(gè)測(cè)試方法的代碼如下:
func TestUserDomainSvc_RegisterUser(t *testing.T) {
Convey("Given a user for RegisterUser of UserDomainSvc", t, func() {
givenUser := &do.UserBaseInfo{
Nickname: "Kevin",
LoginName: "kevin@go-mall.com",
Verified: 0,
Avatar: "",
Slogan: "Keep tang ping",
IsBlocked: 0,
CreatedAt: time.Date(2025, 1, 31, 23, 28, 0, 0, time.Local),
UpdatedAt: time.Date(2025, 1, 31, 23, 28, 0, 0, time.Local),
}
planPassword := "123@1~356Wrx"
var s *dao.UserDao
// 讓UserDao的CreateUser返回Mock數(shù)據(jù)
gomonkey.ApplyMethod(s, "CreateUser", func(_ *dao.UserDao, user *do.UserBaseInfo, password string) (*model.User, error) {
passwordHash, _ := util.BcryptPassword(planPassword)
userResult := &model.User{
ID: 1,
Nickname: givenUser.Nickname,
LoginName: givenUser.LoginName,
Verified: givenUser.Verified,
Password: passwordHash,
Avatar: givenUser.Avatar,
Slogan: givenUser.Slogan,
CreatedAt: givenUser.CreatedAt,
UpdatedAt: givenUser.UpdatedAt,
}
return userResult, nil
})
Convey("When the login name of user is not occupied", func() {
gomonkey.ApplyMethod(s, "FindUserByLoginNam", func(_ *dao.UserDao, loginName string) (*model.User, error) {
returnnew(model.User), nil
})
Convey("Then user should be created successfully", func() {
user, err := domainservice.NewUserDomainSvc(context.TODO()).RegisterUser(givenUser, planPassword)
So(err, ShouldBeNil)
So(user.ID, ShouldEqual, 1)
So(user, ShouldEqual, givenUser)
})
})
Convey("When the login name of user has already been occupied by other users", func() {
gomonkey.ApplyMethod(s, "FindUserByLoginNam", func(_ *dao.UserDao, loginName string) (*model.User, error) {
return &model.User{LoginName: givenUser.LoginName}, nil
})
Convey("Then the user's registration should be unsuccessful", func() {
user, err := domainservice.NewUserDomainSvc(context.TODO()).RegisterUser(givenUser, planPassword)
So(user, ShouldBeNil)
So(err, ShouldNotBeNil)
So(err, ShouldEqual, errcode.ErrUserNameOccupied)
})
})
})
}
在這個(gè)測(cè)試方法中,我在頂層Convey中嵌套了兩個(gè)并列的Convey方法來(lái)組織正向和負(fù)向的單元測(cè)試,之所以不跟上面那個(gè)案例一樣寫(xiě)兩個(gè)并列的頂層Convey方法是因?yàn)楸粶y(cè)方法 RegisterUser 的入?yún)?shù)太難構(gòu)造,這也正好給大家展示了我們使用Convey設(shè)計(jì)單元測(cè)試的行為路徑時(shí)的靈活性。
這里我們提供了兩個(gè)測(cè)試用例,正向用例中讓 RegisterUser 依賴(lài)的Dao方法 CreateUser 返回創(chuàng)建成功的結(jié)果,預(yù)期 RegisterUser 返回正確的結(jié)果。
而負(fù)向用例中則讓 CreateUser 返回用戶(hù)名在數(shù)據(jù)庫(kù)中已存在時(shí)返回的結(jié)果,同時(shí)預(yù)期 RegisterUser 會(huì)返回用戶(hù)名已被占用的錯(cuò)誤 errcode.ErrUserNameOccupied 。
最后咱們用 go test -v
命令來(lái)看看測(cè)試運(yùn)行的效果:
圖片
Controller 的單元測(cè)試
到現(xiàn)在為止我們的單元測(cè)試實(shí)戰(zhàn)案例已經(jīng)覆蓋了數(shù)據(jù)訪問(wèn)Dao層、API對(duì)接層和領(lǐng)域服務(wù)層。還剩下一個(gè)用戶(hù)接口層沒(méi)有涉及到,即項(xiàng)目的Controller方法該怎么做單元測(cè)試呢?
首先我覺(jué)得,按照我們項(xiàng)目的分層架構(gòu)來(lái)說(shuō)Controller是負(fù)責(zé)接受和驗(yàn)證請(qǐng)求和調(diào)用下層拿到結(jié)果返回響應(yīng)的,在這里包含核心業(yè)務(wù)邏輯。如果我們能把它依賴(lài)的下層的單元測(cè)試做到位,Controller的單元測(cè)試可以不做。
不過(guò)我們知道有個(gè)驗(yàn)證項(xiàng)目質(zhì)量的數(shù)據(jù)指標(biāo)叫:測(cè)試覆蓋率,這個(gè)指標(biāo)肯定越高越好,所以這里我在簡(jiǎn)單地把Controller 處理函數(shù)的單元測(cè)試給大家過(guò)一下。
在 Web 項(xiàng)目中 Controller 里都是API接口的請(qǐng)求處理函數(shù),為它們編寫(xiě)單元測(cè)試需要用到Go
自帶的net/http/httptest
包, 它可以mock一個(gè)HTTP請(qǐng)求和響應(yīng)記錄器,讓我們的 server 端接收并處理我們 mock 的HTTP請(qǐng)求,同時(shí)使用響應(yīng)記錄器來(lái)記錄 server 端返回的響應(yīng)內(nèi)容。
這里我們那用戶(hù)登陸這個(gè)接口給大家演示它的Controller函數(shù)是怎么做單元測(cè)試的,它的單元測(cè)試如下。
func TestLoginUser(t *testing.T) {
Convey("Given right login name and password", t, func() {
loginName := "yourName@go-mall.com"
password := "12Qa@6783Wxf3~!45"
Convey("When use them to Login through API /user/login", func() {
var s *appservice.UserAppSvc
gomonkey.ApplyMethod(s, "UserLogin", func(_ *appservice.UserAppSvc, _ *request.UserLogin) (*reply.TokenReply, error) {
LoginReply := &reply.TokenReply{
AccessToken: "70624d19b6644b0bbf8169f51fb5a91f132edebc",
RefreshToken: "d16e22fef5cb7f6c69355c9a3c6ce8d1d3b37a84",
Duration: 7200,
SrvCreateTime: "2025-02-01 15:34:35",
}
return LoginReply, nil
})
var b bytes.Buffer
json.NewEncoder(&b).Encode(map[string]string{"login_name": loginName, "password": password})
req := httptest.NewRequest(http.MethodPost, "/user/login", &b)
req.Header.Set("platform", "H5")
gin.SetMode(gin.ReleaseMode) // 不讓它在控制臺(tái)里輸出路由信息
g := gin.New()
router.RegisterRoutes(g)
// mock一個(gè)響應(yīng)記錄器
w := httptest.NewRecorder()
// 讓server端處理mock請(qǐng)求并記錄返回的響應(yīng)內(nèi)容
g.ServeHTTP(w, req)
Convey("Then the user will login successfully", func() {
So(w.Code, ShouldEqual, http.StatusOK)
// 檢驗(yàn)響應(yīng)內(nèi)容是否復(fù)合預(yù)期
var resp map[string]interface{}
json.Unmarshal([]byte(w.Body.String()), &resp)
respData := resp["data"].(map[string]interface{})
So(respData["access_token"], ShouldNotBeEmpty)
})
})
})
在這個(gè)單元測(cè)試中我們還是會(huì)用 goconvey來(lái)組織測(cè)試的行為路徑,用 gomonkey 給Controller函數(shù)調(diào)用的應(yīng)用服務(wù)方法做打樁返回Mock結(jié)果,不然就跟用POSTMAN 請(qǐng)求接口一樣咧,那樣的話(huà)如果下層代碼里有數(shù)據(jù)庫(kù)CURD更新之類(lèi)操作的話(huà)還是會(huì)去實(shí)際訪問(wèn)數(shù)據(jù)庫(kù)的,這顯然不是我們想要的。
對(duì)于Controller方法的驗(yàn)證主要聚焦于請(qǐng)求參數(shù)的驗(yàn)證以及響應(yīng)結(jié)果的驗(yàn)證,因?yàn)?Controller 在我們項(xiàng)目的分層設(shè)計(jì)中就只干這兩件事。
總結(jié)
通過(guò)這幾節(jié)單元測(cè)試實(shí)戰(zhàn)的內(nèi)容大家應(yīng)該能體會(huì)到,我們?yōu)轫?xiàng)目做好分層設(shè)計(jì)的一個(gè)優(yōu)點(diǎn)--好測(cè)試。每個(gè)分層都有具體的職責(zé),每塊代碼的邊界不至于過(guò)大,這樣我們做單元測(cè)試代碼寫(xiě)起來(lái)會(huì)更簡(jiǎn)單。如果把所有邏輯都耦合在Controller 函數(shù)那種代碼,寫(xiě)單元測(cè)試的難度先不說(shuō),有效性也很難保證,因?yàn)闇y(cè)試的顆粒度太大必然導(dǎo)致很難測(cè)出代碼內(nèi)部的問(wèn)題。