单元测试 go test

Test 单元测试基础

setup 和 teardown

如果在同一个测试文件中,每一个测试用例运行前后的逻辑是相同的,一般会写在setup 和 teardown 函数中。 例如执行前需要实例化待测试的对象,如果这个对象比较复杂,很适合将这一部分逻辑提取出来;执行后,可能会做一些资源回收类的工作,例如关闭网络连接,释放文件等。标准库 testing 提供了这样的机制:

func setup() {
	fmt.Println("Before all tests")
}

func teardown() {
	fmt.Println("After all tests")
}

func Test1(t *testing.T) {
	fmt.Println("I'm test1")
}

func Test2(t *testing.T) {
	fmt.Println("I'm test2")
}

func TestMain(m *testing.M) {
	setup()
	code := m.Run()
	teardown()
	os.Exit(code)
}
  • 在这个测试文件中,包含有2个测试用例,Test1 和 Test2。
  • 如果测试文件中包含函数TestMain,那么生成的测试将调用 TestMain(m), 而不是直接运行测试。
  • 调用 m.Run() 触发所有测试用例的执行,并使用 os.Exit()处理返回的状态码,如果不为0,说明有用例失败。
  • 因此可以在调用 m.Run() 前后做一些额外的准备(setup)和回收(teardown)工作。

gomock库的使用

参考文章

Gomock 实战指南:提升 Go 代码测试质量 - (lixueduan.com)

Go Mock (gomock)简明教程 | 快速入门 | 极客兔兔 (geektutu.com)

背景

后端代码的业务逻辑会很复杂,通常都会用到关系数据库、缓存、文件I/O、第三方接口等。我们要测试自己写的一个function中逻辑是否对,一般会确定好外部依赖返回的值,我们要测定的是这些之外的代码逻辑(我们的业务逻辑)是否正确。因此,我们可以对数据库等这些进行模拟,也就是我们想从DB查询,其实不是真的DB,而是MockDB,我们自己预定好返回啥。这么做,我们就可以把一段逻辑中的各个依赖都梳理清楚,摘干净,测到我们自己的业务逻辑是否正确就可以了。

一个Demo来说明

首先要知道,我们进行mock,也就是模拟的都是一个接口,这个接口有我们具体的业务实现,而在单元测试的时候,我们用mock内容替代这个实现,返回我们想要的确定的值。

下面代码中, IUser是一个接口,它提供了Get方法返回User对象。

QueryUser函数,它用到了IUser接口,也就是有依赖。如果要测试QueryUser中的代码逻辑是否正常,我们无法避开对IUser的调用,但是我们其实不要测试IUser具体实现的对错,我们要有个mock,能模拟出IUser实现的正确操作就行。 在IUser的mock实现能正确执行的前提下,我们再看QueryUser的逻辑是否正确的。

type IUser interface {
	Get(id string) (User, error)
}

type User struct {
	Username string
	Password string
}

var ErrEmptyID = errors.New("id is empty")

func QueryUser(db IUser, id string) (User, error) {
	if id == "" {
		return User{}, ErrEmptyID
	}
	return db.Get(id)
}

有了gomock,就能非常方便的帮助我们做这件事。

通过运行下面指令,能生成对应的mock代码

mockgen -source=user.go -destination=user_mock.go -package mock i-go/test/mock IUser

代码文件 user_mock.go内容如下

// Code generated by MockGen. DO NOT EDIT.
// Source: user.go

// Package mock is a generated GoMock package.
package mock

import (
	reflect "reflect"

	gomock "github.com/golang/mock/gomock"
)

// MockIUser is a mock of IUser interface.
type MockIUser struct {
	ctrl     *gomock.Controller
	recorder *MockIUserMockRecorder
}

// MockIUserMockRecorder is the mock recorder for MockIUser.
type MockIUserMockRecorder struct {
	mock *MockIUser
}

// NewMockIUser creates a new mock instance.
func NewMockIUser(ctrl *gomock.Controller) *MockIUser {
	mock := &MockIUser{ctrl: ctrl}
	mock.recorder = &MockIUserMockRecorder{mock}
	return mock
}

// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockIUser) EXPECT() *MockIUserMockRecorder {
	return m.recorder
}

// Get mocks base method.
func (m *MockIUser) Get(id string) (User, error) {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "Get", id)
	ret0, _ := ret[0].(User)
	ret1, _ := ret[1].(error)
	return ret0, ret1
}

// Get indicates an expected call of Get.
func (mr *MockIUserMockRecorder) Get(id interface{}) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIUser)(nil).Get), id)
}

IUser接口是业务定义的,业务上肯定会有一个实现,比如用某个具体的数据库连接去操作数据库读取数据。为了模拟,gomock帮我们生成了 MockUser结构体,它实现了IUser接口。

我们要测试的是QueryUser函数,它依赖IUser接口,后面写单测代码的时候,它就用的MockUser这个实现,调用get的时候,实际就是MockUser的get。

下面是具体的写的test内容

func TestQueryUser(t *testing.T) {
	mockCtrl := gomock.NewController(t)
	defer mockCtrl.Finish()
	mockDB := NewMockIUser(mockCtrl)

	t.Run("empty id", func(t *testing.T) {
		mockDB.EXPECT().Get("").Return(User{}, ErrEmptyID).Times(0)
		user, err := QueryUser(mockDB, "")
		require.Equal(t, err, ErrEmptyID)
		require.Empty(t, user)
	})

	t.Run("normal id", func(t *testing.T) {
		targetUser := User{
			Username: "tom",
			Password: "pwd",
		}
		mockDB.EXPECT().Get("tom").Return(targetUser, nil).Times(1)
		user, err := QueryUser(mockDB, "tom")
		require.NoError(t, err)
		require.Equal(t, user, user)
	})
}

用gomock库的方法来创建一个控制器对象,这个是固定的,得到mockCtrl。

基于控制器,我们在创建实际的mock对象。

在我们写test的代码时,实际关注其实就是用mock对象来进行期望值断言,比如Get(""), 即当用户调用Get但是传参是空字符串时,期望的返回值就是 User{}、ErrEmptyID。

这个代码就是核心逻辑

mockDB.EXPECT().Get("").Return(User{}, ErrEmptyID).Times(0)

这里制定了Get方法的入参和返回值,就不需要真的去连接数据库了,起到了mock的效果。

gmock的知识点

1、3个核心概念

1)控制器(Controller): 控制器是 Gomock 的核心组件,用于创建和管理模拟对象。它可以跟踪模拟对象的方法调用,并验证预期的行为。

2)模拟对象(Mock Object):模拟对象是对实际对象的模拟,用于模拟外部依赖的交互。在Gomock中,通过在控制器适当创建模拟对象,我们可以定义模拟对象的行为和预期

3)预期行为(Expected Behavior):预期行为是在测试中对模拟对象方法的调用和返回值进行设定。通过设置预期行为,我们可以定义模拟对象应该如何被调用,以及在调用时应该返回什么结果。

简而言之,通过使用模拟对象和预期行为,我们可以模拟外部依赖的行为,使得测试更加独立和可控。

2、gomock的作用

1)消除外部依赖:在单元测试中,我们希望集中关注待测试的单元,而不是受其影响的外部依赖。通过使用Gomock,我们可以轻松创建模拟对象来替代真实的外部依赖,从而将被测单元与其依赖解耦。

2)简化测试设置和验证:Gomock提供了简洁的API,可以用来设置预期行为和验证模拟对象的调用。我们可以设定模拟对象的方法应该被调用多少次、以及调用时应该返回什么结果。然后,通过调用控制器的验证方法,我们可以确保模拟对象的调用符合预期。

3)提高测试代码的可读性和可维护性:使用Gomock,我们可以编写更简洁、更可读的测试代码。通过定义模拟对象的预期行为,测试人员可以清晰地了解待测试单元的行为和逻辑,而无需深入研究外部依赖的实现细节。

3、使用步骤

1)安装

使用以下命令安装 gomock 库以及 mockgen 工具

go get -u github.com/golang/mock/gomock
go install github.com/golang/mock/mockgen

检查是否安装成功

$ mockgen -version
1.6.0

2)生成mock代码

使用mockgen工具,根据接口(go 里的interface)生成mock代码。

命令如下:

# 语法: mockgen [-flag] path interface
mockgen -source=user.go -destination=user_mock.go -package mock

各个参数具体含义:

  • -source: 源文件名字, 也就是我们业务逻辑代码,里面有go的interface定义代码。
  • -destination:生成的mock文件名,可以指定路径,默认在当前目录。
  • -package:生成的mock文件中的go package

3)注入具体逻辑

由于mock工具是不知道我们的具体逻辑的,因此需要在使用的时候通过指定具体的请求参数以及该参数对应的响应来注入具体的逻辑。

具体做法,就是在写test方法的时候,使用 EXPECT 方法往mock代码里注入具体逻辑就好了。

例如

mockDB.EXPECT().Get("").Return(User{}, ErrEmptyID).Times(0)

编写可mock的代码

设计可测试的代码结构

  • 将代码分解为小而可测试的单元:将代码分解为更小的模块或函数,每个单元负责完成一个清晰的任务。这样可以提高代码的可测试性,使单元测试更加精确和可靠。
  • 遵循单一职责原则:确保每个单元只关注特定的功能或行为,避免将多个责任耦合在一起。这样可以使单元测试更加独立和可维护。

依赖注入和接口设计策略

  • 使用接口来定义依赖关系: 通过定义接口,我们可以明确模块之间的依赖关系,并使其可替换。这为后续使用模拟对象进行测试提供了便利。
  • 使用依赖注入来传递模拟对象:通过依赖注入的方式,我们可以将模拟传递给测试的代码。这样,我们可以在测试中传递模拟对象,而在实际生产环境中传递真实的依赖对象。

实战

gin框架的中间件怎么测?

在gin框架中,web请求的统一拦截器都是用它定义的middleware来实现。如下我们实现一个简单的拦截器,用于获取token并验证,具体逻辑省略。实现非常简单的逻辑,token有就通过,token没用就返回401无权限的状态码。

创建文件 token_middleware.go

package middleware

import (
	"net/http"
	"github.com/gin-gonic/gin"
)

func TokenMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		token := c.Request.Header.Get(Authorization)

		// 验证token等业务逻辑 ...

		if token == "" {
			c.Abort()
			c.JSON(http.StatusUnauthorized, "StatusUnauthorized")
			return
		}

		c.Next()
	}
}

上面的函数想要写单元测试用例,得需要创建一个gin的web服务,随便定义一个路由使用这个TokenMiddleware()做为拦截器。

在用http包创建一个请求服务,让我们的web服务对这个请求进行响应,从而触发拦截器的代码。

创建文件 token_middleware_test.go

如下内容中,我们创建一个gin服务,支持一个路由 /test, 使用了中间件 TokenMiddleware()。

下面写了两个子测试用例,一个token为空的请求,一个有token的请求。

使用http.NewRequest()构建一个http请求对象,在使用gin的ServeHTTP功能对这个请求进行处理,再查看处理结果。

package middleware_test

import (
	"fmt"
	"myapp/middleware"
	"io/ioutil"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
)

func TestMiddleware_TokenMiddleware(t *testing.T) {
	// 创建一个新的gin引擎
	r := gin.New()

	// 使用中间件
	r.Use(middleware.TokenMiddleware())
	// 定义一个路由
	r.GET("/test", func(c *gin.Context) {
		c.JSON(200, "hello, world")
	})

	t.Run("wrong token", func(t *testing.T) {
		req, _ := http.NewRequest("GET", "/test", nil)
		req.Header.Set("Authorization", "")

		w := httptest.NewRecorder()
		r.ServeHTTP(w, req)

		body, _ := ioutil.ReadAll(w.Body)
		fmt.Println(string(body))

		assert.Equal(t, w.Code, http.StatusUnauthorized)
	})

	t.Run("right token", func(t *testing.T) {
		req, _ := http.NewRequest("GET", "/test", nil)
		req.Header.Set("Authorization", "xxxxxx")

		w := httptest.NewRecorder()
		r.ServeHTTP(w, req)

		body, _ := ioutil.ReadAll(w.Body)
		fmt.Println(string(body))

		assert.Equal(t, w.Code, http.StatusOK)
	})
}

gin框架的中间件有依赖怎么测?

如果上面的中间件TokenMiddleware()实际使用时,依赖外部接口,我们要写单测,就需要写对依赖的接口进行mock。

1、构建grpc服务

比如我们依赖一个叫做 user_center 的grpc服务,会用此服务进行真正的token验证。

对应的proto文件如下 user_center.proto

syntax = "proto3";

package myapp;
option go_package = "./";

service UserCenterService {
    rpc Identify(UserToken) returns (TokenRes) {}
}

message UserToken {
    string value = 1;
}

message TokenRes {
    string id    = 1;
    string name = 2;
}

我们使用protoc-gen-go 工具生成文件 user_center.pb.go

protoc --go_out=. user_center.proto

我们再用 protoc-gen-go-grpc 工具生成文件 user_center_grpc.pb.go

protoc --go-grpc_out=. user_center.proto

在user_center_grpc.pb.go文件中,我们看下下面的代码,当别的微服务想要通过grpc调用这个user_center服务的时候,就需要使用下面的接口 UserCenterServiceClient 。一般是需要实现这个接口。

type UserCenterServiceClient interface {
	Identify(ctx context.Context, in *UserToken, opts ...grpc.CallOption) (*TokenRes, error)
}

2、UserCenterServiceClient接口

我们实现的TokenMiddleware中间件,它验证token的时候,是调用userCenter服务,因此它依赖UserCenterServiceClient接口。如下,通过依赖注入的方式,可以看到我们的函数依赖了接口。

package middleware

import (
	"fmt"
	"myapp"
	"net/http"

	"github.com/gin-gonic/gin"
)

func TokenMiddleware(client myapp.UserCenterServiceClient) gin.HandlerFunc {
	return func(c *gin.Context) {
		token := c.Request.Header.Get(Authorization)

		// 验证token等业务逻辑 ...
		tokenRes, err := client.Identify(c.Request.Context(), &opencar.UserToken{
			Value: token,
		})

		fmt.Println(tokenRes)

		if err != nil {
			c.Abort()
			c.JSON(http.StatusUnauthorized, "StatusUnauthorized")
			return
		}

		c.Next()
	}
}

3、实现mock

我们们要写单测测试函数TokenMiddleware(), 我们是不用测试UserCenterServiceClient接口的功能的,因此对于它,我们要构建一个mock的实现即可。

mockgen -source=user_center_grpc.pb.go -destination=user_center_grpc_mock.pb.go -package myapp

这样我们就创建了一个文件 user_center_grpc_mock.pb.go

它里面有结构体实现了上面的接口

// MockUserCenterServiceClient is a mock of UserCenterServiceClient interface.
type MockUserCenterServiceClient struct {
	ctrl     *gomock.Controller
	recorder *MockUserCenterServiceClientMockRecorder
}

4、单测中使用mock

我们们要写单测测试函数TokenMiddleware(), 函数中依赖的UserCenterServiceClient接口,用我们的MockUserCenterServiceClient来替代.

通过EXPECT().Identify() 定义出使用某些参数情况下,该如何返回内容。

比如下面代码中,如果传UserToken.token是空,则grpc的mock功能返回error。 如果UserToken.Value是“token”,则grpc的mock功能返回error是nil。

package middleware_test

import (
	"errors"
	"fmt"
	"taiwu/myapp"
	"greatwall/opencar/coffeeapp/app/middleware"
	"io/ioutil"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/golang/mock/gomock"
	"github.com/stretchr/testify/assert"
)

func TestMiddleware_TokenMiddleware(t *testing.T) {
	// 创建mock对象
	mockCtl := gomock.NewController(t)
	defer mockCtl.Finish()
	mockClient := myapp.NewMockUserCenterServiceClient(mockCtl)

	// 创建一个新的gin引擎
	r := gin.New()
	// 使用中间件
	r.Use(middleware.TokenMiddleware(mockClient))
	// 定义一个路由
	r.GET("/test", func(c *gin.Context) {
		c.JSON(200, "hello, world")
	})

	t.Run("wrong token", func(t *testing.T) {
		// 定义可能的返回值
		mockClient.EXPECT().Identify(gomock.Any(), &myapp.UserToken{Value: ""}, gomock.Any()).Return(&myapp.TokenRes{}, errors.New("token不正确"))

		req, _ := http.NewRequest("GET", "/test", nil)
		req.Header.Set("Authorization", "")

		w := httptest.NewRecorder()
		r.ServeHTTP(w, req)

		body, _ := ioutil.ReadAll(w.Body)
		fmt.Println(string(body))

		assert.Equal(t, w.Code, http.StatusUnauthorized)
	})

	t.Run("right token", func(t *testing.T) {
		// 定义可能的返回值
		mockClient.EXPECT().Identify(gomock.Any(), &myapp.UserToken{Value: "token"}, gomock.Any()).Return(&myapp.TokenRes{}, nil)

		req, _ := http.NewRequest("GET", "/test", nil)
		req.Header.Set("Authorization", "token")

		w := httptest.NewRecorder()
		r.ServeHTTP(w, req)

		body, _ := ioutil.ReadAll(w.Body)
		fmt.Println(string(body))

		assert.Equal(t, w.Code, http.StatusOK)
	})
}

你可能感兴趣的:(golang,开发语言,后端)