复活废弃代码库:第二部分——稳定化
原文由 Michael Lynch 于 发布,订阅该博客
在这篇文章中,我将演示如何为一个没有测试的遗留代码库补上自动化测试。
这是我复活ingredient-phrase-tagger的三部曲中的第二篇。这个库使用机器学习将烹饪配料(如“2 cups milk”)解析为结构化数据。完整背景请阅读第一篇,简而言之,我发现了一个被废弃的代码库,并将它重新带回可用状态,用来支撑我的 SaaS 业务:
在持续集成中运行它
在第一篇的结尾,我创建了一个 Docker 镜像,让这个库可以在任何系统上运行。下一步就是在持续集成环境中运行它。
持续集成是指在每次代码变更时,使用一个独立、可控的环境来测试软件的做法。我首选的持续集成方案是Travis。它的配置文件直观易懂,并且为开源项目提供无限次免费构建。
为了接入 Travis,我在 Travis 的配置页面上添加了我 fork 的 ingredient-phrase-tagger,然后启用了构建:

为 ingredient-phrase-tagger 库启用 Travis 构建
接着,我创建了一个名为.travis.yml的文件,用来告诉 Travis 如何构建这个库:
sudo: required
services: docker
script: docker build .我把提交推送到 GitHub,创建了一个Pull Request,Travis成功构建了它:

在 Travis 上的首次成功构建
添加端到端测试
Travis 当时只是在构建我的 Docker 镜像,但这个构建还没有实际意义。它只构建了库的依赖,却没有验证任何功能。我想要的是一个能在我破坏库功能时发出告警的构建。为此,我需要一个端到端测试。
端到端测试用于验证一个完整的真实场景是否按预期工作。它通常遵循以下结构:
- 提供预先生成好的输入及其预期输出(也称为“基准输出”)。
- 使用自动化工具将输入喂给代码库。
- 将库的输出与基准输出进行比较。
原始仓库中包含一个名为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 上使用 2 个线程,而在本地环境中使用 8 个线程
啊哈!
由于我没有指定--thread选项,CRF++ 会根据可用的 CPU 核心数自动设置它。我的 Travis 环境有 2 个 CPU 核心,而本地机器有 8 个。
我修改了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 上通过
良好测试的价值
端到端测试很快就证明了它的价值。虽然深入研究库的某个依赖的文档很繁琐,但测试揭示了该库会因环境不同而产生不一致的结果。这很可能是库的原作者从未意识到的问题。
有了端到端测试和持续集成的保障,我就拥有了一个能展示库预期功能的权威环境。这个测试提供了一道宝贵的防线,防止我无意中改变库的行为。
下一步是什么?
有了测试带来的信心,就到了我最喜欢的软件项目环节:重构。我可以放心地对代码进行大规模改动,因为我知道如果做了什么蠢事,构建会立刻大声报错。
请继续阅读本系列的第三篇,在其中我将介绍我是如何:
- 添加单元测试
- 自动为代码应用代码风格规范
- 将静态分析集成到构建中
封面插图由 Loraine Yow 绘制。我 fork 的 ingredient-phrase-tagger 库可在GitHub上获取。我基于该库提供一项名为Zestful的托管服务。
随机一篇博客


评论
登录后参与讨论