聊一聊 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
相关推荐
无心水20 分钟前
【文档解析】4、跨平台文档解析:JS/Go/C#全攻略
javascript·后端·golang·c#·架构师·大数据分析·分布式系统利器
清汤饺子26 分钟前
用了大半年 Claude Code,我总结了 16 个实用技巧
前端·javascript·后端
ん贤3 小时前
Go channel 深入解析
开发语言·后端·golang
changhong19866 小时前
如何在 Spring Boot 中配置数据库?
数据库·spring boot·后端
月月玩代码9 小时前
Actuator,Spring Boot应用监控与管理端点!
java·spring boot·后端
feng一样的男子9 小时前
NFS 扩展属性 (xattr) 提示操作不支持解决方案
linux·go
XPoet9 小时前
AI 编程工程化:Skill——给你的 AI 员工装上技能包
前端·后端·ai编程
码事漫谈10 小时前
从“功能实现”到“深度优化”:金仓数据库连接条件下推技术的演进之路
后端
码事漫谈10 小时前
数据库查询优化中的谓词下推策略与成本感知优化实践
后端
Amour恋空10 小时前
SpringBoot+Lombok+Logback实现日志
spring boot·后端·logback