前端技術發展迅速,即便不說是日新月異,每年也都推出新框架和新技術。Tubi 的產品前端代碼倉庫始建于 2015 年,至今 8 年有余。可喜的是,多年來緊隨 React 社區的發展,Tubi 絕大多數的基礎框架選型都遵循了社區流行的最佳實踐。核心框架和依賴的版本基本都已經或有計劃更新到最新的穩定版本。
能做到這一點,主要得益于 Tubi 小而美的前端團隊有著強烈的技術自驅力;此外,團隊管理層重視工程師文化和技術基礎設施建設,愿意給予團隊不少于 20% 的整塊時間進行功能開發外的必要技術優化和升級。
本文將介紹的 Enzyme 到 React Testing Library(RTL)的遷移發生在 2022 年底,是 Tubi 前端至關重要且有相當工作量的代碼遷移項目之一。
遷移動機
在 Tubi ,即便是社區推薦的技術選型,我們也要先對其必要性和價值做出評估,待達成共識后才會采取進一步的行動。回到 Enzyme 到 RTL 的遷移,主要理由有四個:
第一,Airbnb 官方已經不再活躍維護 Enzyme,且沒有計劃支持最新的 React 18。Tubi 決定升級到最新的 React 18 ,就必須找到并遷移所有 UI 測試到 Enzyme 的替代方案。
第二,RTL 關注于集成測試的設計理念使得團隊可以更輕易、高效地寫出易于維護的測試代碼。
第三,RTL 鼓勵從用戶實際使用角度寫測試用例,因此其 API 設計理念無形中就引入很多 UI 測試的最佳實踐,例如對 Web Accessibility 的關注和強調。
第四,RTL 成熟活躍的社區及輕量的實現機制保證了該測試框架可預期的長久生命力。
關于第二點,相對于大家所熟知的測試金字塔模型,RTL 的作者 Kent C. Dods 提出了與之相對應的測試獎杯模型(Test Trophy),如下圖所示。Kent 認為,應該把主要精力放到寫 UI 集成測試(Integration Test)上,這樣模擬 API 調用和返回結果去整合應用,既可以規避因調用異步 API 導致過慢的測試運行速度,又能從用戶實際使用角度全方位測試應用的功能,做到事半功倍。
圖片來源:testingjavascript.com/
舉例說明,當我們測試頁面渲染時,不僅測試了預期被渲染的 UI 元素,同時自然而然地覆蓋了該頁面數據加載、UI 元素展示和潛在的用戶交互及權限控制。換句話說,我們通過 UI 測試用例,就可以順帶自然而然地觸及更底層的有關 Redux 狀態管理、事件派發和數據組織的功能和邏輯,從而輕松覆蓋原本需要單元測試去檢測的代碼功能和分支條件。因此,UI 集成測試兼顧了測試覆蓋度和測試運行速度,是更有效、值得推薦的書寫前端測試代碼的方式。
第三點中提到的 RTL 先進測試理念在下文 Counter 測試的示例中展示無遺。Enzyme 測試一般會與組件的具體實現細節相綁定。
在示例中,Enzyme 通過 button 對應的 increment
class name 定位到自增按鈕,并通過檢查保存在組件內部 state 中 count 的變化來確定自增計數功能被正確執行。而 RTL 通過 getByLabelText
和 getByRole
?這樣的語義化 API 獲取對應遵循 Web Accessibility 規范的元素,進而通過檢測用戶可見界面的改變而驗證計數器自增功能被如預期執行。因此,RTL 測試有兩個顯而易見的好處:
1. 與 UI 實現細節的解耦讓測試代碼更加健壯,今后實現細節的變更并不會導致測試代碼失效;
2. Web Accessibility 導向的 API 設計鼓勵開發者在實現 UI 組件時遵循 Web Accessibility 規范。
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { shallow } from 'enzyme';
import React from 'react';import Counter from './Counter';describe('Enzyme tests', () => {it('should increment by 1', () => {const wrapper = shallow(<Counter />);const instance = wrapper.instance();expect(instance.state.count).toBe(0);wrapper.find('button.increment').simulate('click');expect(instance.state.count).toBe(1);});
});describe('RTL tests', () => {it('should increment by 1', () => {render(<Counter />);const countLabel = screen.getByLabelText('count');expect(countLabel).toHaveTextContent(0);userEvent.click(screen.getByRole('button', { name: 'Increment' }));expect(countLabel).toHaveTextContent(1);});
});作者:隔壁正在裝修真羨慕
鏈接:https://juejin.cn/post/7260024054066085946
來源:稀土掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
此外,RTL 和 Enzyme 的 npm 下載次數統計也清晰反映出當前 RTL 已經取代 Enzyme,成為 React 前端項目測試框架的不二選擇。因此,我們將 Enzyme 到 RTL 的遷移工作提上了正式日程。
遷移規劃
了解遷移項目規模和整體概況對于我們制定切實可行的遷移規劃和策略至關重要。Tubi 產品前端代碼倉庫有著高達 92% 的代碼測試覆蓋率。依照遷移項目啟動前的統計,我們依賴 Enzyme 的 UI 測試文件總共有 440+ 個(涉及的總代碼行數應該超過 10 萬行)。如果遷移不能很快完成,隨著新 Enzyme 測試的創建,這個工作量還會持續增加。
因此,我們新功能和新組件的開發需要盡早強制使用 RTL 去測試;同時,對于存量 Enzyme 測試,我們需要找到一個漸進的方案實現逐步遷移。一個分而治之的遷移路徑由此浮現,如下圖所示。
驗證可行性
為了進一步驗證 RTL 是否可以匹配我們對新功能新組建的 UI 測試需求,我們決定從實戰出發,將 RTL 應用到我們當時正在開發的世界杯活動頁面上。選用這個頁面做驗證,是本著先難后易的原則。因為這個著陸頁足夠復雜,涉及響應式 UI 、deep links 調用、API mocks 、用戶交互和頁面跳轉等眾多復雜場景的測試。我們認為,如果 RTL 可以很好地滿足在復雜場景下的測試,那么相對簡單的 UI 測試就更不成問題了。
最終的結果令人滿意,RTL 可以完美適配上述測試場景。相對于 Enzyme 測試,RTL 測試的實現更加簡潔高效,且測試運行時間并沒有顯著改變。同時,我們還封裝了適配 Redux、react-intl 等第三方庫初始化的自定義渲染(render)方法,并沿用?Nock?在 API 層面去模擬 API 的響應結果。這一切,都讓我們得以在盡量維持技術選型和依賴不變的基礎上,做到相對嚴格地遵循 RTL 官方推薦實踐和理念。這些成功而積極的正向反饋堅定了團隊向 RTL 測試遷移的信心。
引入 Lint 和進度統計腳本
延續分而治之的策略,在大規模遷移已有測試前,我們實現了一個自定義的 ESlint 規則并將其添加到 Github CI 中,以確保停止添加新的 Enzyme 測試這一共識被貫徹執行。下圖中,Lint 規則的?startDate
?被設為 2022-12-16,這意味著在該日期后引入的新 Enzyme 測試會引發 Lint 報錯。
同時,為了方便掌握遷移進度,并快速定位尚未遷移的代碼,我們在項目初期就實現了名為?rtl-migrate
的 npm 命令以輔助遷移。如下圖所示,運行 yarn run rtl-migrate
?可以獲知 Enzyme 測試的整體遷移進度和選定文件夾的遷移概要。運行 yarn run rtl-migrate -p src/ott --type=enzyme
?將打印出 src/ott?
目錄下所有尚未遷移的測試文件。此外,該命令還實現了找出測試文件核心貢獻者和根據文件大小過濾排序的功能,這些都為我們分配遷移任務給最恰當的開發者提供了數據參考。文章長度所限,這里不一一展示。
通過 Codemods 自動遷移
根據最初的分析,我們有不少于 440 個測試文件需要遷移。即便按平均每個文件僅 250 行來做最樂觀的估算,整個遷移涉及的測試代碼改寫量將不少于 11 萬行代碼。這無疑是一個巨大且可能曠日持久的工程。為了盡可能縮短工期,我們決定基于 jscodeshift 構建自己的 Codemods 腳本,以便盡可能自動遷移典型的 Enzyme 代碼模式到 RTL 測試,從而節省人工遷移的成本。
選擇自己構建 Codemods 腳本,是因為在開源社區中我們并沒有找到符合要求的現成 Enzyme 遷移腳本。同時考慮到我們項目對 Enzyme 的獨有封裝和擴展,如果希望盡可能靈活精準地將更多 Enzyme 測試自動轉化為 RTL 測試,構建可以完全掌控的 Codemods 腳本勢在必行。
當然,我們并不期望 Codemods 可以遷移所有測試用例。務實的期望是 Codemods 能以最小代價優先覆蓋代碼倉庫中最常見的測試用例和代碼模式。技術選型和決策同樣需要衡量投入產出比和最終收益。平衡和取舍往往是技術決策的關鍵詞。尤其對于測試遷移這類一次性的工作而言,實現一個面面俱到的 Codemods 腳本并不是我們的初衷。重點是,我們須確保通過 Codemods 自動遷移帶來的時間節省收益大于開發 Codemods 付出的精力消耗。
因此,我們優先找出了 Enzyme 測試中最常見的幾種代碼模式及其變形,并實現了對應的 Codemods 使其可以被自動轉換。這一過程切合我們常說的二八原則,即通過 20% 的成本完成了 80% Codemods 的預定目標,剩余 20% 的功能也許我們可以繼續投入 80% 的時間去打磨。考慮到遷移工作一次性投入的特性,對非常見的測試模式我們并沒有強求在 Codemods 層級做自動遷移的支持。
回到具體實現,本質上 Codemods 先將特定代碼片段轉化為抽象語法樹,進而識別并修改特定語法樹的結構后再重新生成新的代碼片段。因此,構建諸如遷移 Enzyme 測試這樣功能復雜的 Codemods 時,對 Codemods 功能的拆解和組織尤其重要;否則,復雜性的不斷疊加終將導致 Codemods 難以維護。實現之初,我們便仔細設計了?rtl-codemod
?代碼組織關系和 transformer 間的通信存儲機制。具體說明如下圖。
這里的關鍵詞是解耦,可以將各 transformer 想象為獨立、目的明確的插件或中間件,它們可以互不干擾地獨立運行;最終 Codemods 對代碼的修改是這些插件按順序執行的結果。我們設計?rtl-codemod
?時,進一步引入了 motions 這個 micro-transformer 的概念,從而構建了兩層的插件體系結構,做到了 tranformer 功能的進一步拆解和靈活組裝。
作為 Codemods 執行入口的核心,transform 只重點負責配置各 transformers 的執行順序。其實現大體精簡如下:
const transform: Transform = (file, api, options) => {const j = api.jscodeshift;const { source } = file;const ast = j(source);// NOTE: The order of motions is important. Some motions need to be// applied before others.applyMotions(j, ast, [...renderMotions,...snapshotMotions,...findTextMotions,...inTheDocumentMotions,...userEventMotions,// More transform-related motions...]);return toSource(ast, options.toSourceOptions);
};
此外,作為腳本程序,某 transformer 運行時結果和中間計算產出可以通過持久化存儲被其他 tranformers 從特定存儲中讀取。換言之,存儲也可以被理解為 transformer 實現通信和數據共享的媒介。
更多細節可以參考我們開源出來的精簡示例實現:github.com/nickqi-tubi…
妥協和適配
做技術決策時,經常會不得不選用一些退而求其次的妥協方案,但這并不完全是一件壞事。相反,為了更好地適配現有代碼和模式而主動做出的妥協,往往是一種務實而精明的抉擇。
以 Enzyme 遷移為例。
我們在 Enzyme 測試中大量使用了?wrapper.instance()
這種獨有 API 去檢測組件的期望實例值。這是 Enzyme 測試中常見且官方推薦的模式之一。與之相反,RTL 明確反對針對實現細節設計任何測試用例,這也是 RTL 未曾暴露獲取組件實例或內部狀態的 API 的原因。
RTL?官方建議
You want your tests to avoid including implementation details so refactors of your components (changes to implementation but not functionality) don't break your tests and slow you and your team down.
RTL 的設計理念和建議無疑是有道理的。但回到測試遷移這個任務上,考慮到我們已經有至少十萬行代碼基于 Enzyme 實踐,去除對?wrapper.instance()
的依賴意味著我們需要徹底重寫大量測試,這無疑極大加劇了測試遷移的實現成本而難以推進。務實的考慮是,我們需要為 RTL 提供一種橋接,使 RTL 測試依然可以適配現存針對組件實例和內部狀態的測試。
基于上述原因,我們實現了如下所示的?renderWithInstance
?工具方法:
function renderWithInstance(passedComponentOptions,renderOptions
) {let incorrectlyUseInstancePattern;let WithExtendedClass;const { extendingClass } = passedComponentOptions;try {if (extendingClass.prototype.isReactComponent) {WithExtendedClass = class WrapperInstance extends extendingClass {constructor(props) {super(props);incorrectlyUseInstancePattern = this;}};} else {WithExtendedClass = class WrapperInstance {constructor(_props: any) {incorrectlyUseInstancePattern = this;}};Object.setPrototypeOf(WithExtendedClass, extendingClass);}} catch (e) {throw new Error(`Problem extending passed 'extendingClass'.\n${e.stack}`);}const renderResult = render(<WithExtendedClass {...passedComponentOptions.props} />,{wrapper: getWrapper(renderOptions),...renderOptions,});return {...renderResult,incorrectlyUseInstancePattern,};
}作者:隔壁正在裝修真羨慕
鏈接:https://juejin.cn/post/7260024054066085946
來源:稀土掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
通過在遷移后的 RTL 測試中調用 renderWithInstance
,我們使自定義的 RTL 渲染方法依然可以暴露組件實例對象而盡量復用已有的檢測條件。
it('should call handleUpdateA11y when activeIdx changes', () => {const updateA11ySpy = jest.spyOn(incorrectlyUseInstancePattern, 'handleUpdateA11y');const {incorrectlyUseInstancePattern} = renderWithInstance({extendingClass: AlertModal,props: getProps()});incorrectlyUseInstancePattern.componentDidUpdate({}, { activeIdx: 1 });expect(updateA11ySpy).toHaveBeenCalled();
});作者:隔壁正在裝修真羨慕
鏈接:https://juejin.cn/post/7260024054066085946
來源:稀土掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
時間和成本開銷
Tubi 前端團隊從?Enzyme 到 RTL 的測試遷移工作耗時約兩個半月。期間,我們創建并完成了 109 個與之相關的開發任務(stories),共遷移了 466 個測試文件和 10?余萬行的測試代碼,項目燃盡圖如下。
其中,綠色表示 Stories 完成的統計出現驟減,是因為在 Shortcut 系統中,只有對應的代碼真正發布到生產環境后才被統計為完成;但實際的遷移工作每天都在推進,而非一蹴而就。
通過燃盡圖,我們可以看到 Tubi 從 2022 年 9 月已經開始考慮這一遷移工作。當時只是創建了用于概念驗證的前期準備工作和團隊內部的分享討論。11 月底,借著圣誕和新年期間線上代碼凍結的時機,我們正式將測試遷移排上日程。
2022 年 11 月底到 12 月中旬,我們投入了近一個半的全職前端工程師(兩位負責遷移項目的前端工程師,每人投入約 70% 的時間推進遷移工作),構建輔助遷移的 Codemods 、進度報告腳本和 Lint 工具。同時,他們還嘗試手動遷移代碼庫中的一些典型測試,以便了解遷移的難點并找到對應的解決方案。
2022 年 12 月底,隨著輔助工具的完善和對遷移難度的掌控,我們對其他長期工作于 Tubi 產品前端代碼倉庫的工程師進行了 RTL 測試和遷移方面的培訓。之后,將需要遷移的測試文件按對所涉及代碼的熟悉程度和工作量進行了統一分配。2023 年 2 月中旬,我們實際已經完成了全部的遷移工作,燃盡圖在 2 月底上線時將已經合并的測試代碼記為完成。
經驗和總結
大多數工程師(尤其是前端工程師)希望緊追技術潮流,使用最新最酷的技術,但對于已有代碼倉庫而言,框架和依賴遷移的成本不容忽視。成熟的工程團隊需要平衡利弊、明確投入產出比和必要性后做出理智的決策。推進遷移時,尋求漸進而為的方式應被視為基本策略。當然,對新舊代碼分而治之的差異化對待經常是為了快速推進而做出的必要妥協。
另外,本文中提到的 Codemods 和進度報告腳本等工具也都是輔助遷移的必要前期開發。考慮到遷移往往是一次性的工作,因此對輔助工具開發上的投入,我們認為二八原則依然適用,應盡可能花少量時間滿足多數需求,無需做到面面俱到。
如果有機會重新主導一次 RTL 測試遷移項目,以下兩點依然有改進空間:
1. Codemods 的范圍需要提前界定
雖然我們一直在強調二八原則和避免在一次性的 Codemods 工具上做過度投入,但工程師追求完善自動化工具的天性讓我們依然在 Codemods 上花費了比預期更多的時間,實現了對相對不常見的代碼模式及其變形的支持。預先引入團隊計劃和評估決策流程,將有效地避免這一問題。
2. 理應更早引入團隊參與,加速項目進展
在項目后期,我們等 Codemods 相對穩定后才請更多團隊成員參與進來。回頭看,這種瀑布式的開發流程,使整個項目周期拉長了。更好的做法是我們預先確定測試遷移的典型代碼模式,并對團隊做預先培訓。即便 Codemods 沒有完全完成,我們依然可以動員整個前端團隊,盡早開始手動遷移那些 Codemods 計劃中不會支持的代碼模式,做到項目的并行推進。
總體而言,此次測試遷移工作為 Tubi 前端團隊掃清了之后升級最新 React 18 的障礙。在短短兩個月,我們完成了 466 個測試文件和 10 余萬行測試代碼的遷移,這無疑是一個值得稱道的成績。同時,受益于 React Testing Library,團隊實現 UI 測試代碼的效率和對 Web Accessbility 的重視得到了極大提升,這也印證了我們對技術項目投入的價值與收益。
作者:Nick QI,Tubi Staff Software Engineer
歡迎加入 Tubi
如果你對類似項目感興趣,歡迎加入 Tubi!?