让一座“死去的”代码库重获新生:第二部分——稳定化
在这篇文章中,我将演示如何为一个未经测试的遗留代码库补上自动化测试。
这是一个三篇系列的第二部分,介绍我如何让 ingredient-phrase-tagger 重获新生。这是一个使用机器学习将烹饪食材(例如“2 cups milk”)解析为结构化数据的代码库。完整背景请阅读第一部分;简单来说,我发现了一个被遗弃的代码库,并让它重新焕发生机,使其能够为我的 SaaS 业务提供支持:
在持续集成中运行
在第一部分结束时,我创建了一个 Docker 镜像,使这个代码库能够在任何系统上运行。下一步是在持续集成(continuous integration,CI)中运行它。
持续集成是一种实践:使用独立且受控的环境,在代码每次发生变更时测试软件。我偏好的持续集成解决方案是 Travis。它的配置文件直观易懂,而且为开源项目提供不限次数的免费构建。
为了与 Travis 集成,我在 Travis 的配置页面中添加了 我 fork 的 ingredient-phrase-tagger,然后启用了构建:

为 ingredient-phrase-tagger 代码库启用 Travis 构建
接着,我创建了一个名为 .travis.yml 的文件,告诉 Travis 如何构建这个代码库:
sudo: required
services: docker
script: docker build .我将提交推送到 GitHub,创建了一个拉取请求,Travis 随后成功构建:

Travis 上首次成功的构建
添加端到端测试
Travis 虽然构建了我的 Docker 镜像,但这个构建还没有实际意义。它只构建了代码库的依赖项,并没有检验代码库的任何行为。我希望构建过程能在我破坏代码库功能时提醒我。为此,我需要一个端到端测试(end-to-end test,E2E 测试)。
端到端测试用于验证一个完整的现实场景是否按预期工作。它通常包含以下结构:
- 提供预先生成的输入及其预期输出(也称为“黄金输出”)。
- 使用自动化工具将输入交给代码库。
- 将代码库的输出与黄金输出进行比较。
原始代码库中包含一个名为 roundtrip.sh 的脚本,它与端到端测试很相似。它向代码库提供预先生成的输入,使用部分输入训练一个新的机器学习模型,然后使用该模型解析输入的其他部分。唯一缺少的环节是:它从未将结果与一个已知正确的输出进行比较。
一个基本的端到端测试
在第一部分中,我展示过 roundtrip.sh 脚本的最终结果是一组关于模型性能的汇总统计信息:
Sentence-Level Stats:
correct: 1487
total: 1999
% correct: 74.3871935968
Word-Level Stats:
correct: 10391
total: 11450
% correct: 90.7510917031这已经足够让我编写一个简单的端到端测试。我重新运行了 roundtrip.sh 脚本的最后一步,但将控制台输出重定向到一个名为 tests/golden/eval_output 的新文件中,并将它加入版本控制:
python bin/evaluate.py tmp/test_output > tests/golden/eval_output现在我有了已知正确的输出,于是修改了 roundtrip.sh 的结尾,让它将今后的所有输出与这份保存的输出进行比较:
python bin/evaluate.py tmp/test_output > tmp/eval_output
diff tests/golden/eval_output tmp/eval_output我的测试能发现代码何时出问题吗?
端到端测试只有在能够捕获错误时才有用,因此下一步是模拟一次会导致程序出错的变更,检查端到端测试能否捕获它。
在 cli.py 中,有一个用于匹配数字序列(例如 "83625")的正则表达式:
m3 = re.match('^\d+$', ss)作为实验,我调整了这个正则表达式,使它无法识别任何包含 9 的数字:
m3 = re.match('^[0-8]+$', ss)然后,我重新运行修改后的 roundtrip.sh 脚本:
3c3
< correct: 1487
---
> correct: 1486
5c5
< % correct: 74.3871935968
---
> % correct: 74.33716858429成功了!
当我告诉代码 9 不再算作数字时,代码库的准确率下降了,脚本也以失败退出码终止。
扩展端到端测试
上面的基本端到端测试很有用,但 roundtrip.sh 执行的是一个包含多个阶段的数据流水线。要是能知道究竟是哪个阶段出错,就方便多了,因此我寻找了更多可以纳入端到端测试的输出。
除了向控制台打印输出外,脚本还会将文件写入名为 tmp/ 的子目录:
$ file tmp/*
tmp/model_file: data
tmp/output.html: HTML document, ASCII text, with very long lines
tmp/test_file: ASCII text
tmp/test_output: ASCII text
tmp/train_file: ASCII texttest_file、test_output 和 train_file 都是纯文本文件,看起来有点像这样:
$ head -n 16 tmp/test_file
1 I1 L12 NoCAP NoPAREN B-QTY
boneless I2 L12 NoCAP NoPAREN I-COMMENT
pork I3 L12 NoCAP NoPAREN B-NAME
tenderloin I4 L12 NoCAP NoPAREN I-NAME
, I5 L12 NoCAP NoPAREN B-COMMENT
about I6 L12 NoCAP NoPAREN I-COMMENT
1 I7 L12 NoCAP NoPAREN B-QTY
pound I8 L12 NoCAP NoPAREN I-COMMENT
Salt I1 L8 YesCAP NoPAREN B-NAME
and I2 L8 NoCAP NoPAREN I-NAME
freshly I3 L8 NoCAP NoPAREN B-COMMENT
ground I4 L8 NoCAP NoPAREN I-COMMENT
black I5 L8 NoCAP NoPAREN B-NAME
pepper I6 L8 NoCAP NoPAREN I-NAME我当时还不理解这种文件格式,但这并不重要。我只需要一种检测文件发生变化的方法。
将这些文件复制到 tests/golden 后,我把它们作为额外的黄金输出保存到版本控制中。然后,我在构建脚本中加入了多个 diff,用于检测这些输出文件是否发生变化。
完整的构建脚本
完成对 roundtrip.sh 的所有修改后,我将它保存为一个名为 build.sh 的新文件,内容如下:
#!/bin/bash
# Exit build script on first failure
set -e
# Echo commands to stdout.
set -x
COUNT_TRAIN=20000
COUNT_TEST=2000
OUTPUT_DIR=$(mktemp -d)
ACTUAL_CRF_TRAINING_FILE="${OUTPUT_DIR}/training_data.crf"
ACTUAL_CRF_TESTING_FILE="${OUTPUT_DIR}/testing_data.crf"
ACTUAL_CRF_MODEL_FILE="${OUTPUT_DIR}/model.crfmodel"
ACTUAL_TESTING_OUTPUT_FILE="${OUTPUT_DIR}/testing_output"
ACTUAL_EVAL_OUTPUT_FILE="${OUTPUT_DIR}/eval_output"
bin/generate_data \
--data-path=nyt-ingredients-snapshot-2015.csv \
--count=$COUNT_TRAIN \
--offset=0 > "$ACTUAL_CRF_TRAINING_FILE"
bin/generate_data \
--data-path=nyt-ingredients-snapshot-2015.csv \
--count=$COUNT_TEST \
--offset=$COUNT_TRAIN > "$ACTUAL_CRF_TESTING_FILE"
crf_learn \
template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"
crf_test \
-m "$ACTUAL_CRF_MODEL_FILE" \
"$ACTUAL_CRF_TESTING_FILE" > "$ACTUAL_TESTING_OUTPUT_FILE"
python bin/evaluate.py "$ACTUAL_TESTING_OUTPUT_FILE" > "$ACTUAL_EVAL_OUTPUT_FILE"
# Check against golden output.
GOLDEN_DIR=tests/golden
GOLDEN_CRF_TRAINING_FILE="${GOLDEN_DIR}/training_data.crf"
GOLDEN_CRF_TESTING_FILE="${GOLDEN_DIR}/testing_data.crf"
GOLDEN_TESTING_OUTPUT_FILE="${GOLDEN_DIR}/testing_output"
GOLDEN_EVAL_OUTPUT_FILE="${GOLDEN_DIR}/eval_output"
diff --context=2 "$GOLDEN_CRF_TRAINING_FILE" "$ACTUAL_CRF_TRAINING_FILE"
diff --context=2 "$GOLDEN_CRF_TESTING_FILE" "$ACTUAL_CRF_TESTING_FILE"
diff --context=2 "$GOLDEN_TESTING_OUTPUT_FILE" "$ACTUAL_TESTING_OUTPUT_FILE"
diff "$GOLDEN_EVAL_OUTPUT_FILE" "$ACTUAL_EVAL_OUTPUT_FILE"然后,我在这个脚本外面加了一个名为 docker_build 的简单包装脚本,让它在代码库专用的 Docker 容器中运行端到端测试:
#!/bin/bash
# Exit on first failing command.
set -e
# Echo commands to console.
set -x
IMAGE_NAME="ingredient-phrase-tagger-image"
CONTAINER_NAME="ingredient-phrase-tagger-container"
docker build \
--tag "$IMAGE_NAME" \
.
docker run \
--tty \
--detach \
--name "$CONTAINER_NAME" \
"$IMAGE_NAME"
docker exec "$CONTAINER_NAME" ./build.sh有了 docker_build 脚本,我的端到端测试就能在任何支持 Docker 的系统上运行了。自然地,我希望在持续集成环境中运行它。
在持续集成中运行端到端测试
我之前的 Travis 配置会构建 Docker 镜像,但不会运行代码库。现在我已经有了一个全面的测试脚本,于是更新了 .travis.yml 文件来运行它:
sudo: required
services: docker
-script: docker build .
+script: ./docker_build我推送了修改,准备见证这个能在任何地方稳定运行的精彩测试所展现的辉煌。然而,结果却是失败:

端到端测试在本地机器上通过后,却在 Travis 上失败
看到构建失败当然让我不高兴,但我很高兴端到端测试确实捕获到了问题。现在我只需要找出问题所在。
调试差异
Docker 容器存在的全部意义,就是让程序无论在哪里都表现一致;那么,为什么我在两个地方运行同一个容器,却会得到不同的输出?
Travis 构建日志显示,测试在对 testing_output 文件执行 diff 时失败了:
+ diff --context=2 tests/golden/testing_output /tmp/tmp.W5S3C5T4if/testing_output
*** tests/golden/testing_output Fri Jul 27 02:44:20 2018
--- /tmp/tmp.W5S3C5T4if/testing_output Fri Jul 27 03:03:56 2018
***************
*** 173,178 ****
1 I1 L8 NoCAP NoPAREN B-QTY B-QTY
tablespoon I2 L8 NoCAP NoPAREN B-UNIT B-UNIT
! dark I3 L8 NoCAP NoPAREN B-COMMENT B-COMMENT
! corn I4 L8 NoCAP NoPAREN B-NAME B-NAME
syrup I5 L8 NoCAP NoPAREN I-NAME I-NAME
--- 173,178 ---
1 I1 L8 NoCAP NoPAREN B-QTY B-QTY
tablespoon I2 L8 NoCAP NoPAREN B-UNIT B-UNIT
! dark I3 L8 NoCAP NoPAREN B-COMMENT B-NAME
! corn I4 L8 NoCAP NoPAREN B-NAME I-NAME
syrup I5 L8 NoCAP NoPAREN I-NAME I-NAMEtesting_output 文件是我的 build.sh 脚本中以下两行命令的结果:
crf_learn \
template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"
crf_test \
-m "$ACTUAL_CRF_MODEL_FILE" \
"$ACTUAL_CRF_TESTING_FILE" > "$ACTUAL_TESTING_OUTPUT_FILE"crf_learn 和 crf_test 都是 CRF++ 的命令行工具。CRF++ 是驱动 ingredient-phrase-tagger 机器学习逻辑的引擎。虽然我对这些工具了解不多,但从语法上可以推断出:crf_learn 创建机器学习模型,而 crf_test 使用该模型对数据进行分类。
端到端测试已经验证 $ACTUAL_CRF_TRAINING_FILE 和 $ACTUAL_CRF_TESTING_FILE 的内容与我的黄金版本一致。这意味着在我的本地系统和持续集成环境中,crf_learn 与 crf_test 接收的输入完全相同,但它们会根据运行环境产生不同的输出。
深入研究 CRF++
难道 CRF++ 是非确定性的吗?我尝试在本地再次运行测试,测试通过了。我又在 Travis 上重新运行测试,结果仍然以同样的方式失败。这说明 CRF++ 在同一环境中的多次执行结果一致,但在不同环境之间却不一致。
我不喜欢这个结论指向的方向。它表明 CRF++ 的行为依赖于系统底层硬件。也许 Intel CPU 和 AMD CPU 会产生不同结果。这会很麻烦,因为 Travis 不保证其硬件环境的一致性。而且,如果不同硬件会产生不同结果,那就违背了 Docker 容器的意义。
无奈之下,我查看了 CRF++ 的命令行文档,寻找任何可能暗示硬件依赖的内容:
$ crf_learn --help
...
-p, --thread=INT number of threads (default auto-detect)--thread 标志看起来很有意思。我查看了完整文档以了解更多详情:
-p NUM:
如果 PC 有多个 CPU,可以使用多线程来加快训练速度。NUM 是线程数。
这听起来很有希望。
我将 Travis 上 CRF++ 的输出与本地环境中对应的输出行进行了比较:

crf_learn 在 Travis 上使用两个线程,而在我的本地环境中使用八个线程
啊哈!
由于我省略了 --thread 标志,CRF++ 会根据可用 CPU 核心数自动设置线程数。我的 Travis 环境有两个 CPU 核心,而本地机器有八个。
我修改了 build.sh 脚本,显式设置线程数:
-crf_learn template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"
+crf_learn \
+ --thread=2 \
+ template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"然后,我将新生成的输出文件保存为黄金副本。我把修改推送到 GitHub,随后看到了令人愉快的一幕:我的端到端测试通过了:

端到端测试在 Travis 上通过
优秀测试的价值
端到端测试很快就证明了自己的价值。虽然深入研究某个代码库依赖项的文档有些繁琐,但测试揭示出,该代码库会根据运行环境产生不一致的结果。这很可能是代码库最初的作者从未意识到的问题。
有了端到端测试并运行着持续集成,我就拥有了一个能够展示代码库预期功能的权威环境。如果我做出任何无意中改变代码库行为的修改,这项测试就会提供一道重要的安全防线。
接下来做什么?
有了测试带来的信心,接下来就该进行软件项目中我最喜欢的环节了:重构。因为我知道只要做了太愚蠢的事情,构建就会大声报错,所以我可以放心地对代码进行大规模修改。
请继续阅读本系列的第三部分,在那里我会介绍自己如何:
- 添加单元测试
- 自动将代码应用统一的风格约定
- 将静态分析(static analysis,静态分析)集成到构建中
封面插图由 Loraine Yow(洛林·尤)绘制。我 fork 的 ingredient-phrase-tagger 代码库可在 GitHub 上获取。我基于这个代码库提供一项名为 Zestful 的托管服务。
随机一篇博客

