Go 实际项目测试实践
测试的重要性不言而喻。然而在项目中,真正做到编写完整的测试却是少之又少。
主要原因推测有三个:
- 觉得写测试太麻烦,花费时间。其实我觉得这是个伪命题,因为通常写完一段代码,我们肯定会测试该代码能否正确执行,比如HTTP请求,数据库请求,RPC请求,逻辑函数。我们都会进行访问测试,这其实就是测试用例了,只是没有集成到测试框架中。其实,我们完全可以将这部分测试写成代码放到测试中。
- 代码不可测试。这其实很常见,大部分我们都是接手已有的代码。如果代码没有好的架构设计,统一的分格,强制的CR。那么写出不可测试的代码也就司空见惯了。对于这种代码,只能进行逐步重构了。
- 不知道怎么测试。项目中的代码一般都会有很多的依赖,而测试中去独立部署这些服务肯定成本巨大,因此如何处理这些依赖是建立测试的最大障碍。下文中我也会尝试介绍这部分的处理手段。
一般的测试分为单元测试和集成测试,但是这里我们会模糊这种界限,不做具体区分。下面出现的代码都是为了这篇文章而写的伪代码,没有考虑架构之类的。
项目基本介绍
为了结合项目介绍测试的方法,在这里,我简单实现了一个基本的业务逻辑框架。分为API层、Service层,基础依赖层有数据库(也就是DAO层)、缓存层(一般也就是Redis)、RPC等。简单代码函数如下:
// Service 为业务领域。经常变动大,因此不做接口
type PostService struct{
// Service 通过属性保存用到的基础依赖,通过依赖注入进行解藕,一能灵活改变扩展,二也方便测试MOCK
// PostDAO 和 RedisClient 皆为接口,变动会很小。
postDAO *PostDAO // 数据库依赖,后面会细讲
cacheClient *CacheClient // 缓存层依赖,后面会细讲
}
// 依赖注入,也叫控制反转,
func NewPostService(postDAO *PostDAO, redisClient *RedisClient) {
return &PostService{
postDAO: postDAO,
cacheClient: cacheClient,
}
}
// 具体的业务接口
func (post *PostService)GetAllPost() {
... 业务逻辑
post.postDAO.FindPosts()
....
}
// API层(也可叫Controller层)
type Server struct{
// go 中 http服务器对象
ln net.Listener
server *http.Server
// 对应的 Service 逻辑,启动应用时实例化
postService *PostService
}
func NewServer(ln net.Listener, server *http.Server ) {
return &Server{
ln: ln,
server: server,
}
}
// 依赖注入 PostService
func (server *Server)SetPostService(postDAO *PostDAO, redisClient *RedisClient) {
server.postService = NewPostService(postDAO, redisClient)
}
// API
func (server *Server)GetPosts(w http.ResponseWriter, r *http.Request) {
... 请求参数检查
server.postService.GetAllPost()
... 返回响应信息
}
上面的设计算是一种 领域驱动设计(DDD)(其实和MVC等传统的分层差不了太多),业务逻辑层和所有的其他层进行了解藕,更换底层的数据库,缓存层时,不需要改变业务逻辑代码。常见的架构拆分有模块拆分 和分层拆分,这里不做扩展,简单提供个小经验。大项目可以模块拆分,小项目分层拆分就行,另外由于Go的循环引用限制,大多数情况下分层拆分更好,具体可看 翻译Packages as layers, not groups。
上述业务架构机会适合大部分项目,根据代码我们可以将测试分为三部分:一是API层测试;二是Service测试;三是依赖层测试。
虽然访问是从API到依赖层,但是测试中首先要讲解的便是依赖层。
基础依赖层测试
基础依赖层即好测试又不好测试,好测试是应为依赖层不会在依赖其他层,同时代码变动不大,开发完成几乎不会变动。不好测试是因为基础依赖机会都需要开启另一个服务,比如MySQL,Redis同时还的进行初始化。
DAO 数据库依赖层测试
测试的基础是DAO层需要解藕,也就是得建立对应的接口文件(由于基层依赖层变动少,因此接口必须仔细设计好,翻译Common CRUD Design in Go可以参考),同时进行依赖反转。虽然在Go中不通过接口也能去直接MOCK对应函数,但是极不推荐,本文也不会做详细说明。
DAO层主要是进行数据库中的表格增删改查的操作。下面实现上述代码的PostDAO接口。
// 接口文件
type PostDAO interface {
FindPostByID(ctx context.Context, id int) (*Post, error)
FindPosts(ctx context.Context, filter PostFilter) ([]*Post, int, error)
CreatePost(ctx context.Context, post *Post) error
UpdatePost(ctx context.Context, id int, upd PostUpdate) (*Post, error)
DeletePost(ctx context.Context, id int) error
}
// 对应的实现
type PostMysql struct{
db *sql.DB
}
// 依赖注入 sql.DB
func NewPostMysql(db *sql.DB){
return &PostMysql{
db: db,
}
}
func (s *PostMysql) FindPostByID(ctx context.Context, id int) (*Post, error){
...
}
func (s *PostMysql) FindPosts(ctx context.Context, filter PostFilter) ([]*Post, int, error){
s.db.Exec("...",...)
}
func (s *PostMysql) CreatePost(ctx context.Context, post *Post) error{
...
}
func (s *PostMysql) UpdatePost(ctx context.Context, id int, upd PostUpdate) (*Post, error){
...
}
func (s *PostMysql) DeletePost(ctx context.Context, id int) error
{
...
}
DAO层测试有一个重要的问题值得讨论:应不应该建一个数据库进行测试。建立数据库能最好的模拟真实环境,但是成本巨大。不建立进行Mock(这里的Mock是只mock掉DB数据库,而不是DAO这层)的话,也有问题,一是未在真实环境中进行测试,难免会有问题,而是mock掉一个数据成本不必建立一个数据库大。在这里,我是推荐建立真实数据库的,传统建立数据库麻烦,但是幸好,我们又Docker 和 docker-compose (还不知道docker的可以退群了)。一共三种方案依次介绍
- 建立真实数据库,测试最方便。直接上
docker-compose.yml
代码。通过docker-compose 我们可以一条命令启动我们的测试环境,并且用完就可以删除容器。完美无害
测试代码version: "3.9" services: mysql: networks: - mysql_net image: mysql:5.7.29 restart: always command: --default-authentication-plugin=mysql_native_password environment: MYSQL_ROOT_PASSWORD: test123 ports: - 3306:3306 volumes: # mysql 初始化脚本目录,可以将init.sql放入该目录,容器初始化时会自动执行。 - "./docker/init_sql:/docker-entrypoint-initdb.d/" # 查看mysql 工具,网页访问 8080 可查看数据库内容。 adminer: networks: - mysql_net image: adminer restart: always ports: - 8080:8080 networks: mysql_net: name: mysql_net driver: bridge
func TestPostMysql(t *testing.T) {
// 连接 docker 数据库
conn_str := "root:test123@tcp(localhost:3306)/dataname?autocommit=1&timeout=200ms&readTimeout=2s&writeTimeout=2s"
db, err := sql.Open("mysql",conn_str)
if err != nil {
log.Fatal(err)
}
defer db.Close()
postMysql := NewPostMysql(db)
// 使用了Convey 框架
convey.Convey("PostMysql 测试",t, func() {
post := &Post{
...
}
convey.Convey("添加->查找->删除测试", func() {
convey.Convey("添加数据", func() {
postNew := post
err := postMysql.CreatePost(context.Background(), postNew)
convey.So(err, convey.ShouldEqual, nil)
convey.Convey("查找", func() {
res, err :=postMysql.FindPostByID(context.Background(),postNew.PostId)
convey.So(err,convey.ShouldEqual,nil)
convey.So(*res,convey.ShouldResemble,postNew)
convey.Convey("删除", func() {
err = postMysql.DeletePost(context.Background(),postNew.PostId)
convey.So(err, convey.ShouldEqual, nil)
convey.Convey("再查找试试", func() {
_, err :=postMysql.FindPostByID(context.Background(),postNew.PostId)
convey.So(err,convey.ShouldBeError)
})
})
})
})
})
}
测试代码例子, docker-compose.yml
都给出来了,相信大家都能看懂,也就不多说了。
2. 第二种方法是用内存数据库,在Java中有h2内存数据库,不见建立第三方服务,且兼容大部分Mysql语法。在Go里面,由于 database/sql
是一个接口层,因此,我们可以采用Sqlite的驱动实例化一个sql.DB
出来,但是还是会有兼容问题,因此极不推荐。使用在上述代码中改个驱动就行了,也就不详细说明了。
3. 第三种方法是Go独有的,也是因为database/sql
是一个接口层,有一个包go-sqlmock
,其实和第二种也是一样的,只是需要自己模拟输入输出,具体可以看官网用法。也不推荐。
缓存层、RPC层等基础依赖
在应用中缓存层是少不了的。现在的缓存大部分都是Redis,类似一个内存数据库,因此测试方法和上述一样,推荐在Docker中真实的建立一个Redis服务,如果实在不能建立,可以考虑使用 miniredis项目,和上述DAO中的第二种方法类似,模拟了一个小的Redis,兼容大部分常见命令。
在这里值得说的是RPC等依赖。在微服务中,一个服务调用多个服务是很常见的情况。而这些服务我们又不可能在本地建立一个真实的环境。因此只能想办法去Mock掉。方法也很简单,就是根据服务提供的API抽象出一个接口文件,然后使用适配器模式或代理模式进行Wrap一层。
Service层测试
Sevice层通常是业务逻辑的地方,这里的测试容易与否主要看基础依赖层和Service是否完全解藕,如果解藕,Service层的测试将会变得非常容易。
这里也有一个问题,是否要对基础依赖层进行Mock,有人可能问,不Mock的话直接设计接口这一套是干嘛呢,不如直接搭建环境。这里的主要不Mock的原因是我们想尽量真实的模拟线上环境。而且有些基础设计是只能Mock的。同时,这里可以不Mock的另一个前提是,我们的解藕做的很好,同时基层依赖层的测试也做的很好。不然的话我们只能进行Mock了。
常用的Mock工具是 go-mock
具体用法不多介绍了,可以直接看文档。我简单说下,我的做法,由于解藕做的很好,所以我的测试实现中会自动判断是否数据库能连接成功,如何可以的话用数据库实例的基础依赖层测试,否则用go-mock
生成的基础依赖层进行测试。具体代码如下
func TestGetAllPost(t1 *testing.T) {
postDAO,issql:= GetPostDAO(t1)
if !issql {
... mock 配置
}
// redisClient 的获取方法一样,不写代码
edisClient,isTrue:= GetRedisClient(t)
if !isTrue {
... mock 初使化
}
postService := NewPostService(postDAO, redisClient)
Convey("TestGetAllPost", t1, func() {
postServie.GetAllPost()
...
})
}
func GetPostDAO(t1 *testing.T) (PostDao,bool){
conn_str := "root:test123@tcp(localhost:3306)/rule?autocommit=1&timeout=200ms&readTimeout=2s&writeTimeout=2s"
db, err := sql.Open("mysql",conn_str)
if err != nil {
log.Fatal(err)
}
if err := conn.Ping(); err != nil {
return nil,false
}
if falg {
t1.Log("数据库开启,使用数据库进行测试")
return NewPostMysql(db),true
}else {
t1.Log("数据库未开启,使用MOCK DAO层测试")
ctl := gomock.NewController(t1)
defer ctl.Finish()
PostDaoMock := mock.NewMockPostDao(ctl)
//TemplateId := 123456
//MustMockCreateTemplate(templateDaoMock,TemplateId)
return PostDao,false
}
}
API层测试
API层测试可以说是集成测试了,一般开发人员开发后,测试人员主要会对这部分接口进行测试。而且这部分测试一般都需要建立HTTP请求,因此耗时费力,开发人员大部分只会进行简单的访问看看能够跑通。然而Go给我们提供了方便的http测试工具,使用这些工具不但能方便测试,还能保存复用,不必每次在浏览器测试。下面代码举个例子。
func setupServerPostHandler(t *testing.T) *gin.Engine {
engine := gin.New()
//engine.Use(middleware.Logger())
engine.Use(gin.Recovery())
PostDaoMock,issql:= GetPostDAO(t)
if !issql {
... mock 初使化
}
RedisClient,isTrue:= GetRedisClient(t)
if !isTrue {
... mock 初使化
}
server := NewServer()
// 唯一依赖
server.SetPostService(PostDaoMock,RedisClient)
engine.POST("/AddPost", server.GetPosts)
return engine
}
func TestPostHandler(t *testing.T) {
router := setupServerPostHandler(t)
Convey("Post Handler接口测试",t, func() {
req_content := &Post{
... 内容
}
type Data_resp struct {
Post_id int `json:Post_id`
}
type resp_json struct {
Data Data_resp `json:data`
Err_msg string `json:err_msg`
Err_no int `json:err_no`
}
req_content.Type = "AddPost"
Convey("AddPost 测试", func() {
Convey("AddPost 测试1", func() {
req_new := req_content
req_string, _ := json.Marshal(req_new)
req := httptest.NewRequest(http.MethodPost, "/AddPost", strings.NewReader(string(req_string)))
req.Header[global.HEADER_TRACEID] = []string{"testTrace"}
req.Header[global.HEADER_SPANID] = []string{"testSpan"}
req.Header[global.HEADER_USER] = []string{"testUser"}
req.Header.Set("Content-Type","application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
resp := w.Result()
resp_json1 := &resp_json{}
_ = json.Unmarshal(w.Body.Bytes(), resp_json1)
So(resp_json1.Data.Post_id,ShouldHaveSameTypeAs,1)
So(resp.StatusCode,ShouldEqual,http.StatusOK)
})
})
})
}
相信代码已经足够清晰,因此不在赘述。
一些测试问题
多个函数调用如何写测试
通常函数直接是相互调用的,比如API层调用Service层,接着调用基础服务层。一个请求在多个函数中经过。这种情况各个测试的侧重点如何写呢。
有个简单做法,各自函数着重测试自己的实现逻辑。比如一般在API层,我们会进行参数检测,那在这一步我们就着重测试参数检测的测试用例。而在Service,此时我们只需要提供正确的参数就行,不必在提供错误的请求参数。而在基础依赖层,我们也就不必测试ID小于0,或参数类型,范围的错误用例了。
有状态函数如何测试
什么叫有状态函数,这里简单顶一下,函数中出现的可变变量不是由参数传递的。比如函数中如果用了全局变量,类的中局部属性,时间函数,随机数生成器,那么这些就都是有状态的,在不同时间、环境下,相同的输入会有不同的输出。当然也有一些情况例外,比如上述中DAO中的sql.DB一般初始化后就不会改变,这种就不算有状态函数了。
这些函数在测试之前,我们必须固定其中的参数,常见的语言中,你必须将这些方法单独提取出来,进行封装。然而在Go中,你可以使用monkey
包直接在运行时替换掉原函数。该库为github.com/agiledragon/gomonkey
,具体用法可以查看官方文档。
无法mock的依赖
一些项目由于历史原因,无法解藕,这部分代码,如果非要测试,也只有使用上述的monkey
直接替换原函数。
总结
给一个函数写测试其实并不难,但是给一个大项目写测试需要一定的架构设计能力,首先的理解项目的架构,然后才能围绕项目设计一个测试的架构。大部分人化时间去学习MVC,分层等架构,但是很少会有些去学习测试框架的架构。而者也是大部分人面对一个项目不知道如何去写测试的主要原因。希望这篇文章能给大家一点收获。
另外,测试也是不断集成的,我们不可能使用测试一下子就测试到所有情况,因此在线上环境发现 bug 时,需要及时补充我们的测试。这样,不断集成测试用例后,后续开发时,就真正只需要测试一下,就可以放心的提交上线。