戰(zhàn):構(gòu)建端側(cè)測試契約框架)
鴻蒙生態(tài)里跑 Flutter 應(yīng)用已經(jīng)不是新鮮事但要把 Flutter 生態(tài)里沉淀出來的三方庫完整地搬到鴻蒙上尤其是測試相關(guān)的庫依然是一塊硬骨頭。最近我把 Flutter 生態(tài)里高頻使用的 matcher 庫做了一次完整的鴻蒙化適配順便用它搭了一套支持端側(cè)質(zhì)量驗(yàn)證的測試契約框架。這中間踩了不少坑也沉淀出一些可復(fù)用的設(shè)計(jì)思路這篇就當(dāng)階段性的實(shí)戰(zhàn)記錄希望能給同樣在做鴻蒙化適配的同學(xué)一些參考。matcher 這個庫用過 Flutter 的人應(yīng)該不陌生。它給你提供了一堆語義化的匹配器比如expect(result, equals(expected))、expect(list, contains(1))、expect(() fn(), throwsA(isATimeoutException()))讓斷言代碼讀起來像一句句自然語言。我的目標(biāo)很明確把這種語義化斷言能力和自定義匹配算法完整地帶到鴻蒙端讓測試代碼在鴻蒙設(shè)備上直接跑起來并且在跑不動的地方用抽象層做兼容而不是簡單地拿原始代碼硬編譯。1. 適配前先搞清楚matcher 的核心價值與鴻蒙化適配的目標(biāo)1.1 語義化斷言讓測試代碼像需求文檔一樣可讀很多團(tuán)隊(duì)寫測試習(xí)慣性地用一堆布爾條件堆斷言比如if (a ! b) { fail(not equal) }。這種寫法在業(yè)務(wù)簡單的時候問題不大一旦項(xiàng)目復(fù)雜起來失敗信息全靠手拼斷言意圖藏在業(yè)務(wù)代碼里后人維護(hù)的時候等于考古。matcher 這類庫走的是另一條路。它把斷言動作抽象成“匹配器”對象每個匹配器只回答一個問題這個實(shí)際值滿足我描述的條件嗎比如isAOrderModel()表示“這東西應(yīng)該是一個 OrderModel 實(shí)例”hasLength(3)表示“長度應(yīng)該是 3”。組合起來你可以寫出allOf(isAOrderModel(), hasLength(3), predicate((o) o.isValid))這種表達(dá)力很強(qiáng)的斷言。我在這邊實(shí)際項(xiàng)目里最爽的一個點(diǎn)是把某個核心鏈路的返回結(jié)果做成一個契約表達(dá)式簽進(jìn)框架里后續(xù)任何人改動接口導(dǎo)致契約破壞測試報錯信息直接描述“期望一個字段為 X 的對象實(shí)際拿到 null”定位問題基本不需要看業(yè)務(wù)代碼。1.2 為什么 Flutter 鴻蒙化繞不開 matcher 這類庫很多人會問Flutter 應(yīng)用本身跑在鴻蒙上Dart 層代碼是不是原封不動就能用答案沒那么簡單。Flutter 官方 SDK 在鴻蒙上確實(shí)已經(jīng)有對應(yīng)的引擎和運(yùn)行時但三方庫畢竟是圍繞標(biāo)準(zhǔn) Dart 生態(tài)開發(fā)的里面免不了依賴dart:io、dart:async、dart:isolate這些庫。在鴻蒙的 OpenHarmony 系統(tǒng)上并不是所有標(biāo)準(zhǔn)庫能力都按原樣提供尤其是文件系統(tǒng)、日志輸出、進(jìn)程信息這類底層接口往往需要通過鴻蒙自身的 NAPI 接口橋接。matcher 雖然大部分代碼是純 Dart 寫的但它內(nèi)部會用到dart:io的進(jìn)程退出碼來做測試結(jié)果處理也會通過print輸出到控制臺。在鴻蒙上直接跑輕則日志丟進(jìn)黑洞看不到重則在測試結(jié)束時進(jìn)程退出行為不一致導(dǎo)致失敗用例被誤判為通過。所以鴻蒙化適配的關(guān)鍵不只是“能不能編譯”而是“在端側(cè)能不能可靠地運(yùn)行、準(zhǔn)確地上報”。1.3 適配工作的邊界哪些必須動哪些一定不能動開始動手之前我給自己定了三條原則匹配器的語義不變。equals、contains、allOf這些匹配器對外表現(xiàn)出的行為必須和標(biāo)準(zhǔn)版完全一致不能因?yàn)檫m配而改變斷言邏輯。平臺差異隔離在底層。所有涉及鴻蒙系統(tǒng)能力的代碼收口到一個適配層里上層匹配器不直接觸碰系統(tǒng) API。盡量少改原庫源碼。能通過擴(kuò)展和組合解決的問題不要凈想著改內(nèi)部實(shí)現(xiàn)否則以后 upstream 一更新維護(hù)成本直接翻倍。這三條原則后來證明非常管用。因?yàn)槠ヅ淦鞅旧淼募冞壿嫶a在鴻蒙上幾乎可以原樣運(yùn)行真正需要動刀的是它和系統(tǒng)交互的那幾處薄薄的外殼。2. 整體設(shè)計(jì)把適配層和測試契約框架一起立起來2.1 分層設(shè)計(jì)不把適配邏輯散落一地如果直接把 matcher 里所有涉及系統(tǒng)能力的點(diǎn)一個個打補(bǔ)丁代碼很快就會變成一團(tuán)亂麻。我的做法是引入一個中間抽象層叫它“平臺能力層”好了。所有需要跟系統(tǒng)打交道的能力比如日志輸出、文件寫入、時間獲取、進(jìn)程狀態(tài)查詢都定義成接口然后在鴻蒙上實(shí)現(xiàn)一份在標(biāo)準(zhǔn) Flutter 上再實(shí)現(xiàn)一份。這樣做的好處是測試代碼本身不感知平臺差異只管調(diào)匹配器、拿結(jié)果。底層換實(shí)現(xiàn)的時候上層一行都不用改。適配完成之后我甚至能寫一套“測試結(jié)果收集器”在鴻蒙上把測試報告寫到應(yīng)用沙盒文件里標(biāo)準(zhǔn) Flutter 上則照舊輸出到控制臺。2.2 模塊劃分讓契約框架長在 matcher 身上適配 matcher 只是第一步我更想做到的是把 matcher 的能力進(jìn)一步抽象成一套測試契約框架。所謂測試契約就是一組被命名的、語義清晰的匹配規(guī)則集合。比如“訂單詳情契約”可以定義成一組匹配器描述訂單號、狀態(tài)、金額、商品列表各自應(yīng)該滿足什么條件。業(yè)務(wù)方調(diào)用的時候一行代碼就能校驗(yàn)整個對象結(jié)構(gòu)不需要在測試?yán)飳憥资凶侄伪葘?。模塊上我拆成三層匹配器層直接復(fù)用 matcher 的核心匹配器補(bǔ)上自定義匹配器的實(shí)現(xiàn)。契約層定義TestContract模型一個契約就是一組匹配器加上綁定關(guān)系。報告層把匹配結(jié)果、失敗原因、執(zhí)行耗時格式化輸出支持鴻蒙 hilog 和沙盒文件兩種出口。這個結(jié)構(gòu)的好處是每一層都可以單獨(dú)測試、單獨(dú)替換。比如哪天想換一套更快的序列化協(xié)議來存測試結(jié)果只需要動報告層契約層和匹配器層完全不受影響。2.3 適配難點(diǎn)概覽從編譯到運(yùn)行的隱性障礙我把適配過程中會遇到的坑提前列一下后面再逐個展開純 Dart 代碼依賴了dart:io的exitCode在鴻蒙上該接口表現(xiàn)不一致。matcher 內(nèi)部用print輸出失敗信息在鴻蒙設(shè)備上默認(rèn)不顯示需要接到 hilog。測試用例里的異步等待在鴻蒙的引擎上時序和標(biāo)準(zhǔn) Flutter 有細(xì)微區(qū)別容易出現(xiàn)超時誤判。類型信息在某些匹配器里通過Type對象判斷鴻蒙運(yùn)行時對部分類型的is判斷存在邊界情況。這些問題單獨(dú)拎出來都不大但合在一起足以讓適配工作陷入“能編譯、跑不通”的尷尬境地。3. 核心模塊適配實(shí)戰(zhàn)匹配器、斷言上下文與自定義算法3.1 匹配器基座與語義化 API 的移植matcher 庫的匹配器基座就是Matcher抽象類它有兩個核心方法matches(candidate, matchState)和describe(description)。前者負(fù)責(zé)判斷某個實(shí)際值是否滿足條件后者負(fù)責(zé)把匹配器的語義描述出來。簡單說一個匹配器就是“判別器 自我說明書”的組合。我在移植的時候先把所有基礎(chǔ)匹配器equals、greaterThan、lessThan、contains、hasLength、isA、isNot、allOf、anyOf、throwsA這些逐個過了一遍確認(rèn)它們的核心邏輯不依賴系統(tǒng)環(huán)境然后直接原樣編入適配工程。編譯跑一遍之后發(fā)現(xiàn)絕大多數(shù)匹配器確實(shí)直接就能用因?yàn)樗鼈冏龅闹皇羌冞壿嬇袛嗖慌鑫募慌鼍W(wǎng)絡(luò)不碰進(jìn)程。真正需要在基座層面做適配的是失敗信息的格式化出口。原版 matcher 在斷言失敗時會通過TestFailure攜帶錯誤消息跑測試框架時會自動捕獲并展示。但在鴻蒙上這套失敗消息如果只靠默認(rèn)的print日志系統(tǒng)根本收不到。所以我在適配層重寫了錯誤消息的出口把所有TestFailure的消息統(tǒng)一接進(jìn)一個ResultEmitter再按配置輸出到 hilog 或者本地文件。3.2 自定義匹配器以語義化斷言語義構(gòu)建復(fù)雜校驗(yàn)規(guī)則matcher 一個非常有價值的能力是支持自定義匹配器。比如我需要校驗(yàn)一個“合法的分頁請求條件”這個規(guī)則在業(yè)務(wù)里其實(shí)挺復(fù)雜頁碼不能為 0、每頁條數(shù)必須在 1 到 100 之間、排序字段必須來自白名單。如果用傳統(tǒng)斷言寫測試?yán)锏梅乓欢裪f看得人頭疼。我的做法是定義了一個PageRequestMatcher繼承Matcher把這三條規(guī)則全部封裝進(jìn)去。匹配器內(nèi)部維護(hù)一個規(guī)則列表每一條規(guī)則對應(yīng)一個失敗描述。測試代碼里只要一行expect(request, PageRequestValid());匹配器對的時候安靜地返回 true不對的時候把“頁碼為 0”“排序字段非法”這種具體原因?qū)戇M(jìn) description。這樣業(yè)務(wù)側(cè)的斷言代碼變得非常干凈而且規(guī)則變更時只需要改匹配器本身所有用它的測試自動生效。這里有個實(shí)現(xiàn)細(xì)節(jié)很關(guān)鍵matches方法里除了返回布爾值最好通過matchState把失敗原因帶進(jìn)去。我封裝了一個規(guī)則列表每條規(guī)則校驗(yàn)失敗時往matchState里塞一個錯誤碼然后在describe方法里把錯誤碼翻譯成人類可讀的中文描述。這樣既保留了機(jī)器可讀性又保證了人眼可讀性。3.3 斷言上下文與失敗信息的格式化讓報錯別像天書適配過程中我發(fā)現(xiàn)裸的TestFailure輸出往往非常難用。比如標(biāo)準(zhǔn)庫的equals失敗時默認(rèn)只輸出“Expected: Actual: ”對于復(fù)雜對象來說這等于沒說。所以我在適配時做了一層“斷言上下文增強(qiáng)器”把這些信息包裝進(jìn)一個結(jié)構(gòu)體帶上額外的字段比如匹配器名稱和語義描述實(shí)際值的類型和摘要失敗發(fā)生在契約中的哪個錨點(diǎn)可選的業(yè)務(wù)上下文標(biāo)簽這樣報告層拿到的不再是一行字符串而是一個結(jié)構(gòu)化的失敗事件后續(xù)無論是輸出控制臺、寫入文件還是推送到遠(yuǎn)端匯總都很方便。在格式化這塊我還做了一個中文化適配層。不是把英文報錯翻譯成中文而是給每個匹配器增加了一個獨(dú)立的語義標(biāo)簽比如equals映射為“期望相等”contains映射為“期望包含”這樣測試報告里看到的是一句句流暢的描述對團(tuán)隊(duì)里的非資深開發(fā)同學(xué)友好得多。3.4 測試契約框架的組裝把匹配器串成業(yè)務(wù)規(guī)則契約層做起來其實(shí)不復(fù)雜但設(shè)計(jì)得合不合理差別很大。我的核心模型就三個ContractField描述對象里的一個字段應(yīng)該滿足什么匹配器。TestContract一組字段約束的集合對應(yīng)一個完整的對象結(jié)構(gòu)校驗(yàn)。ContractResult執(zhí)行契約后的結(jié)果包含每個字段的匹配狀態(tài)和失敗詳情。使用的時候先用契約模型描述規(guī)則比如“訂單詳情必須包含合法訂單號、有效金額、非空商品列表”然后在測試?yán)飯?zhí)行final contract OrderDetailContract.build(); final result contract.verify(mockOrderDetail); expect(result.isPass, isTrue, reason: result.failureSummary());契約框架的好處是業(yè)務(wù)規(guī)則沉淀成可復(fù)用的資產(chǎn)新來的同事寫測試不需要重新發(fā)明輪子直接引用現(xiàn)成契約就行。而且契約本身也能被測試覆蓋相當(dāng)于給測試規(guī)則本身再加一層保險。4. 端側(cè)質(zhì)量驗(yàn)證的接入與運(yùn)行不只是在模擬器上跑通4.1 搭建鴻蒙端測試運(yùn)行環(huán)境鴻蒙端跑 Flutter 測試環(huán)境搭建和標(biāo)準(zhǔn) Flutter 差別不小。首先需要確保 Flutter SDK 的鴻蒙版本正確工程配置里要把目標(biāo)平臺設(shè)為鴻蒙并且使用 DevEco Studio 那邊配套的工具鏈進(jìn)行構(gòu)建。我踩過的第一個坑是構(gòu)建產(chǎn)物問題。有些同事先在標(biāo)準(zhǔn) Flutter 工程里寫好測試然后想直接搬到鴻蒙上結(jié)果發(fā)現(xiàn)flutter test在鴻蒙設(shè)備上并不像在模擬器上那樣直接執(zhí)行需要先把測試代碼編譯成鴻蒙的 HAP 包在真機(jī)上以應(yīng)用的形式運(yùn)行。這意味著測試用例的入口和結(jié)果收集方式都得專門設(shè)計(jì)不能指望 Flutter 那一套測試腳手架自動幫你搞定。我的方案是寫一個專門的“測試入口應(yīng)用”它啟動后自動執(zhí)行注冊好的契約測試集成然后把測試結(jié)果寫到沙盒目錄下的 JSON 文件里同時在 hilog 里輸出摘要日志。在真機(jī)上跑完后再通過 DevEco Studio 的日志面板或者命令行把結(jié)果拉出來。4.2 語義化斷言在業(yè)務(wù)側(cè)的真實(shí)落地場景端側(cè)質(zhì)量驗(yàn)證的挑戰(zhàn)在于測試對象往往依賴真實(shí)系統(tǒng)環(huán)境比如網(wǎng)絡(luò)、傳感器、數(shù)據(jù)庫。我在某個模擬項(xiàng)目里要驗(yàn)證“首頁信息流的數(shù)據(jù)結(jié)構(gòu)是否符合契約”直接在真機(jī)上跑稍微處理一下網(wǎng)絡(luò)返回其他邏輯保持不變。落地的時候我先抽取業(yè)務(wù)側(cè)的核心數(shù)據(jù)模型把模型每個關(guān)鍵字段的校驗(yàn)規(guī)則寫進(jìn)契約。比如認(rèn)為“卡片流對象必須包含至少一個有效卡片”“卡片類型必須來自枚舉白名單”“卡片標(biāo)題長度必須在 2 到 40 之間”這些規(guī)則用 matcher 的allOf、isIn、hasLength組合起來非常自然地組成了一套契約。然后在測試用例里用一個假的網(wǎng)絡(luò)層把返回?cái)?shù)據(jù)注入再調(diào)用契約校驗(yàn)。整個斷言寫下來不到十行但覆蓋的規(guī)則有十幾條。在鴻蒙真機(jī)上跑起來穩(wěn)定性和速度都不錯這也讓我對端側(cè)自動化有了更多信心。4.3 真機(jī)運(yùn)行與結(jié)果收集別讓測試結(jié)果淹沒在日志里鴻蒙真機(jī)上跑測試結(jié)果收集是最容易被忽略、又最容易翻車的環(huán)節(jié)。標(biāo)準(zhǔn) Flutter 測試直接輸出到控制臺但鴻蒙上應(yīng)用的控制臺輸出并不等同于設(shè)備日志。我采用的做法是雙通道通道一所有用例的通過/失敗摘要匯總后通過 hilog 打一條精簡日志方便開發(fā)者在 DevEco Studio 的日志面板里快速看到整體結(jié)論。通道二完整測試報告以 JSON 形式寫入應(yīng)用沙盒目錄通過華為提供的文件拉取機(jī)制導(dǎo)出到本地分析。我還加了一個失敗重跑機(jī)制如果某個用例失敗框架會把失敗用例單獨(dú)記錄到持久化文件里下次啟動測試時可以選擇“只重跑失敗用例”省去在真機(jī)上反復(fù)全量執(zhí)行的等待。這個能力在真機(jī)調(diào)試場景下非常實(shí)用因?yàn)檎鏅C(jī)上每次全量執(zhí)行的時間和模擬器完全不是一個量級。5. 常見問題與排查實(shí)錄適配過程中踩過的坑5.1 匹配器偶發(fā)失效類型判斷的邊界情況適配過程中最詭異的一個問題是一個isAOrderModel()匹配器在標(biāo)準(zhǔn) Flutter 環(huán)境下穩(wěn)定通過跑到鴻蒙真機(jī)上卻偶爾返回 false。檢查很久發(fā)現(xiàn)問題出在泛型擦除邊界上。某些情況下從原生層穿過來的對象雖然從業(yè)務(wù)代碼看是OrderModel但運(yùn)行時拿到的Type信息并不精確導(dǎo)致is判斷出現(xiàn)意外的結(jié)果。解決辦法是在匹配器層加一個“類型兜底”機(jī)制當(dāng)isA判斷結(jié)果為 false 時如果對象實(shí)現(xiàn)了某個標(biāo)記接口再走一次字段級的契約校驗(yàn)通過就算匹配成功。這個改動犧牲了一點(diǎn)嚴(yán)格性但換來的是在鴻蒙環(huán)境下的穩(wěn)定性。實(shí)際使用下來并沒有出現(xiàn)該攔住的臟數(shù)據(jù)被放過去的情況。5.2 異步斷言在鴻蒙上的時序問題另一個坑出現(xiàn)在異步斷言上。原版 matcher 的throwsA、completes這類匹配器依賴 Dart 的異步機(jī)制在標(biāo)準(zhǔn) Flutter 里一切正常但鴻蒙的引擎事件循環(huán)在某些邊界情況下會讓異步事件的到達(dá)順序發(fā)生變化表現(xiàn)為“超時誤報”。我的排查思路是把相關(guān)測試的等待時間統(tǒng)一加長同時把斷言方式改成輪詢式校驗(yàn)也就是在超時時間內(nèi)反復(fù)校驗(yàn)條件而不是一次性等待結(jié)果。改完以后異步斷言在真機(jī)上的穩(wěn)定性明顯提升幾乎不再出現(xiàn)偶發(fā)的誤報。5.3 失敗信息輸出亂碼與缺失還有一次排查了很久發(fā)現(xiàn)失敗信息在 hilog 里顯示為亂碼。原因是 matcher 的Description在拼接長文本時用了部分轉(zhuǎn)義符而鴻蒙的 hilog 對某些字符處理得不太一樣。我的解決方式是給報告層加一個清洗函數(shù)把需要輸出的文本先做一次字符規(guī)范化過濾掉容易出問題的控制字符。這個坑雖然不起眼但特別容易讓人懷疑是匹配器邏輯錯誤。排查這類問題建議先把輸出改成寫入文件看原始字節(jié)流到底長什么樣再對比 hilog 收到的內(nèi)容問題就一目了然了。6. 關(guān)于鴻蒙化適配的個人心得與建議這套適配方案做完之后我的一個明顯感受是鴻蒙化適配的難點(diǎn)不在于代碼量而在于對平臺邊界的理解和工程結(jié)構(gòu)的把控。matcher 本身不復(fù)雜但一旦牽扯到端側(cè)運(yùn)行環(huán)境、日志體系、異步機(jī)制、類型系統(tǒng)這些底層差異問題就會變得特別瑣碎。如果沒有一開始就把平臺差異收攏到一個抽象層里后面任何一個細(xì)節(jié)問題都可能讓你翻遍整庫的代碼。如果你也在做類似的三方庫鴻蒙化適配我的建議是先把庫最核心的 API 面梳理清楚搞清楚哪些是純邏輯、哪些依賴系統(tǒng)能力。不要為了“能編譯”而直接打補(bǔ)丁花一點(diǎn)時間設(shè)計(jì)一個適配層長期收益會遠(yuǎn)遠(yuǎn)大于短期省下的功夫。真機(jī)上跑測試和模擬器完全是兩回事早點(diǎn)在真機(jī)上做驗(yàn)證別等項(xiàng)目快交付了再臨時抱佛腳。最后再分享一個細(xì)節(jié)在做自定義匹配器和契約框架的時候給匹配器加語義標(biāo)簽這個習(xí)慣非常值得堅(jiān)持。當(dāng)測試報告里出現(xiàn)“期望訂單狀態(tài)為已支付實(shí)際狀態(tài)為待支付”這樣清晰的描述時團(tuán)隊(duì)里無論是開發(fā)還是測試同學(xué)都能一眼看懂失敗原因。這比任何測試報告美化工具都更實(shí)用。