Go单元测试 | 青训营笔记

116 阅读5分钟

这是我参与「第五届青训营 」笔记创作活动的第2天

单元测试

单元测试,一个不断被强调,但又不断被忽略的话题,从一名码农成长为一名优秀的工程师,单元测试,是必不可少的技能。单元测试是在所有测试环节中最先完成的,在我们写代码的过程中就需要写单元测试,也可能在未写代码前先写测试。个人认为编码能力,代码评审和单元测试是一个研发同学必备的三大重要技能。

为什么要写单元测试?

质量

单元测试针对一个具体的函数或方法,排除其他模块的干扰,更容易发现问题

对于单个函数,更容易构造测试case,核心功能验证更加充分

单元测试促使研发者深入思考代码设计和编写逻辑,及时纠偏

以上都有利于写出更高质量的代码

效率

提高代码可读性,降低理解成本: 清晰的单元测试能够在不了解代码主要逻辑的情况下明确函数的功能以及关键输入输出。

降低调试成本:单元测试足够小,且case充分的情况下,会极大的缩短调试的时间,不需要再深入到函数内部一步步排查。

降低代码修改成本:在迭代开发过程中,难免会对以前的代码进行修改或者是重构,有个单元测试,做修改后能够及时验证是否对其他模块乃至整个系统有影响,及早发现问题,规避风险。同时只需要针对修改内容进行测试,研发效率相应提升。

缩短开发周期:尽早的发现bug,尽早修复,可以大大的缩短开发周期并且降低运维成本;

什么是单元测试?

单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类、超类、抽象类等中的方法。单元测试就是软件开发中对最小单位进行正确性检验的测试工作。

不同地方对单元测试有的定义可能会有所不同,但有一些基本共识:

  • 单元测试是比较底层的,关注代码的局部而不是整体。
  • 单元测试是开发人员在写代码时候写的。
  • 单元测试需要比其他测试模块先运行。

通常而言,一个单元测试的用例主要是用于判断在某个特定条件或者说某个特定场景下对于某个特定函数的一个行为。我们一般的感受是就是说针对一个函数需要构造不同的输入,然后验证这个函数的输出是否符合预期。这里要强调一点的是,对于输入的话有时候不仅仅是显示的,也有可能是隐式的,比如我们的函数可能没有任何的参数传入,但它有可能会读取我们的配置文件等其他数据源内容

在讲了单元测试的意义和基本概念后,以Go语言为例,看看实际如何进行单元测试。

先从一个简单的Test例子开始吧!

基础写法:

  • 文件格式: 以_test.go为后缀,源文件在执行go build时不会被构建成包的一部分,测试文件和函数的文件放在一个包下。
  • 函数格式:每个测试的函数都是以Test为函数名的前缀
  • 函数都必须导入testing包(Go 官方的testing包
func TestAbs(t *testing.T) {
    got := Abs(-1)
    if got != 1 {
        t.Errorf("Abs(-1) = %d; want 1", got)
    }
}

Mock

在写单测过程中,常会遇到有些不容易构造或者不容易获取的对象,这时候我们要创建一个虚拟的对象以便测试。

常用的Mock可以归纳为两类:

打桩Stub

代表框架:GoMonkey、monkey、GoStub

主要通过打补丁(Patch)的方式替换函数、方法、变量等等

	monkey.PatchInstanceMethod(reflect.TypeOf(personService), "GetPersonById", func(_ *PersonServiceImpl, _ string) (*Person, error) {
		return &Person{
			Name: "test_name",
		}, nil
	})

依赖注入+GoMock

这种方法要求业务代码必须以“依赖倒置”的方式实现。关于什么是“依赖倒置原则”,参考官方的定义:

  1. 高层模块不应该依赖底层模块,两个都应该依赖抽象。
  2. 抽象不应该依赖细节,细节应该依赖抽象。“抽象”是指接口(interface)或者抽象类(abstract class),“细节”指的是实现(struct或者class)。

举例来说,比如在业务层我们会定义一些接口,如获取用户信息:

type UserStorager interface {
	GetUserInfo(context.Context, *UserParam) (User, error) 
}

对于这些接口使用gomock进行模拟,通过mockgen -source=user.go -destination=user_mock.go 可自动生成接口应的mock实现

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

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

import (
	context "context"
	reflect "reflect"

	gomock "github.com/golang/mock/gomock"
	ibasic "icode.baidu.com/baidu/bfe-private-cloud/control-api/api-server/module/ibasic"
)

// MockUserStorager is a mock of UserStorager interface.
type MockUserStorager struct {
	ctrl     *gomock.Controller
	recorder *MockUserStoragerMockRecorder
}

// MockUserStoragerMockRecorder is the mock recorder for MockUserStorager.
type MockUserStoragerMockRecorder struct {
	mock *MockUserStorager
}

// NewMockUserStorager creates a new mock instance.
func NewMockUserStorager(ctrl *gomock.Controller) *MockUserStorager {
	mock := &MockUserStorager{ctrl: ctrl}
	mock.recorder = &MockUserStoragerMockRecorder{mock}
	return mock
}

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

// GetUserInfo mocks base method.
func (m *MockUserStorager) GetUserInfo(arg0 context.Context, arg1 *ibasic.UserParam) (ibasic.User, error) {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "GetUserInfo", arg0, arg1)
	ret0, _ := ret[0].(ibasic.User)
	ret1, _ := ret[1].(error)
	return ret0, ret1
}

// GetUserInfo indicates an expected call of GetUserInfo.
func (mr *MockUserStoragerMockRecorder) GetUserInfo(arg0, arg1 interface{}) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInfo", reflect.TypeOf((*MockUserStorager)(nil).GetUserInfo), arg0, arg1)
}

总结

通过对测试框架的调研分析和实践,个人感觉在写单元测试时,使用GoConvey + GoMock的方式比较高效。web项目涉及到访问数据库的,可以使用sqlmock测试数据库交互层面的代码逻辑。