嵌入式單元測試(基於規格) 是做什麼的?
為使用者提供的 .c/.h 檔案集合生成單元測試套件,遵循 Maurício Aniche 的規格化測試 7 步驟(Effective Software Testing,第 2 章)。此技能之所以成為技能的關鍵判斷在於:良好地分割每個函式的域(最有價值的步驟)、推導出邊界、設計出仍能捕捉錯誤的最小套件、在專案的正確框架中自動化測試,並迭代直到覆蓋率達到目標。
在第三階段之前請閱讀 references/specification-based-testing.md——這是關於如何思考分割與邊界的校準文件,以及避免套件膨脹或脆弱的實用方針。
範圍不變量(請先閱讀)
- 英文輸出。 此技能產出的所有內容均為英文,即使使用者以其他語言撰寫:測試程式碼、測試名稱、註解、目錄、
Covers:註解、跳過/忽略訊息、路徑宣告以及最終摘要。 - 僅限單元測試。 絕不生成整合測試、端到端測試、系統測試、HIL 測試或 Robot Framework 測試。顆粒度為孤立函式/模組,依賴關係使用 fake/mock。
- 僅限給定的檔案。 套件僅針對使用者傳入的
.c/.h檔案集合。不要掃描專案尋找其他目標,也不要自行測試相鄰模組。 - 永遠不要修改目標檔案——即使是暫時的或「為了測試再還原」也不行。套件編譯原始的
.c檔案,測試目錄中不複製。若要測試難以觸及的內容,請使用公開 API、依賴項的 fake 或獨立的測試二進位檔案。 - 自主執行。 生成、執行並報告,無需請求確認即可執行。有兩個例外情況需要停下並詢問:(a) 路徑 C(非 Zephyr 也非 ESP-IDF)且無法識別任何單元測試框架(第 0 階段);(b) 每兩次覆蓋率迭代仍未達到目標(第 8 階段)。
- 不撰寫報告。 你不要撰寫結果報告。執行工具並指向它們生成的工件(覆蓋率 HTML、執行器輸出)。「結果」是工具發出的內容,而非你的說明。
- 覆蓋率目標: 預設為函式 ≥ 95%、行 ≥ 95%、分支 ≥ 90%(95/95/90)。一旦達到目標,停止迭代並列出剩餘未覆蓋的行/分支。僅在使用者要求時才追求 100/100/100。低於目標的情況僅在於遺漏的部分確實無法到達(日誌/斷言噪音、模組的可測試性限制),且每個遺留的行/分支都有正當理由。
流程(Aniche 的 7 步驟 → 9 階段)
7 步驟並非僵化的管線,而是迭代的(第 3 階段常會揭示一些需要返回第 1 階段的內容)。以下是作為骨幹的階段。
| 階段 | Aniche 步驟 | 你做的事 |
|---|---|---|
| 0 | — | 偵測環境 → 路徑 A(Zephyr)、B(ESP-IDF)或 C(其他) |
| 1 | 1 理解 | 每個給定函式的需求、輸入、輸出 |
| 2 | 2 探索程式碼 | 建立需用 fake/mock 隔離的依賴關係圖 |
| 3 | 3 分割 | 編號的分割目錄(最重要的步驟) |
| 4 | 4 邊界 | 編號的邊界目錄(on point 與 off point) |
| 5 | 5 設計案例 | 結合分割/邊界,減少組合數量 |
| 6 | 6 自動化 | 在路徑的框架中撰寫套件,並帶有可追溯性 |
| 7 | — | 執行工具:執行 + 覆蓋率 |
| 8 | 7 擴充 | 針對差距迭代(覆蓋率 + 目錄項中無測試的部分) |
第 0 階段——環境偵測
按以下順序識別包含給定檔案的專案環境,並選擇路徑。Robot Framework、HIL、整合 pytest 不是單元測試框架——它們在任何路徑中都不算數。
-
Zephyr?→ 路徑 A。 標誌:
west.yml/west.yaml;CMakeLists.txt包含find_package(Zephyr ...);prj.conf;testcase.yaml/sample.yaml;tests/;ztest/ZTEST_SUITE。工具:twister + ztest + FFF + 覆蓋率。閱讀references/zephyr-path.md。 -
ESP-IDF?→ 路徑 B。 標誌:
CMakeLists.txt包含idf_component_register(;sdkconfig/sdkconfig.defaults;使用$ENV{IDF_PATH}/idf.py;main/元件。工具:ESP-IDF 的原生 Unity(宿主機編譯,linux目標)+ gcov/gcovr。直接使用 Unity——不要停下來詢問。例外:如果專案已經一致使用其他單元測試框架(Ceedling、GoogleTest…),則遵循該框架。閱讀references/espidf-path.md。 -
其他(既非 Zephyr 也非 ESP-IDF)→ 路徑 C。 檢查現有的單元測試結構(Unity、CMock、Ceedling、GoogleTest、自訂)並忠實遵循專案的模式。如果沒有找到單元測試框架,停下並詢問使用者該使用哪一個——在路徑 C 中永遠不要假設靜默的預設值。閱讀
references/non-zephyr-path.md。
在回應文字中說明(不僅在你的推理中),在進行下一步之前,給出選定路徑以及證明其合理的具體標誌,包括檔案和你發現的內容——例如「路徑 A (Zephyr):zephyr/west.yml、tests/.../CMakeLists.txt 中的 find_package(Zephyr)、tests/lib/ 中的 ZTEST_SUITE 套件。」
第 1 階段——理解需求、輸入與輸出
對於套件將涵蓋的每個函式,從閱讀 .c/.h 中提取:
- 業務規則:它做什麼以及它不能做什麼(包括邊角情況);
- 輸入:類型、範圍/域、變數之間的約束;
- 輸出:輸入如何變成結果(以及可觀察的副作用:寫入輸出、返回錯誤、發出/未發出動作)。
部分內容是明確的;部分是你推斷的隱含域規則(飽和、繞回、單位、符號、間隙)。這些隱含規則正是 Bug 藏身之處。
第 2 階段——探索程式碼
閱讀整個 .c/.h 並建立需隔離的依賴關係圖:HAL、驅動程式、核心/RTOS、I/O、時間、呼叫其他模組。每個依賴項在第 6 階段都會變成一個 fake/mock。同時確定實際邏輯如何被觸及(直接公開 API,或經由 handle/vtable 的 static 函式——透過真實 API 呼叫它們,永遠不要自己發明簽名)。
第 3 階段——識別分割(最重要的步驟)
對每個函式應用 references/specification-based-testing.md 中的三個透鏡:分別考慮每個輸入、輸入的結合以及輸出類別。考慮 NULL/空值以及隱含的域規則。如果函式有狀態,則也根據模組/上下文的狀態進行分割。
針對每個函式,輸出一個命名的、編號的目錄,在套件內具有穩定的 ID:
- 分割:
<func>/P1, P2, … - 邊界(在第 4 階段填入):
<func>/B1, B2, …
每個項目都有一個簡短描述。此目錄是套件的可追溯性合約(在第 6 階段實現),也是差距的真相來源(第 8 階段)。良好的第 3 階段應在第一次覆蓋率通過時涵蓋幾乎所有內容。
第 4 階段——分析邊界
對於相鄰分割之間的每個邊界,將on point(條件中的值)和off point(對立分割中最接近的鄰值)加入目錄;等於條件給出一個 on 點和兩個 off 點。如果邊界已被其他案例涵蓋則刪除——不要重複。「分割或邊界?不重要」:重要的是案例存在。
第 5 階段——設計測試案例
結合跨輸入類別的分割,但減少組合爆炸:測試例外情況(NULL/空)一次,不要將其與所有東西結合;使用域來削減。使用單一種子輸入,每個案例只變化最少的部分(易於除錯)。有疑問時,使用最簡單的輸入。對於不參與行為的輸入,使用合理/實際的值。如果一個案例與其他多個案例具有相同的骨架,則將其規劃為參數化。列出案例,並對每個案例列出其執行的目錄 ID。
第 6 階段——自動化(帶有強制可追溯性)
在偵測到的路徑框架中撰寫套件(語法、隔離和框架細節請參閱 references/zephyr-path.md、references/espidf-path.md 或 references/non-zephyr-path.md)。
第 3/4 階段的目錄成為套件中的註解,分為兩個層級:
- 每個函式的標頭——在該函式測試區塊頂部的區塊,列出完整目錄(每個分割/邊界及其 ID 和簡短描述)。
- 每個測試的註解——每個測試引用其執行的項目,例如
/* Covers: P3 + B1 */。
強制規則(這使得可追溯性有價值):
- 無孤立測試: 每個測試引用 ≥ 1 個目錄項目。
- 無孤立項目: 每個分割/邊界由 ≥ 1 個測試執行。沒有測試的項目會被標記為差距(供第 8 階段使用)。
- 標頭與註解之間的 ID 穩定且相同——這樣
grep "P4"就能列出標頭以及所有接觸該分割的測試。 - 涵蓋組合的測試列出所有 ID(
/* Covers: P2 + P5 */)。 Covers:說實話: 每個註解的 ID 都與案例實際執行的內容(輸入、狀態和斷言)相符,而不僅僅是目錄中存在的一個項目。在第 7 階段之前,請根據每個測試主體重新閱讀註解,並修正不匹配的註解。- 在路徑 B 和 C 中,使用相同的方案,並根據框架語法調整(每個
TEST(...)/TEST_CASE(...)/TEST_F(...)上方的註解)。
規範形式(路徑 A,ztest):
/*
* 目錄 — motor_ctrl_transform (第 3 階段 · 基於規格的測試)
* 分割:
* P1 p_axis_values == NULL -> 拒絕,不寫入輸出
* P2 有效的 axis_count (1..N) -> 變換
* P3 正值 -> 正步數
* P4 負值 -> 保留符號
* P5 零值 -> 零步數
* 邊界:
* B1 axis_count == N (最大值) -> 接受
* B2 axis_count == N+1 -> 拒絕
*/
/* Covers: P2 + P3 */
ZTEST(motor_ctrl_suite, test_transform_positive_gives_positive_steps) { /* ... */ }
/* Covers: P1 */
ZTEST(motor_ctrl_suite, test_transform_null_input_is_rejected) { /* ... */ }
注意:在較新的 Zephyr 中,執行器要求測試名稱以
test_開頭(ZTEST(suite, test_...))——沒有此前綴,twister 將不會收集該案例。
發現的缺陷:程式碼與文件不一致
當程式碼與標頭檔、函式註解或 API 合約文件相矛盾時(例如返回與文檔不同的錯誤碼、失敗時未恢復狀態),不要讓套件保持紅色,也不要使用 ZTEST_EXPECT_FAIL。撰寫兩個測試:
- 文檔行為,附理由地忽略——文檔承諾的斷言,附帶理由跳過:
ztest_test_skip()(路徑 A)或TEST_IGNORE_MESSAGE("BUG:...")(路徑 B)。上方註解:/* Covers: Pn — BUG:<文件說 X;程式碼做 Y> */。 - 特徵描述——固定當前行為,這樣任何變更(包括修復)都會顯現。註解:
/* Covers: Pn — 上述 BUG 的特徵描述 */。
套件以綠色結束,Bug 在每次執行中都顯現(作為跳過/忽略),且二進位檔案進入測量範圍。在最終摘要中列出缺陷。
第 7 階段——執行 + 覆蓋率
在執行前,啟動專案環境。 測試工具很少在全局 PATH 上——它通常存在於儲存庫的虛擬環境中。在得出「無法執行」的結論之前,請先尋找並啟動環境(這適用於每個路徑):
- 在韌體根目錄尋找虛擬環境(
.venv/、venv/、env/)並使用其二進位檔案(.venv/bin/west、.venv/bin/python)或source .venv/bin/activate。虛擬環境通常是符號連結:使用test -e .venv/bin/activate或ls -laL(追蹤連結)檢查,永遠不要只使用ls -d/find -type d。說明你發現了什麼:所使用的虛擬環境路徑,或「在<檢查的目錄>中沒有虛擬環境」。 - Zephyr: 設定
ZEPHYR_BASE(例如deps/zephyr,或west topdir/.west/config)並為宿主機設定ZEPHYR_TOOLCHAIN_VARIANT=host。如果west不在PATH上但 Zephyr 樹存在,直接執行 twister:python3 $ZEPHYR_BASE/scripts/twister ...。 - ESP-IDF: 確保 IDF 環境已設定(存在時執行
. $IDF_PATH/export.sh)以進行宿主機建置;否則直接使用gcc編譯(請參閱espidf-path.md)。
只有在窮盡此搜尋之後,才能誠實地報告此處無法執行。
上下文經濟(韌體建置輸出冗長,且每次輪換都重新讀取所有內容):
- 將建置、twister 和 gcovr 的輸出重新導向到檔案(
> build.log 2>&1),僅顯示摘要(tail -n 30 build.log、grep -E 'error|FAIL|PASS|lines|branches' build.log)。僅在摘要無法解釋失敗時才開啟整個日誌。 - 一次性寫入每個測試檔案的完整內容;透過有針對性的編輯進行修正。
- 在同一次輪換中分組進行獨立的讀取和檢查;不要重新讀取已經讀過的檔案。
執行路徑的工具並指向其生成的工件(不要撰寫報告):
- 路徑 A (Zephyr):
west twister --coverage -p native_sim -T <suite_path>。工件:twister-out/coverage/index.html、twister-out/coverage.json、twister-out/twister.xml、twister-out/twister.json。 - 路徑 B (ESP-IDF): 宿主機建置使用 Unity +
gcov/gcovr(請參閱espidf-path.md);指向覆蓋率報告(coverage.html/.xml)和測試輸出。 - 路徑 C (其他): 專案自己的執行器 + 覆蓋率(例如
ceedling gcov,或對測試建置使用gcov/gcovr)。保留並指向產生的工件。
每個路徑的檔案中提供了詳細資訊和故障排除方法。
第 8 階段——覆蓋率迭代
比較工具報告的覆蓋率與 95/95/90 目標(或使用者要求的目標)。低於目標時,每個可到達但沒有測試的行/分支都是一個差距;一旦達到目標,停止並列出未被覆蓋的內容。對於每個差距,使用兩個來源添加案例:數值覆蓋率(紅色的行/分支)以及……
[為翻譯而截斷]