前面两节我们的单元测试主要集中在对项目基础设施层的代码进行单元测试,针对Dao数据操作层我们讲解了如何在不实际对项目数据库进行CURD的情况下使用了sqlmock的方式进行单元测试。而对于外部API对接层则是教会大家用gock实现无侵入的HTTP Mock,对有API请求的代码进行单元测试。
今天我们更进一步,从项目代码的基础设施层来到逻辑层和用户接口层。逻辑层的代码肯定更注重逻辑,所以我们在这里会引入goconvey 这个库实现,让它帮助我们实现BDD(行为驱动测试),goconvey支持树形结构方便构造各种场景,让我们能更容易地基于 goconvey 来组织的单测。本文大纲如下:

本文节选自我的专栏《Go项目搭建和整洁开发实战》内容无删减,扫码订阅专栏可解锁其他章节。

订阅后除了加入实战项目外,还可加入专属读者群一起学习。
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 函数那种代码,写单元测试的难度先不说,有效性也很难保证,因为测试的颗粒度太大必然导致很难测出代码内部的问题。
想了解怎么做好Go项目的代码分层设计,以及搭建出一个实用、适合自己的Go项目的基础框架,欢迎扫下方二维码订阅专栏。

《Go项目搭建和整洁开发实战》专栏分为五大部分,重点章节如下

第一部分介绍让框架变得好用的诸多实战技巧,比如通过自定义日志门面让项目日志更简单易用、支持自动记录请求的追踪信息和程序位置信息、通过自定义Error在实现Go error接口的同时支持给给错误添加错误链,方便追溯错误源头。
第二部分:讲解项目分层架构的设计和划分业务模块的方法和标准,让你以后无论遇到什么项目都能按这套标准自己划分出模块和逻辑分层。后面几个部分均是该部分所讲内容的实践。
第三部分:设计实现一个套支持多平台登录,Token泄露检测、同平台多设备登录互踢功能的用户认证体系,这套用户认证体系既可以在你未来开发产品时直接应用
第四部分:商城app C端接口功能的实现,强化分层架构实现的讲解,这里还会讲解用责任链、策略和模版等设计模式去解决订单结算促销、支付方式支付场景等多种多样的实际问题。
第五部分:单元测试、项目Docker镜像、K8s部署和服务保障相关的一些基础内容和注意事项。
扫描上方二维码或者访问 https://xiaobot.net/p/golang 即刻订阅
此外想更详细地了解专栏内容,咨询专栏优惠,都可以添加下面我的微信