Go項目實戰(zhàn)-學會對代碼邏輯層進行BDD測試
前面兩節(jié)我們的單元測試主要集中在對項目基礎設施層的代碼進行單元測試,針對Dao數(shù)據(jù)操作層我們講解了如何在不實際對項目數(shù)據(jù)庫進行CURD的情況下使用了sqlmock的方式進行單元測試。而對于外部API對接層則是教會大家用gock實現(xiàn)無侵入的HTTP Mock,對有API請求的代碼進行單元測試。
今天我們更進一步,從項目代碼的基礎設施層來到邏輯層和用戶接口層。邏輯層的代碼肯定更注重邏輯,所以我們在這里會引入goconvey 這個庫實現(xiàn),讓它幫助我們實現(xiàn)BDD(行為驅(qū)動測試),goconvey支持樹形結(jié)構(gòu)方便構(gòu)造各種場景,讓我們能更容易地基于 goconvey 來組織的單測。本文大綱如下:
圖片
goconvey 的 安裝命令如下:
go get github.com/smartystreets/goconvey
輸入命令后,安裝過程如下所示:
圖片
關于goconvey的使用方法詳解,這里就不在給大家舉簡單的例子進行說明了,還是按照前面幾篇的風格,給大家提供一個我在公眾號上寫的 goconvey 入門詳解。
- 使用 Go Convey 做BDD測試的入門指南
邏輯層單元測試實戰(zhàn)
我們項目各業(yè)務的核心邏輯都主要集中在領域服務 domainservice 中,按照我們?yōu)轫椖孔龅牡膯卧獪y試目錄規(guī)劃,它的單元測試_test.go 文件都應該放在test/domainservice 目錄中。
.
|---test
| |---controller # controller 的測試用例
| |---dao # dao 的測試用例
| |---domainservice # 邏輯層領域服務的測試用例
| |---library # 外部API對接的測試用例
TestMain 入口設置
依照慣例,在每個要寫單元測試的package中,我門都需要在包內(nèi)測試的統(tǒng)一入口TestMain中做一些公共基礎性的工作。
我們在TestMain中加上Convey 的SuppressConsoleStatistics和PrintConsoleStatistics,用于在測試完成后輸出測試結(jié)果。
package domainservice
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestMain(m *testing.M) {
// convey在TestMain場景下的入口
SuppressConsoleStatistics()
result := m.Run()
// convey在TestMain場景下的結(jié)果打印
PrintConsoleStatistics()
os.Exit(result)
}
這么設置后,輸出的測試結(jié)果會按照單測中Convey書寫的層級分層級顯示,這個輸出結(jié)果我會在下面的實戰(zhàn)案例中展示給大家。
注意這里convey包的導入方式使用了 import . 的語法,import . "github.com/smartystreets/goconvey/convey"
,這樣是為了方便大家直接使用 convey 包中的各種定義,無需再像 convey.Convey 這樣加包前綴。
實戰(zhàn)案例一:密碼復雜度的BDD測試
在案例一種我們找一個相對簡單的工具函數(shù)來演示怎么用convey幫助我們組織用例。我們在用戶注冊和重設密碼種使用過一個檢查用戶密碼復雜度的工具函數(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
}
接下來我們就給 PasswordComplexityVerify 函數(shù)編寫測試用例。
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)
})
})
})
}
在這個測試函數(shù)中,首先我們從正向和負向兩個方面對函數(shù)進行單元測試,正向測試和負向測試都是什么呢,用通俗易懂的文字解釋就是:
- 正向測試:提供正確的入?yún)ⅲ诖粶y對象返回正確的結(jié)果。
- 負向測試:提供錯誤的入慘,期待被測對象返回錯誤的結(jié)果或者對應的異常。
通過這個例子,正好說一下在使用goconvy的過程中需要注意的幾個點:
- Convey 可以嵌套的,這樣我們就可以構(gòu)造出來一條測試的場景路徑,幫助我們寫出BDD風格的單測。
- Convey 嵌套使用時函數(shù)的參數(shù)有區(qū)別。
最上層Convey 為Convey(description string, t *testing.T, action func())
其他層級的嵌套 Convey 不需要傳入 *testing.T,為Convey(description string, action func())
結(jié)合我們在 description 參數(shù)中的描述,我們就可以建立起來類似 BDD (行為驅(qū)動測試)的語義:
- Given【給定某些初始條件】
Given a simple passowrd 給定一個簡單密碼
- When 【當一些動作發(fā)生后】
- When run it for password complexity checking 當對它進行復雜度檢查時
- Then 【結(jié)果應該是】
- Then the checking result should be false 結(jié)果應該是 false
BDD測試中的描述信息通常使用的是Given、When、Then引導的狀語從句,如果喜歡用中文寫描述信息也要記得使用類似語境的句子。
咱們用 go test -v 命令來看看測試運行的效果,我們可以看到輸出的測試結(jié)果會按照單測中Convey書寫的層級,分層級顯示。
圖片
實戰(zhàn)案例二:用戶注冊的BDD測試
通過上面一個相對簡單的例子,相信大家對goconvey庫的使用已經(jīng)有所了解,那么接下來我們再來看一下,怎么為邏輯層中那些復雜的代碼邏輯編寫單元測試。
我選用的是用戶注冊的領域服務方法,來給大家展示為業(yè)務邏輯代碼編寫單元測試,整個測試用 goconvey 組織用例的行為路徑,使用 gomonkey 對 RegisterUser 方法中依賴的其他方法進行Mock,整個測試方法的代碼如下:
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)
})
})
})
}
在這個測試方法中,我在頂層Convey中嵌套了兩個并列的Convey方法來組織正向和負向的單元測試,之所以不跟上面那個案例一樣寫兩個并列的頂層Convey方法是因為被測方法 RegisterUser 的入?yún)?shù)太難構(gòu)造,這也正好給大家展示了我們使用Convey設計單元測試的行為路徑時的靈活性。
這里我們提供了兩個測試用例,正向用例中讓 RegisterUser 依賴的Dao方法 CreateUser 返回創(chuàng)建成功的結(jié)果,預期 RegisterUser 返回正確的結(jié)果。
而負向用例中則讓 CreateUser 返回用戶名在數(shù)據(jù)庫中已存在時返回的結(jié)果,同時預期 RegisterUser 會返回用戶名已被占用的錯誤 errcode.ErrUserNameOccupied 。
最后咱們用 go test -v
命令來看看測試運行的效果:
圖片
Controller 的單元測試
到現(xiàn)在為止我們的單元測試實戰(zhàn)案例已經(jīng)覆蓋了數(shù)據(jù)訪問Dao層、API對接層和領域服務層。還剩下一個用戶接口層沒有涉及到,即項目的Controller方法該怎么做單元測試呢?
首先我覺得,按照我們項目的分層架構(gòu)來說Controller是負責接受和驗證請求和調(diào)用下層拿到結(jié)果返回響應的,在這里包含核心業(yè)務邏輯。如果我們能把它依賴的下層的單元測試做到位,Controller的單元測試可以不做。
不過我們知道有個驗證項目質(zhì)量的數(shù)據(jù)指標叫:測試覆蓋率,這個指標肯定越高越好,所以這里我在簡單地把Controller 處理函數(shù)的單元測試給大家過一下。
在 Web 項目中 Controller 里都是API接口的請求處理函數(shù),為它們編寫單元測試需要用到Go
自帶的net/http/httptest
包, 它可以mock一個HTTP請求和響應記錄器,讓我們的 server 端接收并處理我們 mock 的HTTP請求,同時使用響應記錄器來記錄 server 端返回的響應內(nèi)容。
這里我們那用戶登陸這個接口給大家演示它的Controller函數(shù)是怎么做單元測試的,它的單元測試如下。
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) // 不讓它在控制臺里輸出路由信息
g := gin.New()
router.RegisterRoutes(g)
// mock一個響應記錄器
w := httptest.NewRecorder()
// 讓server端處理mock請求并記錄返回的響應內(nèi)容
g.ServeHTTP(w, req)
Convey("Then the user will login successfully", func() {
So(w.Code, ShouldEqual, http.StatusOK)
// 檢驗響應內(nèi)容是否復合預期
var resp map[string]interface{}
json.Unmarshal([]byte(w.Body.String()), &resp)
respData := resp["data"].(map[string]interface{})
So(respData["access_token"], ShouldNotBeEmpty)
})
})
})
在這個單元測試中我們還是會用 goconvey來組織測試的行為路徑,用 gomonkey 給Controller函數(shù)調(diào)用的應用服務方法做打樁返回Mock結(jié)果,不然就跟用POSTMAN 請求接口一樣咧,那樣的話如果下層代碼里有數(shù)據(jù)庫CURD更新之類操作的話還是會去實際訪問數(shù)據(jù)庫的,這顯然不是我們想要的。
對于Controller方法的驗證主要聚焦于請求參數(shù)的驗證以及響應結(jié)果的驗證,因為 Controller 在我們項目的分層設計中就只干這兩件事。
總結(jié)
通過這幾節(jié)單元測試實戰(zhàn)的內(nèi)容大家應該能體會到,我們?yōu)轫椖孔龊梅謱釉O計的一個優(yōu)點--好測試。每個分層都有具體的職責,每塊代碼的邊界不至于過大,這樣我們做單元測試代碼寫起來會更簡單。如果把所有邏輯都耦合在Controller 函數(shù)那種代碼,寫單元測試的難度先不說,有效性也很難保證,因為測試的顆粒度太大必然導致很難測出代碼內(nèi)部的問題。