聊一聊 go 中的单元测试

单测定义

在计算机编程中, 单元测试(英语: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 工具来生成和查看测试覆盖率。

  1. 生成覆盖率文件: go test -coverprofile=coverage.out ./... ,对于一些初始化文件,对其编写测试意义不大,我们可以在统计的时候进行跳过。go test -v $(shell go list ./...| grep -v mock | grep -v biz/model) -gcflags=-l -coverprofile=coverage.out
  2. 输出函数级别覆盖率和整体覆盖率: go tool cover -func=coverage.out
  3. 以 html 形式展示:go tool cover -html=coverage.out
相关推荐
喜欢打篮球的普通人15 分钟前
rust高级特征
开发语言·后端·rust
Ling_suu31 分钟前
Spring——单元测试
java·spring·单元测试
代码小鑫1 小时前
A032-基于Spring Boot的健康医院门诊在线挂号系统
java·开发语言·spring boot·后端·spring·毕业设计
豌豆花下猫1 小时前
REST API 已经 25 岁了:它是如何形成的,将来可能会怎样?
后端·python·ai
喔喔咿哈哈2 小时前
【手撕 Spring】 -- Bean 的创建以及获取
java·后端·spring·面试·开源·github
夏微凉.2 小时前
【JavaEE进阶】Spring AOP 原理
java·spring boot·后端·spring·java-ee·maven
彭亚川Allen2 小时前
数据冷热分离+归档-亿级表优化
后端·性能优化·架构
Goboy2 小时前
Spring Boot 和 Hadoop 3.3.6 的 MapReduce 实战:日志分析平台
java·后端·架构
不会编程的懒洋洋3 小时前
Spring Cloud Eureka 服务注册与发现
java·笔记·后端·学习·spring·spring cloud·eureka
NiNg_1_2344 小时前
SpringSecurity入门
后端·spring·springboot·springsecurity