Resurrecting a Dead Library: Part Two - Stabilization

Michael Lynch

让一座“死去的”代码库重获新生:第二部分——稳定化

在这篇文章中,我将演示如何为一个未经测试的遗留代码库补上自动化测试。

这是一个三篇系列的第二部分,介绍我如何让 ingredient-phrase-tagger 重获新生。这是一个使用机器学习将烹饪食材(例如“2 cups milk”)解析为结构化数据的代码库。完整背景请阅读第一部分;简单来说,我发现了一个被遗弃的代码库,并让它重新焕发生机,使其能够为我的 SaaS 业务提供支持:

  • 第一部分:复苏——我将代码救治到能够在任何现代系统上运行
  • 第二部分:稳定化(本文)——我在修复代码的同时,防止其功能发生回退
  • 第三部分:康复——我开始重构代码

正在加固摇摇欲坠房屋的海狸

在持续集成中运行

在第一部分结束时,我创建了一个 Docker 镜像,使这个代码库能够在任何系统上运行。下一步是在持续集成(continuous integration,CI)中运行它。

Travis CI 徽标

持续集成是一种实践:使用独立且受控的环境,在代码每次发生变更时测试软件。我偏好的持续集成解决方案是 Travis。它的配置文件直观易懂,而且为开源项目提供不限次数的免费构建。

为了与 Travis 集成,我在 Travis 的配置页面中添加了 我 fork 的 ingredient-phrase-tagger,然后启用了构建:

启用 Travis 的截图

为 ingredient-phrase-tagger 代码库启用 Travis 构建

接着,我创建了一个名为 .travis.yml 的文件,告诉 Travis 如何构建这个代码库:

sudo: required
services: docker
script: docker build .

下载 travis.yml

我将提交推送到 GitHub,创建了一个拉取请求,Travis 随后成功构建

Travis CI 上首次成功构建的截图

Travis 上首次成功的构建

添加端到端测试

Travis 虽然构建了我的 Docker 镜像,但这个构建还没有实际意义。它只构建了代码库的依赖项,并没有检验代码库的任何行为。我希望构建过程能在我破坏代码库功能时提醒我。为此,我需要一个端到端测试(end-to-end test,E2E 测试)。

端到端测试用于验证一个完整的现实场景是否按预期工作。它通常包含以下结构:

  1. 提供预先生成的输入及其预期输出(也称为“黄金输出”)。
  2. 使用自动化工具将输入交给代码库。
  3. 将代码库的输出与黄金输出进行比较。

原始代码库中包含一个名为 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 text

test_filetest_outputtrain_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"

下载 build.sh

然后,我在这个脚本外面加了一个名为 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_build 脚本,我的端到端测试就能在任何支持 Docker 的系统上运行了。自然地,我希望在持续集成环境中运行它。

在持续集成中运行端到端测试

我之前的 Travis 配置会构建 Docker 镜像,但不会运行代码库。现在我已经有了一个全面的测试脚本,于是更新了 .travis.yml 文件来运行它:

sudo: required
services: docker
-script: docker build .
+script: ./docker_build

推送了修改,准备见证这个能在任何地方稳定运行的精彩测试所展现的辉煌。然而,结果却是失败:

端到端测试在 Travis 上失败

端到端测试在本地机器上通过后,却在 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-NAME

testing_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_learncrf_test 都是 CRF++ 的命令行工具。CRF++ 是驱动 ingredient-phrase-tagger 机器学习逻辑的引擎。虽然我对这些工具了解不多,但从语法上可以推断出:crf_learn 创建机器学习模型,而 crf_test 使用该模型对数据进行分类。

端到端测试已经验证 $ACTUAL_CRF_TRAINING_FILE$ACTUAL_CRF_TESTING_FILE 的内容与我的黄金版本一致。这意味着在我的本地系统和持续集成环境中,crf_learncrf_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++ 的输出与本地环境中对应的输出行进行了比较:

本地机器与 Travis 上 crf_learn 线程数的差异

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 的托管服务。

原文由 Michael Lynch 发布

本文章由 openai/gpt-5.6-luna 进行翻译