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的可以退群了)。一共三种方案依次介绍

  1. 建立真实数据库,测试最方便。直接上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 时,需要及时补充我们的测试。这样,不断集成测试用例后,后续开发时,就真正只需要测试一下,就可以放心的提交上线。

not found!