本篇在上一篇的基础上,梳理下笔者的个人见解,感兴趣的读者可参考原文对比阅读:

揪心疑惑

在撰写单元测试的过程中,你是否曾经被以下问题困扰过?

  1. 为什么要写单元测试?单元测试的目标是什么?
  2. 单元测试的粒度是怎样的?什么叫单元?a class, a function, or a behavior, or an observable behavior?
  3. 单测覆盖率真的有用吗?有什么用?又有哪些限制?
  4. 怎样才能写好单元测试?怎样才能写出性价比最高的单元测试?
  5. 如何判断一个单元测试的好坏?有没有具体可供参阅的维度?
  6. 哪些代码需要写单元测试,哪些代码没必要写单元测试?
  7. 单元测试和集成测试的边界是什么?
  8. (单元丨集成)测试到底是要测什么东西?
  9. 单元测试的侧重点是什么?集成测试的侧重点是什么?二者的比例该是怎样的?
  10. 如何使用 Mock?哪些东西是需要 Mock 的?哪些东西是不应该 Mock 的?需要 Mock 的东西,应该在哪个层次进行 Mock?(你的 repository 层需要 Mock 吗?)
  11. 为什么你的测试代码很脆弱,总是需要频繁修改,维护起来难度很大?
  12. 如何减少测试结果的假阳性和假阴性?

四根柱子

对于第 5 个问题,作者提出了 4 个维度:

  • Protection against regressions:防止回归,通过自动化验证代码修改后原有功能不受破坏。
    • The amount of code that is executed during the test.
    • The complexity of that code.
    • The code’s domain significance.
  • Resistance to refactoring:抗重构性,重构业务代码时,测试代码无需过多变动便可通过用例,证明重构无误。
    • Tests provide an early warning when you break existing functionality.
    • You become confident that your code changes won’t lead to regressions.
  • Fast feedback:快速反馈
  • Maintainability:可维护性
    • How hard it is to understand the test.
    • How hard it is to run the test.

对于这 4 个问题,你是否又有以下疑问:

  1. 哪个维度是最重要的?
  2. 怎样才能写出满足各个维度的测试代码?
  3. 如果维度之间存在矛盾,如何 trade off?

为什么要写单元测试?

三个最重要的原因:

  1. 验证你的程序逻辑正确性。

  2. 带来更好的代码设计。

    因为单元测试能够让你站在使用者的角度去使用暴露的接口,如果接口不好用,逻辑不好测,测试条件不好构建,大概率说明代码的设计本身是有缺陷的,包括但不限于:抽象不合理、逻辑划分不清晰、与其他模块耦合严重等。

  3. 使软件项目更可持续发展。

    如果你的需求没有发生变化,那原本能运行通过的单测应该一直都能运行,这有助于避免在团队协作中不小心改坏你不知道的代码,也有助于你执行各种重构措施。

这三个原因的重要性是显而易见的,但笔者个人觉得还有一个更深层次的最重要的原因:

  • 你要对你做的事情负责,好的代码一定要先过自己这关。

单元测试的粒度是什么?

这是一个很有争议的话题,单元测试的「单元」到底是什么?

  • 一个类?
  • 一个函数?
  • 还是多个类组成的一个模块?
  • 还是多个函数组成的一个大逻辑?

在《Unit Testing》书中,作者指出:「单元」指的是 an observable behavior,即一个外部系统可观测到的行为

为什么是可观测行为:

  1. 一个帮助客户端实现目标的操作(operation)。
  2. 一个帮助客户端实现目标的状态(state)。

换言之,也就是我在撰写单元测试的时候,我就是在使用系统提供的能力,我就是个使用者, 我只要验证你能提供我要的功能,就 OK 了,你背后怎么做,为了这个功能所拆分的类也好,小的辅助函数也好,都不重要,都不属于我要验证的范畴。

所以这更像是黑盒测试(black-box test)。

当然,会有例外,如果你底层有一个特别特别复杂的逻辑,你有必要专门花精力去验证它的逻辑正确性,那是可以针对它撰写专门的白盒测试(white-box test)的。针对这个情况,作者其实也提出了一个观点,对于这个复杂的逻辑,也可以抽成一个单独的模块,由它来提供能力给你当前模块使用。

总结:

  1. 优先选择黑盒测试。
  2. 对于涉及复杂算法的逻辑,单独撰写白盒测试。
  3. 结合覆盖率工具去看哪些代码没被覆盖,然后再站在使用者的角度去思考为什么没被覆盖,是这个分支压根没必要存在,还是还有未考虑到的使用场景。

如何组织单元测试?

两种结构:

  • AAA: Arrange-Act-Assert
  • GWT: Given-When-Then

其实都是一个思路:准备前置条件→执行待验证代码→验证逻辑正确性。

几个建议:

  1. 尽量避免一个单元测试中包含多个 AAA/GWT。
  2. 避免在单元测试中使用 if 等分支语句。
  3. 命名的时候,尽可能让非程序员也能看懂,即这个命名需要描述一个领域问题。

如何发挥单测的最大价值?

  1. 单元测试用例必须持续不断反复执行验证。
  2. 用最小的维护代价提供最大价值的单元测试。
    1. 识别一个有价值的测试
    2. 撰写一个有价值的测试
  3. 验证代码中最重要的部分(领域模型)。

1. 单元测试用例必须持续不断反复执行验证

这里推荐笔者的个人实践:

  • pre-commit 执行增量单元测试,确保本次修改的代码涉及的单测可正确通过。
  • gitlab-ci/github-action 流程中执行全量单元测试,全面覆盖,避免本次修改的代码影响到其他模块的正常功能。同时如果是合并到主分支的请求,加入增量覆盖率阈值检测,不满足阈值的,发送飞书消息卡片进行告警通知。

pre-commit 增量单测

.pre-commit-config.yaml 配置如下:

1
2
3
4
5
6
7
8
9
10
repos:
- repo: local
hooks:
- id: go-unit-tests
name: go-unit-tests
description: run go tests with race detector
entry: bash -c './script/run_diff_go_test.sh'
language: golang
files: \.*$
pass_filenames: false

run_diff_go_test.sh 脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#!/bin/bash
export GOTOOLCHAIN=auto

# 获取当前改动的 Go 文件
changed_files=$(git diff --name-only --cached --diff-filter=d | grep '\.go$')

# 如果没有改动的 Go 文件,退出
if [ -z "$changed_files" ]; then
echo "No Go files changed."
exit 0
fi

# 提取改动文件所在的包路径(使用相对路径),并排除 vendor 目录
test_dirs=$(echo "$changed_files" | xargs -n1 dirname | grep -v '^vendor' | sort -u)

# 对每个改动的包路径运行 go test
for dir in $test_dirs; do
# 检查目录是否存在
if [ ! -d "$dir" ]; then
echo "Directory $dir does not exist. Skipping..."
continue
fi

# 检查是否存在 go.mod 文件,确保在 Go 模块路径中
if [ -f "$dir/go.mod" ] || [ -f "./go.mod" ]; then
echo "Running tests in $dir..."
(cd "$dir" && go test -mod=vendor -gcflags=all=-l -short ./...)
if [ $? -ne 0 ]; then
echo "Tests failed in $dir"
exit 1
fi
else
echo "Skipping $dir (no go.mod found)"
fi
done

echo "All tests passed."

gitlab-ci 全量单测

gitlab-ci.yml 配置如下:

1
2
3
4
5
6
7
8
go-unit-test:
stage: go-unit-test
script:
- sh script/unittest.sh "$CI_MERGE_REQUEST_TITLE" "$GITLAB_USER_EMAIL" "$CI_PIPELINE_ID" "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" "$CI_JOB_ID"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == 'dev'
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == 'release'
coverage: '/coverage: \d+.\d+% of statements/'

unittest.sh 单测执行脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/bash
export GOPROXY="https://goproxy.cn,direct"

function generate_coverage_report {
gocover-cobertura < coverage.out > coverage.xml
}

# 使用 gotestsum 执行单元测试
# 如果单测执行失败,会发送飞书消息卡片到告警群中
if ! gotestsum --junitfile report.xml --post-run-command="./script/send_fs_card.sh \"$1\" \"$2\" \"$3\" \"$5\"" -- ./... -timeout 3s -short -mod=vendor -gcflags=all=-l -coverpkg=./... -coverprofile=coverage.out ; then
generate_coverage_report
sh script/cal_diff_coverage.sh "$4"
exit 1
fi

# 生成单元测试覆盖率报告
generate_coverage_report
# 如果增量覆盖率不满足阈值,会发送飞书消息卡片到告警群中
source script/cal_diff_coverage.sh "$4"

if [[ "$4" == "release" ]]; then
source script/check_test_coverage.sh "$1" "$2" "$3"
fi

其中 cal_diff_coverage.sh 用于计算增量覆盖率:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/bin/bash

export COVERAGE_PERCENT=0.0

# 确保 coverage.xml 文件存在
if [ ! -f coverage.xml ]; then
echo "coverage: 0.0% of statements"
exit 0
fi

# 使用 diff-cover 生成覆盖率报告
diff-cover coverage.xml --exclude **/docs.go --html-report report.html --compare-branch "$1" > diff_detail.txt

# 检查是否成功生成报告
if [ ! -f report.html ]; then
echo "coverage: 0.0% of statements"
exit 0
fi

# 使用 grep 和 awk 提取覆盖率信息
COVERAGE=$(grep "Coverage:" diff_detail.txt | awk '{print $2}')

# 如果找到了覆盖率数据,检查是否包含小数点
if [ -n "$COVERAGE" ]; then
if [[ "$COVERAGE" != *"."* ]]; then
# 如果没有小数点,在百分号前面加上 '.0'
# 这里这么做的目的是不知道为什么 gitlab ci 无法正确解析下面这个正则
# /coverage: \d+(.\d+)?% of statements/
# 只能解析这个
# /coverage: \d+.\d+% of statements/
COVERAGE="${COVERAGE/\%/.0%}"
fi
echo "coverage: $COVERAGE of statements"
else
COVERAGE="0.0%"
echo "coverage: 0.0% of statements"
fi

# 将 COVERAGE 的百分号去掉,只保留数字
export COVERAGE_PERCENT=$(echo "$COVERAGE" | sed 's/%//')

2. 用最小的维护代价提供最大价值的单元测试

如何评价一个单元测试价值是否足够大呢?或者,更简单的说法是,如何评价一个单元测试写得好不好?

可以从 4 个角度进行评估:

  1. protection against regressions
  2. resistance to refactoring
  3. fast feedback
  4. maintainability

更具体地说:

2.1 防止回归

代码修改后,原有功能不受影响。

评价指标:

  1. 被测试代码执行到的业务代码数量(测试覆盖率)。
  2. 业务代码的复杂度。
  3. 业务代码的领域重要性。

2.2 抵抗重构

非功能性重构,测试仍能通过,确保功能一致性。

评价指标:

  1. 越少的“假阳性”越好。
  2. 在重构代码时,引入了破坏性变更,测试代码能否快速反馈,即越少的“假阴性”越好。
  3. 测试代码是否为你重构代码提供了足够的信心。
  4. 测试代码测试的是业务代码的 observable behavior,而不是其背后的每一个步骤。

2.3 快速反馈

测试代码执行时间越快,则反馈间隔越短,缺陷修复效率和质量就越高。

评价指标:

  1. 代码执行速度

2.4 可维护性

测试代码的修改成本,可维护的测试代码更有利于适应需求变更。

评价指标:

  1. 测试代码有多难理解?
  2. 测试代码的代码行数有多少?
  3. 测试代码的执行难度有多高?即有多少的外部依赖?

2.5 如何权衡

单元测试的价值可以通过上述 4 个指标的乘积来进行估算,但现实是,这 4 者,往往无法兼得。那我们如何做权衡呢?

首先回顾「为什么要写单元测试」,核心目的是为了程序逻辑正确性、使软件项目更可持续发展。所以:

  • 可维护性(maintainability)是不可商量的,必须要撰写可维护的测试代码。
  • 抵抗重构(resistance to refactoring)是不可商量的,我们的测试代码应尽可能对错误的逻辑进行告警,也应避免对正确的逻辑进行误告警。

所以我们能权衡的其实就是 protection againts regressions 和 fast feedback,二者的矛盾很清晰:

  1. 如果执行的代码越多,相应的效率就越低。
  2. 如果执行的代码太少,那验证的逻辑范围就越小。

为了权衡这二者,业界提出了“测试金字塔”的概念。

  1. 单元测试的单位更小,涉及的外部依赖也更少,更加 fast feedback,所以在这个层次我们要撰写更多的测试,去尽可能覆盖更多的单元逻辑。
  2. 集成测试、端到端测试的逻辑覆盖范围更大,更加 resistance to refactoring,但是往往会依赖更多的组件,执行的效率也更低,所以在这 2 个层次,我们可以只撰写覆盖最重要(乐观)的业务路径的测试代码,在牺牲有限的执行效率的情况下,尝试更大的防止回归效果。

3. 验证代码中最重要的部分

什么是代码中最重要的部分呢?我们可以将代码分成以下 4 个种类:

  1. 领域模型和算法(Domain Model and Algorithms):领域模型是对业务领域核心概念和逻辑的抽象,算法则是解决特定问题的计算步骤。两者共同构成系统的核心业务逻辑。
  2. 琐碎代码(Trivial Code):实现简单功能、无复杂逻辑的代码片段,通常为工具方法或数据转换层。
  3. 控制器(Controllers):协调业务逻辑与外部交互的中间层,常见于 MVC 或分层架构中。
  4. 过度复杂代码(Overcomplicated Code):既包含核心业务逻辑,又包含控制器逻辑。

作者建议:

  1. 永远为 Domain Model and Algorithms 撰写全面细致的单元测试。
  2. 永远不为 Trivial Code 撰写单元测试。
  3. Controllers 撰写集成测试,而不是单元测试。
  4. 避免写 Overcomplicated Code,将其拆分成 Domain Model and AlgorithmsControllers

如何让代码更容易测试?

根据不同处理架构的业务代码,可以将测试代码分成以下 3 个种类:

  1. output-based:业务代码只产生输出结果,所以只需要验证输出
  2. state-based:业务代码会修改内部状态或依赖状态,所以需要验证状态变化
  3. communication-based:业务代码会跟协作方进行交互,所以需要验证交互情况。对于这种场景,我们会使用 mock 工具来进行验证。关于 mock 这个话题,文章后续会进行详细讨论。

我们按照上述 4 个分析维度,对这 3 种测试代码进行比较:

protection againts regressions resistance to refactoring fast feedback maintainability
output-based ⭐️⭐️⭐️ ⭐️⭐️⭐️ ⭐️⭐️⭐️ ⭐️⭐️⭐️ 最好,不需要外部依赖。
state-based ⭐️⭐️⭐️ ⭐️⭐️ ⭐️⭐️⭐️ ⭐️⭐️ 比较差,需要外部依赖。
communication-based ⭐️⭐️ 过度使用会导致需要到处 mock,而真正执行的业务代码数量很少。 ⭐️ 最差,因为验证交互情况,往往会陷入实现细节,很容易在重构过程中出现误警告。 ⭐️⭐️ 大差不差,但是 mock 工具效率可能会相对低一点点。 ⭐️ 最差,需要引入大量的 mock 工具和 mock 代码。

所以我们应该尽可能写 output-based 测试,减少 communication-based 测试。

可以采取 functional architecture,将代码分成 2 个阶段:

  1. 根据业务规则做出决定
  2. 根据决定做出行为

为此,在可能的场景下,我们可以尝试通过 2 个步骤来优化我们的测试代码:

  1. 使用 mock 来替代外部依赖 out-of-process dependency
  2. 使用 functional architecture 来替代 mock

聊一下 Mock

在撰写单元测试的过程中,如果业务逻辑依赖的组件不好实例化的时候,我们常常会借助各种 Mock 工具来实现“模拟”功能,使单测更易撰写,这里有一个更准确的词叫 test doubles(测试替身)。

test double 的种类

从大的方面可以分为 2 种:

  1. 用于模拟和验证对象间的输出交互(如方法调用次数、参数匹配),则为 mock
  2. 用于模拟输入交互,提供预定义的数据,则为 stub

更进一步可以分为:

  • mock
    • mock: 由 mock 工具生成。
    • spy: 手工撰写。
  • stub
    • stub: 可以通过配置在不同的场景下返回不同的数据。
    • dummy: 占位符,仅用于填充参数,不参与实际逻辑。
    • fake: 跟 stub 几乎一样,唯一的区别是 fake 经常用于替代尚未开发或复杂的依赖。

需要注意的是:永远不要去验证(assert)跟 stub 的交互,没必要!

哪些东西需要 Mock?

在回答这个问题之前,我们先做下铺垫,聊一下接口的误解、依赖的种类和两种交互的概念。

接口的误解

在谈如何更好地利用 mock 之前,我们先来聊一下接口(interface)的误解。

在业务开发当中,我们经常能看到一些企图进行“优雅”架构设计的代码,上来每一层都定义接口,每一层都使用接口进行交互,反正遇到问题先定义接口再说。

目的有二:

  1. 抽象外部依赖,进行解耦。
  2. 可以在不修改既有代码的情况下扩展功能,即所谓的开闭原则(Open-Closed principle)。

但这其实存在一些误区,作者在书中指出:

  1. 只有一个实现的接口,并不是抽象,也并没有比具体的对象起到太多所谓的解耦作用。
  2. 上述第 2 点违反了一个更重要的原则 YAGNI(You are not gonna need it),也就是你所谓的功能扩展大概率是不需要的。
  3. 上述做法的唯一好处是什么:使测试成为可能!因为你不隔离掉外部依赖的话,你的单元测试撰写会非常困难,也无法做到 fast feedback。
🙋🏻‍♀️

抽象是发现出来的,而不是发明出来的!

依赖的种类

  • shared dependency: 一个在测试代码中的共享对象。
  • out-of-process dependency: 独立于当前应用程序的另外一个进程对象,如数据库、STMP 服务器等。
    • managed dependency: 仅当前应用程序可访问的依赖(对其他程序、服务是不可见的)。
    • unmanaged dependency: 除了当前应用,其他应用也可见。
  • private dependency: 一个私有对象。

两种交互

  • intra-system communication: 应用程序内部的交互。
  • inter-system communication: 应用程序之间的交互。

哪些东西需要 Mock?

铺垫完接口的误解、依赖的种类和两种交互的概念之后,我们来聊一下哪些东西需要 Mock?

在抉择的时候,需要牢记我们测试粒度和评价指标。

测试粒度:an observable behavior ⭐️⭐️⭐️⭐️⭐️

评价指标:

  • 防止回归:protection against regressions
  • 抵抗重构:resistance to refactoring
  • 快速反馈:fast feedback
  • 可维护性:maintainability

集合测试粒度和评价指标,Mock 哪些东西可以用一句话来概括:

Mock 那些外部可观测到的交互,而尽量避免 Mock 内部的实现细节。

更具体来说:

  1. 仅对 unmanaged dependency 应用 mock 对象。因为我们无法预知其他应用会对这些依赖进行什么操作,所以只能隔离开。
  2. 对系统最外围的边界进行 mock只有系统边界,才是可观测行为,内部都是实现细节,对实现细节过多 Mock,意味着破坏了 resistance to refactoring。
  3. 尽量只在集成测试中使用 mock,避免在单元测试中使用 mock。
  4. 只 mock 属于你的对象,不去 mock 依赖库中的对象。
    • 始终在第三方库之上编写自己的适配器,并对这些适配器进行 mock,而不是 mock底层类型。
    • 仅从库中暴露你所需要的功能。
    • 使用项目的领域语言(domain language)来完成上述操作。

举个例子:

在上图中,右侧是我们的应用程序,它依赖了左下角的 Message bus 这个外部依赖,准确说是 unmanaged dependency。对此,我们为其创建了适配器接口 IBus,在这个通用接口之上,我们又根据具体业务创建了 IMessageBus

针对这种情况,我们在进行 mock 的时候,只需要 mock IBus 对象,而不是去 mock Message busIMessageBus

数据库要不要 Mock?

这个话题比较有意思,作者的建议是:

  1. 如果这个数据库只有你这个应用可以访问,那就不要 mock。
  2. 如果这个数据库存在可以被其他应用访问的部分,那就只 mock 这一部分,不去 mock 独属于你应用的那部分。

要践行上述标准,需要做到以下前提:

  1. 将数据库的信息也放在源码控制系统中(git),包括:
    • schema
    • reference data(项目启动必须要的初始数据)
    • migration(数据变更记录)
  2. 每个开发者有一个单独的数据库(测试环境下)
  3. 但数据库变更的时候,不要直接修改,而是要写一条对应 sql 去进行修改,同时将这条 sql 也纳入源码控制系统中。

作者不建议 mock 数据库,包括使用内存数据库替代,如 sqlite 替代 MySQL,核心原因是:你无法保证这些数据库能跟线上环境的行为一致,可能会导致一些无效测试用例,即假阴性。

笔者并不完全采纳这个建议,诚然,如果能做到以上前提,是可以考虑践行的。然而,它的要求很高,收益却相对较小,在单元测试环境下,使用内存数据库进行 mock,在保证了 fast feedback 和 maintainability 的情况下,也能够避免绝大多数的逻辑漏洞了,假阴性的情况会非常少,即便有,也可以交给集成测试和端到端测试去解决。

反面案例

  1. 测试私有方法。
  2. 暴露私有状态。
  3. 泄露领域知识到测试中。
  4. 在业务代码中撰写只用于测试的代码。
  5. mock 具体的类。

第 3 点比较有意思,比如下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CalculatorTests
{
[Fact]
public void Adding_two_numbers()
{
int value1 = 1;
int value2 = 3;
int expected = value1 + value2; // <-----The leakage
// int expected = 4 // the better one
int actual = Calculator.Add(value1, value2);
Assert.Equal(expected, actual);
}
}

什么叫做泄露领域知识呢?

比如你要验证一个加法 Add 对不对,但是在测试代码中,你的期望值也是用加法来获得的,这个“加法”就是领域知识,因为这样测的话,就很有可能会出现“负负得正”的情况。

正确的做法是直接断言你预期的最终结果,以确保逻辑符合预期。

Go 实践案例

本章将分享一些笔者在 Go 项目实战过程中的一些实践案例,希望对读者撰写单元测试能提供一些帮助。

依赖 Redis 的逻辑怎么测

可以使用 miniredis,这是一个使用 Go 语言实现的内存版 Redis。

可以封装一个函数,用于快速启动 miniredis 并返回客户端对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
func NewMiniRedis() *redis.Client {
var redisClient *redis.Client
var miniRedisClient *miniredis.Miniredis
var err error
miniRedisClient, err = miniredis.Run()
if err != nil {
panic(err)
}
redisClient = redis.NewClient(&redis.Options{
Addr: miniRedisClient.Addr(),
})
return redisClient
}

这里可能会出现作者提到的不要使用内存数据库替代真实的数据库,因为你无法保证它们的行为一致。

比如这里是单机的,而生产环境可能是集群的,在 Redis Cluster 中,涉及到 lua 脚本和事务的所有 key,都必须保证在同一个 slot 上,在这种情况下,使用 miniredis 是测不出问题的。

依赖 MySQL 的逻辑怎么测

核心挑战:

  1. 依赖真实 MySQL 则容易因为网络原因而导致测试失败(不可重复性)
  2. 依赖真实 MySQL 会严重影响单侧执行效率
  3. 数据预备
  4. 数据清洗
  5. 单测之间的数据隔离,互不影响
  6. 并发安全

为了解决上述问题,提供更优雅的 MySQL 单测解决方案,笔者借助 dolthub/go-mysql-servergorm 的能力,实现了一个 go-mysql-mocker,简称 gmm

其中:

  • dolthub/go-mysql-server 提供了内存 MySQL 引擎。
  • gorm 提供了快速建表和插入数据的能力。

核心功能:

  1. 内存版数据库,无网络依赖;
  2. 每个单测可单独启动一个数据库,天然做到数据隔离和清洗;
  3. 支持 struct、slice、sql stmt、sql file 多种方式进行数据初始化,支持需要前置数据的业务逻辑测试。

随机概率逻辑怎么测

场景:随机抽奖

难点:随机概率的结果是不确定的,直接通过 assert.Equal 是无法写出可稳定重复运行的单测的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// AssertMapRatioEqual 检查实际计数的比例是否符合预期权重的比例
// actual: 实际获得的计数 map[id]count
// expected: 预期的权重 map[id]weight
// tolerance: 允许的误差范围(如 0.05 表示允许 5% 的误差)
func AssertMapRatioEqual(t *testing.T, actual map[int64]int64, expected map[int64]int64, tolerance float64) {
t.Helper()

// 计算总数
var actualTotal, expectedTotal int64
for _, count := range actual {
actualTotal += count
}
for _, weight := range expected {
expectedTotal += weight
}

// 检查每个 ID 的比例
for id, expectedWeight := range expected {
actualCount, exists := actual[id]
if !exists {
t.Errorf("ID %d 在实际结果中不存在", id)
continue
}

expectedRatio := float64(expectedWeight) / float64(expectedTotal)
actualRatio := float64(actualCount) / float64(actualTotal)

if diff := math.Abs(expectedRatio - actualRatio); diff > tolerance {
t.Errorf("ID %d 的比例不符合预期: 期望 %.3f, 实际 %.3f, 差异 %.3f, 超出允许误差 %.3f",
id, expectedRatio, actualRatio, diff, tolerance)
}
}

// 检查是否有多余的 ID
for id := range actual {
if _, exists := expected[id]; !exists {
t.Errorf("实际结果中存在未预期的 ID: %d", id)
}
}
}

案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func TestLottery_randOnce(t *testing.T) {
t.Run("大量抽取应符合权重配置比例", func(t *testing.T) {
gotCount := make(map[int64]int64)
totalCount := int64(10000)
for i := int64(0); i < totalCount; i++ {
reward, err := lotteryOnce(0, 0, nil)
assert.Nil(t, err)
assert.NotNil(t, reward)
gotCount[reward.Id] += 1
}

expectedRatio := map[int64]int64{
1: 10,
2: 20,
3: 30,
4: 40,
5: 40,
}
testutil.AssertMapRatioEqual(t, gotCount, expectedRatio, 0.05)
})
}

HTTP 接口怎么测

挑战:

  1. 如何快速构建请求体并发送请求?
  2. 如何快速断言异常情况?
  3. 如何快速断言成功情况,并解析出期望的返回值?

1. 构造请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 快速创建请求体 form 表单格式
func NewHTTPPostRequest(path string, data any) *http.Request {
req := httptest.NewRequest("POST", path, NewHTTPBody(data))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req
}

func NewHTTPBody(data any) io.Reader {
values := url.Values{}
v := reflect.ValueOf(data)
t := v.Type()

if v.Kind() == reflect.Ptr {
v = v.Elem()
t = v.Type()
}

if v.Kind() != reflect.Struct {
return strings.NewReader(values.Encode())
}

for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
value := v.Field(i)

tag := field.Tag.Get("form")
if tag == "" {
continue
}

// 处理复杂类型(结构体、切片、map)
switch value.Kind() {
case reflect.Struct, reflect.Slice, reflect.Map:
jsonBytes, err := json.Marshal(value.Interface())
if err == nil {
values.Set(tag, string(jsonBytes))
continue
} else {
log.Printf("Error marshaling %v: %v", value.Kind(), err)
}
default:
// 处理其他类型
values.Set(tag, fmt.Sprintf("%v", value.Interface()))
}
}

return strings.NewReader(values.Encode())
}

2. 发送请求

1
2
w := httptest.NewRecorder()
sfRouterTest.ServeHTTP(w, request)

3. 断言异常

1
2
3
4
5
6
7
8
9
// AssertRspErr 断言 http 响应异常,expectedErr 为期望的错误信息
func AssertRspErr(w *httptest.ResponseRecorder, t *testing.T, expectedErr string) {
assert.Equal(t, http.StatusOK, w.Code)
body := w.Result().Body
defer body.Close()
rsp, err := FromHTTPResp[any](body)
assert.Nil(t, rsp)
assert.Equal(t, expectedErr, err.Error())
}

4. 断言正确且返回响应值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// FromHTTPResp 从 http 响应中解析出数据
func FromHTTPResp[T any](resp io.ReadCloser) (*T, error) {
body, err := io.ReadAll(resp)
if err != nil {
return nil, err
}
defer func() { _ = resp.Close() }()

var t httpResp[T]
err = json.Unmarshal(body, &t)
if err != nil {
return nil, err
}
if t.Code != 200 {
return nil, errors.New(t.Message)
}
return &t.Data, nil
}

// AssertRspOk 断言 http 响应成功,并返回响应体 T
func AssertRspOk[T any](w *httptest.ResponseRecorder, t *testing.T) *T {
assert.Equal(t, http.StatusOK, w.Code)
body := w.Result().Body
defer body.Close()
rsp, err := FromHTTPResp[T](body)
assert.Nil(t, err)
assert.NotNil(t, rsp)
return rsp
}

这里其实就违反了上一张反面案例中的第 3 点”泄露领域知识到测试中“,因为这里接受响应的时候,还是使用的领域对象结构,所以可能会出现负负得正的情况,比如你的对象字段名就是拼写错误了,但是因为你业务逻辑和断言处都是用的一个结构,所以内部形成了循环,就负负得正了,但是真正到了客户端那,就解析失败了。

不过在这个情况下,笔者认为这个情况下的这种风险是可以接受的,远盖不住其带来的效率提升。

5. 组合起来

1
2
3
4
5
6
7
8
9
10
11
func SendHTTPRequest[Rsp any](t *testing.T, server HTTPServer, path string, data any, errMsg ...string) *Rsp {
req := NewHTTPPostRequest(path, data)
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
if len(errMsg) > 0 {
AssertRspErr(w, t, errMsg[0])
return nil
} else {
return AssertRspOk[Rsp](w, t)
}
}

6. 案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func Test_GetCollectReward(t *testing.T) {
t.Run("重复领取", func(t *testing.T) {
uid := buildUserInfo(&UserInfo{
Got: map[int][]int{
1: {1},
},
}, apiTest.svc)
_ = testutil.SendHTTPRequest[GetCollectRewardResp](t, routerTest,
"/get_collect_reward", &GetCollectRewardReq{
UID: uid,
CollectID: 1,
}, "重复领取") // 错误信息
})

t.Run("领取成功", func(t *testing.T) {
uid := uuid.NewString()
rsp := testutil.SendHTTPRequest[GetCollectRewardResp](t, routerTest,
"/get_collect_reward", &GetCollectRewardReq{
UID: uid,
CollectID: 1,
},
)
assert.NotNil(t, rsp.Reward) // 正确结果
})
}

依赖时间的逻辑怎么测

  1. 尽量不要依赖时间。
  2. 考虑将时间作为参数,避免 time.Now()

也可以参考:

并发逻辑怎么测

  • go test 推荐开启 -race 用于检测并发冲突。

更多可参考: