我的功能
我有兩個協同運作的模式:
-
DEDUP — 識別並移除冗餘的測試檔案或測試場景,這些場景在其他檔案中已經用相同的輸入值覆蓋了相同的程式碼路徑。透過 gcov 覆蓋率比較(處理前 vs. 處理後)進行驗證。
-
GAP-FILL — 在去重之後,分析覆蓋率報告,找出未被測試或測試不足的程式碼(標頭檔和已編譯原始碼),然後撰寫有針對性的單元測試來填補這些缺口。透過 gcov 覆蓋率改善(處理前 vs. 處理後)進行驗證。
兩種模式共享一個共同的基準建置和覆蓋率測量工作流程。
何時使用我
適用於 DEDUP:
- 你的 C++ 測試套件隨著時間有機增長,有多個檔案覆蓋了相同的範圍
- 與全面的測試檔案 (
*_comprehensive.cpp) 並存的是補缺口測試檔案 (*_gaps.cpp) - 你繼承了一個存在重複測試場景的程式碼庫
- 你想要在不損失覆蓋率的情況下縮短 CI 測試時間
適用於 GAP-FILL:
- 你有一些已編譯的
.cpp原始碼檔案,沒有對應的測試檔案 - 覆蓋率報告顯示某些標頭檔的行覆蓋率低於 90%
- 你剛剛移除了冗餘測試,並希望將節省下來的測試時間用於填補缺口
- 你需要在發布截止日期前提高覆蓋率
在以下情況下請不要使用我:
- 測試是參數化/生成式測試(測試相同的屬性);這些是故意的冗餘
- 專案沒有設定任何覆蓋率工具
- 你只需要新增測試,而不想先檢查冗餘
我的工作方式
階段 0:了解測試環境
讀取測試目錄和 CMakeLists.txt:
test/source/*.cpp
test/CMakeLists.txt
include/<project>/*.hpp
source/*.cpp # 已編譯的原始碼
檢查是否存在以下模式:
- 模式配對:
*_gaps.cpp+*_comprehensive.cpp,*_basic.cpp+*_detailed.cpp source/中已編譯的.cpp檔案,在test/source/中沒有對應的test_*.cpp檔案include/中沒有專用測試檔案的標頭檔- 測試框架(doctest、Catch2、Google Test 等)和 CMake 的通配符匹配方式
備註:
- 測試框架和使用的巨集(
TEST_CASE、CHECK_EQ等) - CMakeLists.txt 是否使用
file(GLOB ...)或明確的原始碼列表 - 覆蓋率旗標模式(通常是
-DENABLE_TEST_COVERAGE=ON)
階段 1:在檔案層級偵測冗餘
對於每個候選檔案,將其測試場景與套件中的每個其他測試檔案進行比較。
具體比較方法:
-
並排讀取兩個檔案。 根據它們所執行的程式碼來分組測試場景:
- 自由函式(gcd、lcm 等)
- 類別方法(建構子、運算子、存取子、修改子)
- 模板實例化和特化
- 邊界情況覆蓋(零、無限大、NaN、邊界值)
-
檢查值層級的重複。 如果兩個檔案使用完全相同的值測試同一個函式(例如,兩個檔案中都有
gcd_recur(12, 8) = 4),則該場景是多餘的。輕微的變化(一個檔案中有費氏數列,另一個沒有)則不算是重複——這些是獨特的輸入。 -
將檔案標記為「冗餘」,如果其超過 90% 的測試場景出現在另一個覆蓋範圍相同或更全面的檔案中。剩餘的不到 10% 的獨特場景(微小的變化)可以被安全地移除。
階段 2:建立基準(移除前)
使用覆蓋率檢測工具建置測試套件:
| 工具 | 組態 |
|---|---|
| GCC + gcov | cmake -S test -B build/cov -DENABLE_TEST_COVERAGE=ON -DCMAKE_CXX_COMPILER=g++ -G "Unix Makefiles" |
| Clang + llvm-cov | cmake -S test -B build/cov -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_CXX_FLAGS="-fprofile-instr-generate -fcoverage-mapping" -G "Ninja" |
| MSVC | 使用 OpenCppCoverage 或 VS Enterprise 的內建覆蓋率功能 |
然後建置並執行:
cmake --build build/cov
./build/cov/<TestExecutable>
為函式庫標頭檔生成覆蓋率
cd build/cov
# 對於每個函式庫標頭檔,對一個 #include 了它的測試物件檔案執行 gcov
gcov -o CMakeFiles/<Tests>.dir/source/<test_file>.cpp.o <path>/include/<project>/<header>.hpp
# 對於每個已編譯原始碼,對函式庫物件檔案執行 gcov
gcov -o _deps/<project>-build/CMakeFiles/<Project>.dir/source/<source>.cpp.o <path>/source/<source>.cpp
記錄基準指標:
Headers:
array_like.hpp: 100% (28/28)
bpqueue.hpp: 100% (73/73)
...
Compiled sources:
ell1d.cpp: 0% (0/23) ← UNTESTED!
ldlt_mgr.cpp: 100% (10/10)
...
Test cases: N | Assertions: M | Status: SUCCESS
同時記錄每個檔案確切未覆蓋的行數(.gcov 輸出中的 #####: 行)。
階段 3:移除冗餘檔案(DEDUP 模式)
Remove-Item -Force test/source/test_*_gaps.cpp # PowerShell
git status test/
git diff --stat
階段 4:重建並收集去重後指標
# 重新配置(CMake GLOB 可能會偵測到遺失的檔案)
cmake -S test -B build/cov -DENABLE_TEST_COVERAGE=ON -DCMAKE_CXX_COMPILER=g++ -G "Unix Makefiles"
cmake --build build/cov
./build/cov/<TestExecutable>
將覆蓋率與基準進行比較。如果任何標頭檔的覆蓋率下降或未覆蓋的行數發生變化,請立即還原——該檔案並非冗餘。
DEDUP 的判定標準:
| 檢查項目 | 要求 |
|---|---|
| 所有測試通過 | 必須通過(前後都必須) |
| 每個標頭檔的行覆蓋率 | 前後必須完全相等 |
| 未覆蓋的行數 | 未覆蓋的行數集合必須完全相同 |
| 測試數量減少 | 預期之中——移除了測試案例 |
| 斷言數量減少 | 預期之中——移除了斷言 |
階段 5:分析覆蓋率缺口(GAP-FILL 模式)
如果去重後(或從頭開始)的覆蓋率報告顯示有缺口,請找出未被測試的程式碼:
缺口類型 A:已編譯原始碼零覆蓋率
檢查 source/*.cpp 與 test/source/*.cpp。source/ 中任何沒有對應 test_*.cpp 檔案的 .cpp 很可能都未被測試。
# 查找沒有測試覆蓋率的原始碼檔案
Get-ChildItem source/*.cpp | ForEach-Object {
$base = $_.Name -replace '\.cpp$'
$test = "test/source/test_$base.cpp"
if (-not (Test-Path $test)) { Write-Host "UNTESTED: $($_.Name)" }
}
缺口類型 B:覆蓋率低的函式庫標頭檔
查看 gcov 報告中行覆蓋率低於 90% 或顯示「沒有可執行行」的標頭檔。範本繁重的標頭檔可能會將其覆蓋率歸因於 .cpp 檔案——請兩者都檢查。
缺口類型 C:已覆蓋檔案內部的函式/分支
部分覆蓋檔案中的未覆蓋行(gcov 中的 #####:)揭示了未被執行的特定分支。這些是最高價值的目標。
階段 6:撰寫有針對性的測試
6a:讀取未被測試的原始碼
完整讀取原始碼以了解:
- 類別/函式 API(建構子、方法、參數)
- 所有條件分支(if/else、switch case、早期返回)
- 邊界情況(空/零輸入、邊界值、錯誤條件)
- 數學或演算法行為
6b:設計能覆蓋每個分支的測試案例
對於每個方法,列舉所需的測試場景:
| 方法 | 條件 | 輸入 | 預期行為 |
|---|---|---|---|
update(cut) | beta > tau | g=1, beta=10 搭配 _r=5 | 回傳 NoSoln,狀態不變 |
update(cut) | beta < -tau | g=1, beta=-10 搭配 _r=5 | 回傳 NoEffect,狀態不變 |
update(cut) | |beta| <= tau, g>0 | g=1, beta=2 搭配 _r=5 | 回傳 Success,新中心在 l+_r |
update(cut) | |beta| <= tau, g<0 | g=-1, beta=2 搭配 _r=5 | 回傳 Success,新中心在 l+_r |
update_central_cut(cut) | g > 0 | g=1, beta=0 | 將 _r 減半,中心向左移動 |
update_central_cut(cut) | g < 0 | g=-1, beta=0 | 將 _r 減半,中心向右移動 |
包含邊界情況:
- 零寬度區間
- 邊界值(
beta == tau、beta == -tau) - 零梯度(如果適用)
- 重複/鏈式操作以驗證狀態轉換
6c:撰寫測試檔案
在 test/source/ 中撰寫一個新的 test_<module>.cpp,遵循專案現有的測試模式:
#define DOCTEST_CONFIG_NO_EXCEPTIONS_BUT_WITH_ALL_ASSERTS
#include <doctest/doctest.h>
#include <ellalgo/<module>.hpp> // 被測試的標頭檔
#include <ellalgo/ell_config.hpp> // 如果需要型別定義
#include <utility> // 如果需要 std::pair
TEST_CASE("<Module>: 建構") {
auto x = <Module>(...);
CHECK_EQ(x.some_method(), expected_value);
}
TEST_CASE("<Module>: 成功路徑的方法") {
auto x = <Module>(...);
auto status = x.update(...);
CHECK_EQ(status, CutStatus::Success);
CHECK_EQ(x.state(), expected_state);
}
TEST_CASE("<Module>: 失敗路徑的方法") {
auto x = <Module>(...);
auto status = x.update(...); // 觸發 beta > tau 分支
CHECK_EQ(status, CutStatus::NoSoln);
}
TEST_CASE("<Module>: 邊界情況") {
// 零寬度區間、邊界條件等
}
TEST_CASE("<Module>: 鏈式操作") {
// 按順序執行多次更新
}
測試值的規則:
- 使用硬編碼的預期值(不要從被測試的相同程式碼計算得出)
- 在註解中用數學推導來註解每個預期值(例如,
// tau = |5*1| = 5, bound = 5 - 2/1 = 3, _r = half(3-0) = 1.5, _xc = 1.5) - 每個
if分支至少測試一次 - 每個
switchcase 至少測試一次 - 至少測試一個鏈式/順序操作以驗證狀態持續性
階段 7:重建並收集填補缺口後指標
cmake -S test -B build/cov -DENABLE_TEST_COVERAGE=ON -DCMAKE_CXX_COMPILER=g++ -G "Unix Makefiles"
cmake --build build/cov
./build/cov/<TestExecutable>
# 重新生成覆蓋率報告
gcov -o CMakeFiles/<Tests>.dir/source/test_<module>.cpp.o <path>/include/<project>/<module>.hpp
gcov -o _deps/<project>-build/CMakeFiles/<Project>.dir/source/<module>.cpp.o <path>/source/<module>.cpp
階段 8:報告覆蓋率改善情況
| 指標 | 改善前 | 改善後 | 變化 |
|--------|--------|-------|---|
| 測試案例 | N | N' | +Δ |
| 斷言 | M | M' | +Δ |
| <module>.hpp | XX% | 100% | +Δ |
| <module>.cpp (已編譯) | 0% | 100% | +100% |
| 所有其他檔案 | ≥90% | ≥90% | 0% |
覆蓋率缺口已填補:✅
關鍵規則
規則 1:覆蓋率才是事實,而非測試數量
只有當移除某個測試檔案後,每個函式庫標頭檔的行覆蓋率都保持不變時,該測試檔案才是冗餘的。如果覆蓋率下降,表示該檔案涵蓋了獨特的程式碼路徑。請立即還原。
在新增測試時,一個新的測試只有當它提高了目標檔案的覆蓋率時才算成功。如果目標檔案的覆蓋率沒有提高,則表示該測試並未真正執行程式碼——請重新審視測試邏輯。
規則 2:檢查未覆蓋的行數,而不僅僅是百分比
兩次執行可能都顯示「98.06%」,但它們的未覆蓋行可能不同。始終比較 .gcov 檔案輸出中 #####: 行的精確集合:
Get-Content build/cov/foo.hpp.gcov | Select-String -Pattern "^ #####:"
這些行在去重前後必須完全相同。對於填補缺口,目標檔案的 #####: 行數必須減少。
規則 3:不要對基於屬性的測試進行去重
基於屬性/生成式的測試(例如 RapidCheck)本質上就是故意設計成冗餘的。它們使用隨機化輸入來驗證不變量。即使它們與基於範例的測試重疊,也不應被移除。
規則 4:不要對具有不同值的測試進行去重
對同一個運算子有不同輸入值的兩個測試會執行不同的程式碼路徑。僅在輸入值和預期輸出完全相同時才移除。
在撰寫新測試時,請使用與現有測試不同的輸入值。重複使用相同的值並不會提高覆蓋率。
規則 5:始終從乾淨狀態重建
.gcda 檔案在被 gcov 使用後會失效,並且在原始碼或二進制檔案發生任何變更後就會過時。在進行測量之前,務必重建並重新執行:
cmake --build build/cov --clean-first # 或從頭重建
./build/cov/<TestExecutable>
規則 6:檢查 CMake GLOB 的行為
如果 CMakeLists.txt 使用帶有 CONFIGURE_DEPENDS 的 file(GLOB ...),CMake 會在重建時自動偵測新增/移除的檔案(CMake 3.12+)。在較舊的 CMake 版本上,請在新增或移除測試檔案後手動重新配置。
在撰寫新的測試檔案時,請驗證 CMake 是否已將其納入:
- 在建置輸出中尋找
test_<module>.cpp - 如果找不到,請重新配置:
cmake -S test -B build/cov
規則 7:硬編碼預期值,不要重新計算
在撰寫測試時,切勿使用被測試的相同程式碼來計算預期值。始終手動推導它們並將其硬編碼為字面值。這可以捕捉到當實作變更但測試仍然通過的迴歸錯誤,因為兩邊都使用了相同的損壞程式碼。
對複雜的推導進行註解:
// tau = |5*1| = 5, bound = 5 - 2/1 = 3
// g>0 所以 u=3, l=5-5=0
// _r = half_nonnegative(3-0) = 1.5, _xc = 0 + 1.5 = 1.5
CHECK_EQ(e.xc(), 1.5);
規則 8:測試每個分支
對於每個包含條件邏輯的函式,請列舉所有分支並為每個分支撰寫至少一個測試:
| 分支 | 測試場景 |
|---|---|
if (beta > tau) | 無解路徑 |
if (beta < -tau) | 無效路徑 |
else (成功) | 成功路徑 |
成功路徑內的 g > 0 | 正梯度 |
成功路徑內的 g < 0 | 負梯度 |
邊界 beta == tau | 仍然成功 (≤) |
邊界 beta == -tau | 取決於 < 還是 ≤ |
規則 9:優先處理已編譯原始碼,而非標頭檔
已編譯的 .cpp 檔案更有可能具有零覆蓋率,因為僅包含標頭檔的範本程式碼通常會在其他測試中被順便執行。優先測試以下項目:
- 沒有對應
test_*.cpp的source/*.cpp→ 最高優先順序 - 行覆蓋率低於 90% 的標頭檔 → 中等優先順序
- 在其他已覆蓋檔案內部的未覆蓋分支 → 較低優先順序
規則 10:符合專案的測試慣例
在撰寫測試之前,請研究 2-3 個現有的測試檔案以符合:
- 測試框架巨集的使用方式(例如
CHECK與REQUIRE) - 檔案命名慣例(
test_<name>.cpp或<name>_test.cpp) #include的模式和相對路徑- 測試案例的分組方式(每個類別一個 TEST_CASE 與每個方法一個)
- 輔助函式或測試裝置的使用