单测定义
在计算机编程中, 单元测试(英语:Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。一个单元可以是单个程序、函数、过程、方法等。单元测试的主要优点是可以尽早发现代码中的问题,提高代码质量,同时也为代码重构提供了保障。然而,单元测试不能替代其他类型的测试,如集成测试和系统测试,因为单元测试只关注代码的局部,而不关注各个部分如何协同工作。
单测原则
测试原则「FIRST」
- Fast:单元测试运行应该足够快,以便我们可以频繁地运行它们。这样可以及时地发现代码中的问题,并且不会因为测试的运行时间过长而影响开发的效率。
- Isolated:每个单元测试都应该是独立的,不依赖于其他测试或外部环境。这样可以保证我们可以单独运行任何一个测试,而不需要担心其他因素对测试结果的影响。
- Repeatable:幂等,无论在什么环境下,我们都应该能够重复运行单元测试,并得到相同的结果。这意味着测试不应该依赖于具体的环境配置或外部数据。
- Self-Validating:单元测试应该能够自动判断测试结果,而不需要人工检查。也就是说,测试应该清楚地表明它是通过还是失败,而不应该产生模棱两可的结果。
- Timely:单元测试应该在编写或修改功能代码之前或同时编写。这可以帮助我们尽早发现并修复问题,提高代码质量。
测试边界原则
主要是指在编写测试用例时,需要特别关注输入、输出或结果的边界情况。这是因为在实际的软件开发中,很多错误和问题往往会发生在边界条件附近。
- 最小值和最大值:如果你的程序需要处理一定范围的输入,那么你应该测试该范围的最小值和最大值。例如,如果你的函数需要处理一个介于 1 到 100 之间的整数,你应该测试输入为 1 和 100 的情况。
- 边界值:除了最小值和最大值之外,你还应该测试刚好在边界上的值,以及刚好在边界附近的值。例如,如果你的函数需要处理一个大于 0 的整数,你应该测试输入为 1(刚好在边界上)和 2(刚好在边界附近)的情况。
- 异常值:你还应该测试一些不在预期输入范围内的值,以确保你的程序能够正确处理这些异常情况。例如,如果你的函数需要处理一个大于 0 的整数,你应该测试输入为 0 和负数的情况。
- 空值和非法值:如果你的程序需要处理可能为空或非法的输入,你应该测试这些情况。例如,如果你的函数需要处理一个可能为空的字符串,你应该测试输入为空字符串的情况。
写可测性高的代码
- 面向接口编程,不要直接依赖于实现
这样可以使得代码之间的依赖关系更清晰,也使得替换依赖变得更容易。例如,你可以将数据库或网络操作抽象为接口,然后在测试时使用模拟的实现替换真实的实现。
- 避免副作用
副作用是指函数除了返回值以外的其他影响,例如修改全局变量,修改参数,进行输入/输出操作等。副作用会使得函数的行为变得不可预测,因此应尽量避免。
- 避免全局变量的使用
全局状态会使得代码的行为变得不可预测,因为你无法控制何时、何地以及如何修改全局状态。更好的做法是使用参数将状态传递给函数,或者使用类的实例变量来保存状态。
常见误区
- 给每个函数写测试:单元测试应该测试功能单元,而非代码单元
- 单纯为了提高单测覆盖率而写单测:通常单测的关注点应该在边界情况
- 严重依赖Mock:使用mock进行测试应当想清楚是为了测试函数还是为了通过测试mock来测试函数。通常单个函数有大量mock也意味着函数的功能过多,需要拆分。
- 写永远不会失败的测试:单元测试的目的之一是为了当代码改动时可以进行回归测试。因此应多考虑如何让测试失败有助于测试内容的完整性
- 测试写的越多越好:如果单测改造成本过大,或者单测冗长不易懂,且测试之间重复多,那么则会无意义地拖慢迭代速度
- 在单测中有实际网络连接:单测尽量要保持执行的稳定性,即同样的case,在不做任何修改的情况下,能保证完成相同的返回。否则当某个case fail时,需要同时怀疑是下游的问题还是代码的问题。排查起来会更困难。
- 单测是黑盒测试:单测是一种白盒测试,在编写单测的时候,被测对象的内部实现是可以被完全感知的。但为了保证测试样例的健壮性,我们应该使用黑盒的视角来编写和组织样例。
单测框架
断言
单测中需要能自动判断其测试结果, 我们可以通过断言相关的框架来实现这一能力。在代码中,断言是一个预期结果为真的布尔表达式,如果该表达式的结果不为真(即为假),则程序将抛出一个错误。断言常用于检查代码中某个位置的某种条件是否满足。
常用的断言
arduino
convey.So(1, convey.ShouldEqual, 1)
// 断言slice、channel、map、string长度为0
convey.So([]int{}, convey.ShouldBeEmpty)
// 断言slice中包含指定元素
convey.So([]int{1}, convey.ShouldContain, 1)
// 断言map中包含指定key
convey.So(map[int]int{1: 2}, convey.ShouldContainKey, 1)
// 断言深度比较,比如两个对象,两个同序数组,等等
convey.So([]*Point{{}, {}}, convey.ShouldResemble, []*Point{{}, {}})
除了断言以外,使用 convey 可以用来管理多个测试 case 和生命周期管理。convey 结构相当于一个树结构, 执行过程为 codeA, return success; codeA return err。这就意味着, 可以用 code A 来初始化下面两个 case 的初始化逻辑, 且每次都会执行一次 code A。
go
func Add(x, y int) (int, error) {
sum := x + y
if (sum > x) == (y > 0) {
return sum, nil
}
return 0, errors.New("integer overflow")
}
func TestAdd(t *testing.T) {
convey.Convey("test ADD", t, func() {
// code A
convey.Convey("return success", func() {
sum, err := Add(1, 3)
convey.So(err, convey.ShouldBeNil)
convey.So(sum, convey.ShouldEqual, 4)
})
convey.Convey("return err", func() {
sum, err := Add(math.MaxInt64, 3)
convey.So(sum, convey.ShouldBeZeroValue)
convey.So(err, convey.ShouldBeError)
})
})
}
MOCK/STUB
为了保证单测的独立性,可以在任何环境运行。需要对外部依赖进行 mock 或打桩。
Mocking(模拟) :模拟是创建一个对象或服务的假实现,以便在测试中替代真实的实现。模拟对象会模拟真实对象的行为,你可以在模拟对象上设置期望值,比如预期某个方法被调用多少次、被调用时的参数是什么,以及该方法应该返回什么值。如果预期的调用没有发生或者参数、返回值与预期不符,测试就会失败。
Stubbing( 打桩 ) :打桩是为测试中的某些方法提供预定义的行为。与模拟不同,打桩不会检查方法是否被调用、被调用的次数或者参数,它只是简单地返回预定义的响应。打桩通常用于隔离被测试的代码,使其不直接依赖外部服务或者资源。
go 官方提供的 mock 工具包。使用 mock 时需要将外部依赖定义为接口, 通过此工具 mock 其实现, 从控制外部依赖的行为。下面的例子中, service 层会依赖仓储层, 而在进行 service 层测试时, 不应该依赖仓储层的具体实现。因此, 可以通过 mock 的方式控制其实现。
go
// repository 层实现
type User struct {
Name string
}
//go:generate mockgen -source=repository.go -package=unit -destination=mock_repository.go
type Repo interface {
GetUserByID(ctx context.Context, id int64) (User, error)
}
type userRepo struct{}
func (u *userRepo) GetUserByID(ctx context.Context, id int64) (User, error) {
// TODO db 查询
return User{}, nil
}
// service 层
type Service struct {
Repo Repo
}
func NewService(Repo Repo) *Service {
return &Service{
Repo: Repo,
}
}
func (s *Service) QueryUser(ctx context.Context, userID int64) (User, error) {
user, err := s.Repo.GetUserByID(ctx, userID)
// biz logic
return user, err
}
通过 mockgen 命令生成接口 mock 对象, 在写单测时就可以使用 mock 对象控制其依赖的行为。
less
func TestService_QueryUser(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
mockRepo := NewMockRepo(ctrl)
s := NewService(mockRepo)
id := int64(math.MaxInt64)
Convey("test QueryUser", t, func() {
Convey("should return err when query repo meet err", func() {
mockUser, mockErr := User{}, errors.New("mock err")
mockRepo.EXPECT().GetUserByID(ctx, id).Return(mockUser, mockErr)
_, err := s.QueryUser(ctx, id)
So(err, ShouldBeError, mockErr)
})
Convey("should return success", func() {
mockUser := User{Name: "name"}
mockRepo.EXPECT().GetUserByID(ctx, id).Return(mockUser, nil )
res, err := s.QueryUser(ctx, id)
So(err, ShouldBeNil)
So(res, ShouldEqual, User{Name: "name"})
})
})
}
使用 mock 时有一个前置条件就是对于外部的依赖必须是一个接口类型, 否则没法进行对其进行 mock。而 mockey 可以直接在编译时对依赖进行替换, 从而控制外部行为。 且此工具对外部依赖无要求, 可以直接进行 mock。还以上面的场景为例子, 只是全部使用函数的形式实现。
go
// 仓储
func GetUserByID(ctx context.Context, id int64) (User, error) {
// TODO db 查询
return User{}, nil
}
// service
func (s *Service) QueryUserV2(ctx context.Context, userID int64) (User, error) {
user, err := GetUserByID(ctx, userID)
// biz logic
return user, err
}
在测试 case 中,可以直接 mock 函数 GetuserByID
scss
// 运行 mockey 测试需要加 -gcflags=-l 避免因内联导致的编译替换异常
func TestService_QueryUserV2(t *testing.T) {
s := NewService(nil)
id := int64(math.MaxInt64)
ctx := context.Background()
PatchConvey("test Query User", t, func() {
PatchConvey("should return err when query repo meet err", func() {
mockUser, mockErr := User{}, errors.New("mock err")
// mock 其 return
Mock(GetUserByID).Return(mockUser, mockErr).Build()
_, err := s.QueryUserV2(ctx, id)
So(err, ShouldBeError, mockErr)
})
PatchConvey("should return success", func() {
mockUser := User{Name: "name"}
Mock(GetUserByID).When(
// 函数入参与 GetUserByID 相同, 返回值为 bool; 结果为 true 的时候返回对应的结果
func(ctx context.Context, id int64) bool {
return true
},
).Return(mockUser, nil).Build()
res, err := s.QueryUserV2(ctx, id)
So(err, ShouldBeNil)
So(res, ShouldEqual, User{Name: "name"})
})
})
}
// 官方例子
type A struct{}
func (a A) Foo(in string) string { return in }
var Bar = 0
func TestMockXXX(t *testing.T) {
PatchConvey("TestMockXXX", t, func() {
Mock(Foo).Return("c").Build() // mock函数
Mock(A.Foo).Return("c").Build() // mock方法
MockValue(&Bar).To(1) // mock变量
So(Foo("a"), ShouldEqual, "c") // 断言`Foo`成功mock
So(new(A).Foo("b"), ShouldEqual, "c") // 断言`A.Foo`成功mock
So(Bar, ShouldEqual, 1) // 断言`Bar`成功mock
})
// `PatchConvey`外自动释放mock
fmt.Println(Foo("a")) // a
fmt.Println(new(A).Foo("b")) // b
fmt.Println(Bar) // 0
}
业务上存储通常使用 sql 数据, 通过 sqlmock 可以验证 sql 语句的正确性, 同时 mock 预期的行为。
go
package main
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
func recordStats(db *sql.DB, userID, productID int64) (err error) {
tx, err := db.Begin()
if err != nil {
return
}
defer func() {
switch err {
case nil:
err = tx.Commit()
default:
tx.Rollback()
}
}()
if _, err = tx.Exec("UPDATE products SET views = views + 1"); err != nil {
return
}
if _, err = tx.Exec("INSERT INTO product_viewers (user_id, product_id) VALUES (?, ?)", userID, productID); err != nil {
return
}
return
}
func main() {
// @NOTE: the real connection is not required for tests
db, err := sql.Open("mysql", "root@/blog")
if err != nil {
panic(err)
}
defer db.Close()
if err = recordStats(db, 1 /*some user id*/, 5 /*some product id*/); err != nil {
panic(err)
}
}
scss
package main
import (
"fmt"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
// a successful case
func TestShouldUpdateStats(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()
mock.ExpectBegin()
mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO product_viewers").WithArgs(2, 3).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
// now we execute our method
if err = recordStats(db, 2, 3); err != nil {
t.Errorf("error was not expected while updating stats: %s", err)
}
// we make sure that all expectations were met
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
// a failing test case
func TestShouldRollbackStatUpdatesOnFailure(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()
mock.ExpectBegin()
mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO product_viewers").
WithArgs(2, 3).
WillReturnError(fmt.Errorf("some error"))
mock.ExpectRollback()
// now we execute our method
if err = recordStats(db, 2, 3); err == nil {
t.Errorf("was expecting an error, but there was none")
}
// we make sure that all expectations were met
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
与 sql 场景相同,对于 redis 依赖,我们可以在单元测试中启动一个 miniredis, 并 mock 其行为。
go
import (
"github.com/alicebob/miniredis/v2"
)
func TestSomething(t *testing.T) {
s := miniredis.RunT(t)
// Optionally set some keys your code expects:
s.Set("foo", "bar")
s.HSet("some", "other", "key")
// Run your code and see if it behaves.
// An example using the redigo library from "github.com/gomodule/redigo/redis":
c, err := redis.Dial("tcp", s.Addr())
_, err = c.Do("SET", "foo", "bar")
// Optionally check values in redis...
if got, err := s.Get("foo"); err != nil || got != "bar" {
t.Error("'foo' has the wrong value")
}
// ... or use a helper for that:
s.CheckGet(t, "foo", "bar")
// TTL and expiration:
s.Set("foo", "bar")
s.SetTTL("foo", 10*time.Second)
s.FastForward(11 * time.Second)
if s.Exists("foo") {
t.Fatal("'foo' should not have existed anymore")
}
}
覆盖率统计
在 Go 语言中,你可以使用内置的 go test
工具来生成和查看测试覆盖率。
- 生成覆盖率文件:
go test -coverprofile=coverage.out ./...
,对于一些初始化文件,对其编写测试意义不大,我们可以在统计的时候进行跳过。go test -v $(shell go list ./...| grep -v mock | grep -v biz/model) -gcflags=-l -coverprofile=coverage.out
- 输出函数级别覆盖率和整体覆盖率:
go tool cover -func=coverage.out
- 以 html 形式展示:
go tool cover -html=coverage.out