讓已死的函式庫起死回生:第二部——穩定化
在這篇文章中,我將示範如何為未經測試的遺留函式庫補上自動化測試。
這是三部曲系列的第二篇,講述我如何復活 ingredient-phrase-tagger——一個使用 machine learning(機器學習) 將烹飪食材(例如「2 cups milk」)解析為結構化資料的函式庫。完整背景請閱讀 第一部,簡而言之,我發現了一個被棄置的函式庫,並讓它起死回生,使其能夠驅動我的 SaaS 事業:
- 第一部:復甦 - 在其中我讓程式碼恢復健康,使其能在任何現代系統上執行
- 第二部:穩定化(本文) - 在其中我防止功能在修復程式碼的過程中退化
- 第三部:復健 - 在其中我開始 refactoring(重構) 程式碼
在 continuous integration(持續整合) 中執行
在第一部結束時,我建立了一個 Docker 映像檔,讓該函式庫能在任何系統上執行。下一步便是在 continuous integration 中執行這個函式庫。
continuous integration 是一種在程式碼每次變更時,使用獨立、受控的環境來測試軟體的實踐。我偏好的 continuous integration 解決方案是 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 上的首次成功建置
加入 end-to-end test(端對端測試)
Travis 正在建置我的 Docker 映像檔,但這次建置還沒有實質意義。它只建置了函式庫的相依套件——並未真正執行其任何功能。我想要一個能在我破壞函式庫功能時發出警示的建置。為此,我需要一個 end-to-end test。
end-to-end test 會驗證一個完整的、真實世界的場景是否如預期般運作。它通常符合以下結構:
- 提供預先產生的輸入及其預期輸出(也稱為「golden output」)。
- 使用自動化工具將輸入餵給函式庫。
- 將函式庫的輸出與 golden output 進行比較。
原始儲存庫包含一個名為 roundtrip.sh 的腳本,其形式類似 end-to-end test。它向函式庫提供預先產生的輸入,使用一部分輸入來訓練新的 machine learning 模型,然後使用該模型來解析輸入的其他部分。唯一缺少的一環是它從未將結果與已知的正確輸出進行比較。
基本的 end-to-end test
在 第一部 中,我展示了 roundtrip.sh 腳本的最終結果是一組關於模型效能的彙總統計數據:
Sentence-Level Stats:
correct: 1487
total: 1999
% correct: 74.3871935968
Word-Level Stats:
correct: 10391
total: 11450
% correct: 90.7510917031這已足夠讓我建立一個簡單的 end-to-end test。我重新執行了 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我的測試能察覺程式碼損壞嗎?
唯有能抓到錯誤,end-to-end test 才有價值,因此我的下一步是模擬一個會造成破壞的變更,並檢查我的 end-to-end test 是否能捕捉到它。
在 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 不再被視為數字時,函式庫的準確度下降了,腳本也以失敗的結束代碼終止。
擴充 end-to-end test
上述基本的 end-to-end test 很有用,但 roundtrip.sh 執行的是包含多個階段的資料處理管線。若能知道是哪一個特定階段出錯會更方便,因此我尋找更多可納入 end-to-end test 的輸出。
除了將輸出印到主控台外,該腳本還會將檔案寫入名為 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 後,我將它們作為額外的 golden output 儲存到版本控制中。接著,我在建置腳本中加入了 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 的簡易包裝器,讓 end-to-end test 在函式庫專屬的 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 腳本,我的 end-to-end test 就能在任何支援 Docker 的系統上執行。自然地,我想在我的 continuous integration 環境中執行它。
在 continuous integration 中執行我的 end-to-end test
我先前的 Travis 設定只建置了 Docker 映像檔,但沒有實際執行函式庫。現在有了完整的測試腳本,我更新了 .travis.yml 檔案來執行它:
sudo: required
services: docker
-script: docker build .
+script: ./docker_build我 推送了變更,準備見證我那能在任何地方一致執行的精妙測試的風采。結果,它失敗了:

在本地端通過後,卻在 Travis 上失敗的 end-to-end test
看到建置失敗我並不開心,但我很慶幸我的 end-to-end test 捕捉到了問題。我只需要弄清楚那是什麼。
除錯差異
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 內 machine learning 邏輯的引擎。即使對這些工具不太了解,我也能從語法推斷出 crf_learn 會建立 machine learning 模型,而 crf_test 則使用該模型來分類資料。
end-to-end test 已驗證 $ACTUAL_CRF_TRAINING_FILE 和 $ACTUAL_CRF_TESTING_FILE 的內容與我的 golden 版本相符。這意味著 crf_learn 和 crf_test 在我的本地系統與 continuous integration 中接收了相同的輸入,卻依環境產生了不同的輸出。
深入探討 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"接著,我將新產生的輸出檔儲存為我的 golden 複本。我將 變更推送到 GitHub,迎來了令人愉悅的景象:我的 end-to-end test 通過了:

在 Travis 上通過的 end-to-end test
良好測試的價值
end-to-end test 很快就證明了它的價值。雖然深入研究函式庫其中一個相依套件的說明文件相當繁瑣,但該測試揭露了函式庫會依環境產生不一致的結果。這很可能是函式庫原作者從未察覺的事。
有了 end-to-end test 和運作中的 continuous integration,我便擁有一個能展示函式庫預期功能的權威環境。該測試提供了一道寶貴的防線,以防我做出任何無意間改變函式庫行為的變更。
接下來呢?
有了測試帶來的信心,是時候進入我最喜歡的軟體專案環節了:refactoring。我可以自由地對程式碼進行大規模的改動,因為我知道如果我做了什麼太蠢的事,建置就會大聲地失敗。
請繼續閱讀本系列的 第三部,在其中我將說明我是如何:
- 加入 unit tests(單元測試)
- 自動對程式碼套用風格規範
- 將 static analysis(靜態分析) 整合到建置中
封面插圖由 Loraine Yow(蘿蘭·尤)繪製。我 fork 的 ingredient-phrase-tagger 函式庫可在 GitHub 上取得。我提供一個以此函式庫為基礎的託管服務,名為 Zestful。
隨機一篇部落格

