单元测试理论及实践

单元测试理论及实践

无论你处于什么位置,做任何事,都要有责任心。你写的是代码,也是信任!

要对自己负责!从需求分析、系统设计到功能开发;说的每句话,画的每张图,写的每行代码,都关系着你的面子,关系着你在别人心中的份量。在开发中如何高效高质量交付呢?
单元测试就是把控代码质量的金钥匙之一。

单元测试是整个测试体系中最底层的自动化测试,也是代价最小把控服务质量的方法。

从一个功能开始

在工作中,我发现大多数人没有写单元测试的习惯,或者没有正确的思维框架。因此,本文从模仿掘金的草稿功能说起,来谈谈单元测试之理论与实践参考。

简化的功能:

  1. 创建空草稿
  2. 更新草稿
  3. 查询草稿
  4. 更新状态
package service

import (
	"time"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

// Draft 草稿
type Draft struct {
	gorm.Model
	Title   string
	Content string
	Post    rune
}

var db *gorm.DB

func init() {
	var err error
	// 仅为了方便演示使用sqlite
	db, err = gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
	if err != nil {
		panic("failed to connect database")
	}
	db.AutoMigrate(&Draft{})
}

//CreateEmptyDraft 创建空草稿
func CreateEmptyDraft(id uint) error {
	dra := new(Draft)
	dra.ID = id
	dra.Post = 'N'
	//保存 draft
	result := db.Create(dra)
	if result.Error != nil {
		return result.Error
	}
	return nil
}

//UpdateDraft 更新草稿
func UpdateDraft(dra *Draft) error {
	dra.UpdatedAt = time.Now()
	// 更新 draft
	err := db.Save(dra).Error
	return err
}

// GetDraft 查询草稿
func GetDraft(id uint) (*Draft, error) {
	var dra Draft
	// 查询 draft
	err := db.First(&dra, id).Error
	if err != nil {
		return nil, err
	}
	return &dra, nil
}

// PostDraft 发表草稿状态
func PostDraft(id uint) error {
	var dra Draft
	err := db.First(&dra, id).Error
	if err != nil {
		return err
	}
	dra.Post = 'Y'
	err = db.Save(dra).Error
	return err
}

上面,我们在服务层定义了四个方法,分别模拟4个接口服务层实现。再来看下一个草稿的状态图:

单元测试理论及实践
在界面中点击写文章,就创建了一份草稿(CreateEmptyDraft),期间修改的过程中,JS会多次自动保存,也就是调用UpdateDraft接口,最后点击发布,调用发布逻辑,然后执行PostDraft,修改草稿状态为已发布。
那么,作为一名有逼格的程序员,于是你写了如下的单元测试。

import (
	"testing"

	"github.com/bwmarrin/snowflake"
	"github.com/stretchr/testify/assert"
)

var snode *snowflake.Node

func init() {
	var err error
	snode, err = snowflake.NewNode(1)
	if err != nil {
		panic(err)
	}
}

// 生成一个分布式唯一ID
func gid() uint {
	return uint(snode.Generate().Int64())
}

func TestDraft(t *testing.T) {
	// 测试 新建草稿
	id := gid()
	err := CreateEmptyDraft(id)
	if err != nil {
		t.Log(err)
	}
	t.Log(id)

	// 测试 更新草稿
	dra := &Draft{
		Content: "第一次更新",
	}
	dra.ID = id
	err = UpdateDraft(dra)
	if err != nil {
		t.Log(err)
	}
	// 测试 查询草稿
	got, err := GetDraft(id)
	if err != nil {
		t.Log(err)
	}
	t.Log(got)
	//  发表草稿状态
	err = PostDraft(id)
	if err != nil {
		t.Log(err)
	}
}

在终端输入go test -v并回车,没有错误显示,完美~

PASS
coverage: 85.2% of statements
ok  	examples/unittest/service	0.373s	coverage: 85.2% of statements

等等。这真是单元测试吗? 不,实际上它就是一个伪逻辑!。

逻辑自洽

自洽(self-consistent)就是按照自身的逻辑推演的话,自己可以证明自己至少不是矛盾或者错误的,这就是简单的自洽性。

我们回到单元测试中,为什么说它是伪逻辑!呢,那是因为它必须依赖你的主观判断。虽然你可以吹这个测试用例代码覆盖率超过80%,但是没有鸟用,你只验证了一件事情,就是它不报错。

那么正确的测试应该是如何的呢?

重点:相互证明

//case1. 新建草稿
id := gid()
err := CreateEmptyDraft(id)
assert.Nil(t, err)
// 验证
got, err := GetDraft(id)
assert.Nil(t, err)
assert.Equal(t, id, got.ID)
assert.Empty(t, got.Content) //内容为空
assert.Empty(t, got.Title)   //标题为空

// case2. 更新草稿
got.Title = "单元测试"
got.Content = "第一次更新"
err = UpdateDraft(got)
if err != nil {
        t.Log(err)
}
// 验证
got, err = GetDraft(id)
assert.Nil(t, err)
assert.Equal(t, id, got.ID)
assert.Equal(t, "单元测试", got.Title) //内容变更
assert.Equal(t, "第一次更新", got.Content) //内容变更

//case3. 发表草稿状态
got, err = GetDraft(id)
assert.Equal(t, 'N', got.Post)

err = PostDraft(id)
if err != nil {
        t.Log(err)
}
// 验证
got, err = GetDraft(id)
assert.Equal(t, 'Y', got.Post)

上面举了三个case,我们通过这四个接口互相验证,来校验开发的这些接口逻辑正确性,这就是一个自洽的过程。

空间对称

在物理学中,如果一个定律在任意空间测试时都可得到相同的结论,我们就可以说这个定律是满足空间对称性的,否则这个定律可能只适用特定环境。

我把这个理论引入单元测试中,也就是如果某个单元测试用例可以在不同的环境下运行得到同样的结果,那么这个测试用例就是空间对称的。

那么影响因素有那些呢?

1.环境因素

通常我们服务最常见的环境因素就是数据库、缓存等。在上面的例子中,我们依赖了数据库,这会导致你的测试用例需要配置数据库环境,比如你依赖的Mysql配置了admin:test123@tcp(172.16.211.18:3306) 这个地址,一但数据库账号密码被修改,测试用例就会执行失败。

2.外部依赖

外部依赖最常见的就是调用了其它服务的接口,或者三方Paas平台提供的服务,导致你的方法结果不可预测。

比如,在你的接口中依赖一个外部接口GET /user/:id,它返回用户信息姓名和年龄,你在dev环境下这个接口返回的是张三、38岁,写了个测试用例通过了。当你在执行发布stage环境时发现测试用例失败,导致CI/CD流程中断,你一看,原来这个接口在stage环境返回的用户信息是张三、38岁。

3.异步逻辑

在一个分布式系统中,通常都会存在同步或异步的操作。比如向MQ写了一条消息,或发了个Email。那么这种情况下,测试用例怎么写吗,你怎么保证确定是向MQ写了一条消息呢?

当出现如上的情况时,你就需要Mock技术。

Mock: 指对一切不可确认的逻辑或者对象,创建一个模拟,这个模拟就是一个定律,结果可预期。

现在mock的框架有很多,比如有针对http的mock服务,定义好接口之后,前端与后台的开发就可以并行,前端通过创建接口mock来调用,不依赖后端接口的完成。

单元测试理论及实践

4.mock示例

我们还是用上面的例子来说明,golang也有mock框架gomock,基于里式代换原则,我们对例子中的代码做出修改。

1、我们先定义出依赖层接口,project/dao/dao.go

package dao

import "gorm.io/gorm"

// Draft 草稿
type Draft struct {
	gorm.Model
	Title   string
	Content string
	Post    rune
}

// DraftDao DraftDao
type DraftDao interface {
	Create(*Draft) error
	Update(*Draft) error
	Get(uint) (*Draft, bool)
}

具体使用数据如mysql的实现就省略了~

2、安装mockgen

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

3、生成dao.go的mock对象,project/dao/dao_mock.go

mockgen --source dao.go -package dao -destination dao_mock.go

4.修改逻辑project/service/service.go

package service

import (
	"examples/unittest/dao"
	"fmt"

	"time"
)
//全局变量,仅为演示方便
var db dao.DraftDao 

//CreateEmptyDraft 创建空草稿
func CreateEmptyDraft(id uint) error {
	dra := new(dao.Draft)
	dra.ID = id
	dra.Post = 'N'
	//保存 dao.Draft
	err := db.Create(dra)
	if err != nil {
		return err
	}
	return nil
}

//UpdateDraft 更新草稿
func UpdateDraft(dra *dao.Draft) error {
	dra.UpdatedAt = time.Now()

	// 更新 dao.Draft
	err := db.Update(dra)
	return err
}

// GetDraft 查询草稿
func GetDraft(id uint) (*dao.Draft, error) {
	// 查询 dao.Draft
	dra, ok := db.Get(id)
	if !ok {
		return nil, fmt.Errorf("null")
	}
	return dra, nil
}

// PostDraft 发表草稿状态
func PostDraft(id uint) error {
	dra, ok := db.Get(id)
	if !ok {
		return fmt.Errorf("null")
	}
	dra.Post = 'Y'
	err := db.Update(dra)
	return err
}

5、测试用例修改project/service/service_test.go

ctrl := gomock.NewController(t)
defer ctrl.Finish()

id := gid()

ddao := dao.NewMockDraftDao(ctrl)
db = ddao //直接修改全局变量,仅为演示方便

var cache dao.Draft
// 定义Dao方法Create的mock返回
ddao.EXPECT().Create(gomock.Any()).DoAndReturn(func(dra *dao.Draft) error {
        assert.Equal(t, id, dra.ID)
        cache = *dra
        t.Log(cache)
        return nil
})
// 定义Dao方法Get的mock返回
ddao.EXPECT().Get(gomock.Eq(id)).Return(&cache, true)

err := CreateEmptyDraft(id)
assert.Nil(t, err)
// 验证case1
got, err := GetDraft(id)
assert.Nil(t, err)
assert.Equal(t, id, got.ID)
assert.Empty(t, got.Content) //新建草稿内容为空
assert.Empty(t, got.Title)   //新建草稿内容为空

我们以case1为例,修改上面的测试逻辑。可以看到,通过mock,我们摆脱了对数据库初始化。

时间对称

在物理学中,如果一个定律在任意时间测试时都可得到相同的结论,我们就可以说这个定律是满足时间对称性的。在单元测试中,则要考虑参数或逻辑不依赖时间!

比如,测试时使用的参数写死了一个时间,但是一个月之后再次执行这个测试用例失败!

可量化

测试覆盖率(test coverage)是单元测试中一个可量化的重要因素,它是衡量软件测试完整性的一个重要指标。越高的覆盖率,单元测试的样本就要越多,也就是时间成本越大。这需要你来平衡好之间的度,因为覆盖率(c)与时间(t)成本不是线性增长,而是指数增长曲线。

通常情况下,你在开发完成之后,编写的测试覆盖率可能不到70%,但是随着bug的发现,测试用例样本的增加,会慢慢增加,达到一个稳定值,只要业务上不对这个逻辑有大的变动,这个值就不会下降。

我们归纳下影响因素:

  1. 自身原因:比如代码逻辑没有分层,一个方法逻辑太多等
  2. 外部原因:比如网络错误、数据库异常等
  3. 测试样本:没有准备足够的测试样本

前面两个原因就不说了,我们来看看测试样本的例子:

func calculate(val int) int {
	if val < 10 {
		return val
	} else if val < 100 {
		return val * 2
	}
	return val * 3
}

func Test_calculate(t *testing.T) {
	type args struct {
		val int
	}
	tests := []struct {
		name string
		args args
		want int
	}{
		{"case1", args{val: 1}, 1},
		{"case2", args{val: 20}, 40},
		{"case3", args{val: 100}, 300},
		{"case4", args{val: 200}, 600},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := calculate(tt.args.val); got != tt.want {
				t.Errorf("calculate() = %v, want %v", got, tt.want)
			}
		})
	}
}

执行结果:

--- PASS: Test_calculate (0.00s)
    --- PASS: Test_calculate/case1 (0.00s)
    --- PASS: Test_calculate/case2 (0.00s)
    --- PASS: Test_calculate/case3 (0.00s)
    --- PASS: Test_calculate/case4 (0.00s)
PASS
coverage: 100.0% of statements
ok  	examples/unittest	0.596s	coverage: 100.0% of statements

上面只是一个简单的例子来说明问题,可以看到calculate这个方法,至少需要3个不同的样本才能覆盖完整的逻辑。

在真实环境中,如果少了一个样本覆盖,上线之后,可能就是一个巨坑~

感谢你观看到最后,如果对你有一点帮助,请不要吝啬你的点赞,感谢!


最后: 欢迎大家关注公众号:【 伤心的辣条 】,领取一份300页pdf文档的Python自动化测试工程师核心知识点总结!

公众号里大部分资料都是面试时面试官必问的知识点,也包括了很多测试行业常见知识,其中包括了有基础知识、Linux必备、Shell、互联网程序原理、Mysql数据库、抓包工具专题、接口测试工具、测试进阶-Python编程、Web自动化测试、APP自动化测试、接口自动化测试、测试高级持续集成、测试架构开发测试框架、性能测试、安全测试等。

如果你测试中有许多的困惑,那么我创建的软件测试技术交流群将会是你接触良师益友的有益社区,同行或许可以给你带来一些实际性的帮助与突破。Q群:902061117 你也想知道同行都在怎样致富吧!

如果对你有一点点帮助,各位的「点赞」就是小编创作的最大动力,我们下篇文章见!

单元测试理论及实践

好文推荐:

阿里小黑叹息:越来越多的年轻人从职场撤退了?

Python简单?先来40道基础面试题测试下

App公共测试用例梳理

从一名开发人员转做测试的一些感悟

上一篇:如何用纯 CSS 创作一个跳动的字母 i


下一篇:深入理解GOT表和PLT表