读书笔记 · 第 5 篇
读书笔记丨《Unit Testing Principles, Practices, and Patterns》
本文总结了读者在阅读《Unit Testing》书籍中的收获和思考。
本篇在上一篇的基础上,梳理下笔者的个人见解,感兴趣的读者可参考原文对比阅读:
https://hedon.top/2025/04/09/note/note-unit-testing-excerpt/
揪心疑惑
在撰写单元测试的过程中,你是否曾经被以下问题困扰过?
- 为什么要写单元测试?单元测试的目标是什么?
- 单元测试的粒度是怎样的?什么叫单元?a class, a function, or a behavior, or an observable behavior?
- 单测覆盖率真的有用吗?有什么用?又有哪些限制?
- 怎样才能写好单元测试?怎样才能写出性价比最高的单元测试?
- 如何判断一个单元测试的好坏?有没有具体可供参阅的维度?
- 哪些代码需要写单元测试,哪些代码没必要写单元测试?
- 单元测试和集成测试的边界是什么?
- (单元丨集成)测试到底是要测什么东西?
- 单元测试的侧重点是什么?集成测试的侧重点是什么?二者的比例该是怎样的?
- 如何使用 Mock?哪些东西是需要 Mock 的?哪些东西是不应该 Mock 的?需要 Mock 的东西,应该在哪个层次进行 Mock?(你的 repository 层需要 Mock 吗?)
- 为什么你的测试代码很脆弱,总是需要频繁修改,维护起来难度很大?
- 如何减少测试结果的假阳性和假阴性?
四根柱子
对于第 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 个问题,你是否又有以下疑问:
- 哪个维度是最重要的?
- 怎样才能写出满足各个维度的测试代码?
- 如果维度之间存在矛盾,如何 trade off?
为什么要写单元测试?
三个最重要的原因:
-
验证你的程序逻辑正确性。
-
带来更好的代码设计。
因为单元测试能够让你站在使用者的角度去使用暴露的接口,如果接口不好用,逻辑不好测,测试条件不好构建,大概率说明代码的设计本身是有缺陷的,包括但不限于:抽象不合理、逻辑划分不清晰、与其他模块耦合严重等。
-
使软件项目更可持续发展。
如果你的需求没有发生变化,那原本能运行通过的单测应该一直都能运行,这有助于避免在团队协作中不小心改坏你不知道的代码,也有助于你执行各种重构措施。
这三个原因的重要性是显而易见的,但笔者个人觉得还有一个更深层次的最重要的原因:
- 你要对你做的事情负责,好的代码一定要先过自己这关。
单元测试的粒度是什么?
这是一个很有争议的话题,单元测试的「单元」到底是什么?
- 一个类?
- 一个函数?
- 还是多个类组成的一个模块?
- 还是多个函数组成的一个大逻辑?
在《Unit Testing》书中,作者指出:「单元」指的是 an observable behavior,即一个外部系统可观测到的行为。
为什么是可观测行为:
- 一个帮助客户端实现目标的操作(operation)。
- 一个帮助客户端实现目标的状态(state)。
换言之,也就是我在撰写单元测试的时候,我就是在使用系统提供的能力,我就是个使用者, 我只要验证你能提供我要的功能,就 OK 了,你背后怎么做,为了这个功能所拆分的类也好,小的辅助函数也好,都不重要,都不属于我要验证的范畴。
所以这更像是黑盒测试(black-box test)。
当然,会有例外,如果你底层有一个特别特别复杂的逻辑,你有必要专门花精力去验证它的逻辑正确性,那是可以针对它撰写专门的白盒测试(white-box test)的。针对这个情况,作者其实也提出了一个观点,对于这个复杂的逻辑,也可以抽成一个单独的模块,由它来提供能力给你当前模块使用。
总结:
- 优先选择黑盒测试。
- 对于涉及复杂算法的逻辑,单独撰写白盒测试。
- 结合覆盖率工具去看哪些代码没被覆盖,然后再站在使用者的角度去思考为什么没被覆盖,是这个分支压根没必要存在,还是还有未考虑到的使用场景。
如何组织单元测试?
两种结构:
- AAA: Arrange-Act-Assert
- GWT: Given-When-Then
其实都是一个思路:准备前置条件 → 执行待验证代码 → 验证逻辑正确性。
几个建议:
- 尽量避免一个单元测试中包含多个 AAA/GWT。
- 避免在单元测试中使用
if等分支语句。 - 命名的时候,尽可能让非程序员也能看懂,即这个命名需要描述一个领域问题。
如何发挥单测的最大价值?
- 单元测试用例必须持续不断反复执行验证。
- 用最小的维护代价提供最大价值的单元测试。
- 识别一个有价值的测试
- 撰写一个有价值的测试
- 验证代码中最重要的部分(领域模型)。
1. 单元测试用例必须持续不断反复执行验证
这里推荐笔者的个人实践:
- 在
pre-commit执行增量单元测试,确保本次修改的代码涉及的单测可正确通过。 - 在
gitlab-ci/github-action流程中执行全量单元测试,全面覆盖,避免本次修改的代码影响到其他模块的正常功能。同时如果是合并到主分支的请求,加入增量覆盖率阈值检测,不满足阈值的,发送飞书消息卡片进行告警通知。
pre-commit 增量单测
.pre-commit-config.yaml 配置如下:
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 脚本如下:
#!/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 配置如下:
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 单测执行脚本如下:
#!/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 用于计算增量覆盖率:
#!/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 个角度进行评估:
- protection against regressions
- resistance to refactoring
- fast feedback
- maintainability
更具体地说:
2.1 回归保护
防止你改坏代码。换言之,这个测试能帮你发现多少 Bug?
评价指标:
- 被测试代码执行到的业务代码数量(测试覆盖率)。
- 业务代码的复杂度。
- 业务代码的领域重要性。
2.2 抵抗重构
非功能性重构,测试仍能通过,确保功能一致性。
评价指标:
- 越少的“假阳性”越好。
- 在重构代码时,引入了破坏性变更,测试代码能否快速反馈,即越少的“假阴性”越好。
- 测试代码是否为你重构代码提供了足够的信心。
- 测试代码测试的是业务代码的 observable behavior,而不是其背后的每一个步骤。
2.3 快速反馈
测试代码执行时间越快,则反馈间隔越短,缺陷修复效率和质量就越高。
评价指标:
- 代码执行速度
2.4 可维护性
测试代码的修改成本,可维护的测试代码更有利于适应需求变更。
评价指标:
- 测试代码有多难理解?
- 测试代码的代码行数有多少?
- 测试代码的执行难度有多高?即有多少的外部依赖?
2.5 如何权衡
单元测试的价值可以通过上述 4 个指标的乘积来进行估算,但现实是,这 4 者,往往无法兼得。那我们如何做权衡呢?
首先回顾「为什么要写单元测试」,核心目的是为了程序逻辑正确性、使软件项目更可持续发展。所以:
- **可维护性(maintainability)**是不可商量的,必须要撰写可维护的测试代码。
- **抵抗重构(resistance to refactoring)**是不可商量的,我们的测试代码应尽可能对错误的逻辑进行告警,也应避免对正确的逻辑进行误告警。
所以我们能权衡的其实就是 protection againts regressions 和 fast feedback,二者的矛盾很清晰:
- 如果执行的代码越多,相应的效率就越低。
- 如果执行的代码太少,那验证的逻辑范围就越小。
为了权衡这二者,业界提出了“测试金字塔”的概念。
- 单元测试的单位更小,涉及的外部依赖也更少,更加 fast feedback,所以在这个层次我们要撰写更多的测试,去尽可能覆盖更多的单元逻辑。
- 集成测试、端到端测试的逻辑覆盖范围更大,更加 resistance to refactoring,但是往往会依赖更多的组件,执行的效率也更低,所以在这 2 个层次,我们可以只撰写覆盖最重要(乐观)的业务路径的测试代码,在牺牲有限的执行效率的情况下,尝试更大的防止回归效果。
3. 验证代码中最重要的部分
什么是代码中最重要的部分呢?我们可以将代码分成以下 4 个种类:
- 领域模型和算法(Domain Model and Algorithms):领域模型是对业务领域核心概念和逻辑的抽象,算法则是解决特定问题的计算步骤。两者共同构成系统的核心业务逻辑。
- 琐碎代码(Trivial Code):实现简单功能、无复杂逻辑的代码片段,通常为工具方法或数据转换层。
- 控制器(Controllers):协调业务逻辑与外部交互的中间层,常见于 MVC 或分层架构中。
- 过度复杂代码(Overcomplicated Code):既包含核心业务逻辑,又包含控制器逻辑。
作者建议:
- 永远为 Domain Model and Algorithms 撰写全面细致的单元测试。
- 永远不为 Trivial Code 撰写单元测试。
- 为 Controllers 撰写集成测试,而不是单元测试。
- 避免写 Overcomplicated Code,将其拆分成 Domain Model and Algorithms 和 Controllers 。
如何让代码更容易测试?
根据不同处理架构的业务代码,可以将测试代码分成以下 3 个种类:
output-based:业务代码只产生输出结果,所以只需要验证输出。state-based:业务代码会修改内部状态或依赖状态,所以需要验证状态变化。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 个阶段:
- 根据业务规则做出决定
- 根据决定做出行为
为此,在可能的场景下,我们可以尝试通过 2 个步骤来优化我们的测试代码:
- 使用
mock来替代外部依赖out-of-process dependency。 - 使用
functional architecture来替代mock。
聊一下 Mock
在撰写单元测试的过程中,如果业务逻辑依赖的组件不好实例化的时候,我们常常会借助各种 Mock 工具来实现“模拟”功能,使单测更易撰写,这里有一个更准确的词叫 test doubles(测试替身)。
test double 的种类
从大的方面可以分为 2 种:
- 用于模拟和验证对象间的输出交互(如方法调用次数、参数匹配),则为
mock。 - 用于模拟输入交互,提供预定义的数据,则为
stub。
更进一步可以分为:
mockmock: 由 mock 工具生成。spy: 手工撰写。
stubstub: 可以通过配置在不同的场景下返回不同的数据。dummy: 占位符,仅用于填充参数,不参与实际逻辑。fake: 跟stub几乎一样,唯一的区别是fake经常用于替代尚未开发或复杂的依赖。
需要注意的是:永远不要去验证(assert)跟
stub的交互,没必要!
哪些东西需要 Mock?
在回答这个问题之前,我们先做下铺垫,聊一下接口的误解、依赖的种类和两种交互的概念。
接口的误解
在谈如何更好地利用 mock 之前,我们先来聊一下接口(interface)的误解。
在业务开发当中,我们经常能看到一些企图进行“优雅”架构设计的代码,上来每一层都定义接口,每一层都使用接口进行交互,反正遇到问题先定义接口再说。
目的有二:
- 抽象外部依赖,进行解耦。
- 可以在不修改既有代码的情况下扩展功能,即所谓的开闭原则(Open-Closed principle)。
但这其实存在一些误区,作者在书中指出:
- 只有一个实现的接口,并不是抽象,也并没有比具体的对象起到太多所谓的解耦作用。
- 上述第 2 点违反了一个更重要的原则 YAGNI(You are not gonna need it),也就是你所谓的功能扩展大概率是不需要的。
- 上述做法的唯一好处是什么:**使测试成为可能!**因为你不隔离掉外部依赖的话,你的单元测试撰写会非常困难,也无法做到 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 内部的实现细节。
更具体来说:
- **仅对
unmanaged dependency应用mock对象。**因为我们无法预知其他应用会对这些依赖进行什么操作,所以只能隔离开。 - **对系统最外围的边界进行
mock。**只有系统边界,才是可观测行为,内部都是实现细节,对实现细节过多 Mock,意味着破坏了 resistance to refactoring。 - 尽量只在集成测试中使用 mock,避免在单元测试中使用 mock。
- 只 mock 属于你的对象,不去 mock 依赖库中的对象。
- 始终在第三方库之上编写自己的适配器,并对这些适配器进行 mock,而不是 mock 底层类型。
- 仅从库中暴露你所需要的功能。
- 使用项目的领域语言(domain language)来完成上述操作。
举个例子:
在上图中,右侧是我们的应用程序,它依赖了左下角的 Message bus 这个外部依赖,准确说是 unmanaged dependency。对此,我们为其创建了适配器接口 IBus,在这个通用接口之上,我们又根据具体业务创建了 IMessageBus。
针对这种情况,我们在进行 mock 的时候,只需要 mock IBus 对象,而不是去 mock Message bus 和 IMessageBus。
数据库要不要 Mock?
这个话题比较有意思,作者的建议是:
- 如果这个数据库只有你这个应用可以访问,那就不要 mock。
- 如果这个数据库存在可以被其他应用访问的部分,那就只 mock 这一部分,不去 mock 独属于你应用的那部分。
要践行上述标准,需要做到以下前提:
- 将数据库的信息也放在源码控制系统中(git),包括:
- schema
- reference data(项目启动必须要的初始数据)
- migration(数据变更记录)
- 每个开发者有一个单独的数据库(测试环境下)
- 但数据库变更的时候,不要直接修改,而是要写一条对应 sql 去进行修改,同时将这条 sql 也纳入源码控制系统中。
作者不建议 mock 数据库,包括使用内存数据库替代,如 sqlite 替代 MySQL,核心原因是:你无法保证这些数据库能跟线上环境的行为一致,可能会导致一些无效测试用例,即假阴性。
笔者并不完全采纳这个建议,诚然,如果能做到以上前提,是可以考虑践行的。然而,它的要求很高,收益却相对较小,在单元测试环境下,使用内存数据库进行 mock,在保证了 fast feedback 和 maintainability 的情况下,也能够避免绝大多数的逻辑漏洞了,假阴性的情况会非常少,即便有,也可以交给集成测试和端到端测试去解决。
反面案例
- 测试私有方法。
- 暴露私有状态。
- 泄露领域知识到测试中。
- 在业务代码中撰写只用于测试的代码。
- mock 具体的类。
第 3 点比较有意思,比如下面这个例子:
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。
https://github.com/alicebob/miniredis
可以封装一个函数,用于快速启动 miniredis 并返回客户端对象:
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 的逻辑怎么测
核心挑战:
- 依赖真实 MySQL 则容易因为网络原因而导致测试失败(不可重复性)
- 依赖真实 MySQL 会严重影响单侧执行效率
- 数据预备
- 数据清洗
- 单测之间的数据隔离,互不影响
- 并发安全
为了解决上述问题,提供更优雅的 MySQL 单测解决方案,笔者借助 dolthub/go-mysql-server 和 gorm 的能力,实现了一个 go-mysql-mocker,简称 gmm。
其中:
dolthub/go-mysql-server提供了内存 MySQL 引擎。gorm提供了快速建表和插入数据的能力。
https://github.com/hedon954/go-mysql-mocker
核心功能:
- 内存版数据库,无网络依赖;
- 每个单测可单独启动一个数据库,天然做到数据隔离和清洗;
- 支持 struct、slice、sql stmt、sql file 多种方式进行数据初始化,支持需要前置数据的业务逻辑测试。
随机概率逻辑怎么测
场景:随机抽奖
难点:随机概率的结果是不确定的,直接通过 assert.Equal 是无法写出可稳定重复运行的单测的。
// 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)
}
}
}
案例:
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. 构造请求
// 快速创建请求体 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. 发送请求
w := httptest.NewRecorder()
sfRouterTest.ServeHTTP(w, request)
3. 断言异常
// 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. 断言正确且返回响应值
// 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. 组合起来
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. 案例
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) // 正确结果
})
}
依赖时间的逻辑怎么测
- 尽量不要依赖时间。
- 考虑将时间作为参数,避免
time.Now()。
也可以参考:
https://hedon.top/2025/03/06/go/go-lib-synctest/
并发逻辑怎么测
go test推荐开启-race用于检测并发冲突。
更多可参考:
来自开放网络的回应
引用、喜欢、转发和站外回复会通过 Webmention 回到这里。
接收与展示代码已经就绪,注册正式域名后即可开始收集回应。
评论
评论由 GitHub Discussions 保存和管理。