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