前端測試體系和最佳實踐

前言

我曾經在好幾個項目裏都近乎完整參與過補齊前端測試的工作,也收集到不同項目的同事很多關於前端測試的困惑和痛點,這其中大部分都很相似,我也感同身受,在這篇文章裏,我會針對大家和自己常遇到的痛點分享一些自己的經驗,如果你也有如下相似的困擾,那希望這篇文章能對你有些幫助~ 常見問題(排名不分先後):

在分享問題的相關經驗之前,我們先來梳理一下前端測試體系~

前端測試體系

前端測試的重要性

這其實跟所有測試的重要性是一樣的,大家有這麼多的痛點也是因爲知道覆蓋全面的測試可以對代碼質量更有保證,讓我們更有信心地去重構代碼,也能幫助我們更方便地瞭解現有的功能細節,甚至是一些極端的邊界情況。而且在大家合作開發項目代碼的過程中,測試可以幫助我們更早地發現錯誤,減少時間成本,提高交付效率。

前端測試方法論(TDD vs. BDD)

這兩個常見的測試方法論在這裏簡單介紹一下,就不大篇幅展開了。TDD - (Test-Driven Development 測試驅動開發)簡單地說就是先根據需求寫測試用例,然後實現代碼,通過後再接着寫下一個測試和實現,循環直到全部功能和重構完成。基本思路就是通過測試來推動整個開發的進行。BDD - (Behavior Driven Development 行爲驅動開發) 其實可以看做是 TDD 的一個分支。簡單地說就是先從外部定義業務行爲,也就是測試用例,然後由外入內的實現這些行爲,最後得到的測試用例也是相應業務行爲的驗收標準。

前端測試的分層

在這裏借一下前端大牛 Kent C. Dodds 的獎盃分層法來引出常見的分類:

(圖片出處:https://kentcdodds.com/blog/static-vs-unit-vs-integration-vs-e2e-tests)

端到端測試 End to End Test

端到端測試一般會運行在完整的應用系統上(包括前端和後端),包含用戶完整的使用場景,比如打開瀏覽器,從註冊或登錄開始,在頁面內導航,完成系統提供的功能,最後登出。

有時,我們也會在這裏引入可視化用戶界面測試,即一種通過像素級比較屏幕截屏來驗證頁面顯示是否正確的測試。目的是確保界面在不同設備、瀏覽器、分辨率和操作系統下與預期的樣式一致。可以設置一定的偏差容忍值。這一層的測試成本較高,所以通常重心會放在確保主流程的功能正常上。常用工具:Cypress、Playwright、Puppeteer、TestCafe、Nightwatch (下載量對比)

集成測試 Integration Test

集成測試主要是測試當單元模塊組合到一起之後是否功能正常。在不同的測試上下文下可能有不同的定義,在前端測試這裏通常指測試集成多個單元組件到一起的組件。

單元測試 Unit Test

單元測試就是對沒有依賴或依賴都被 mock 掉了的測試單元的測試。在前端代碼裏,它可能是:

靜態代碼測試 Static Test

主要是指利用一些代碼規範工具 (Lint Tool) 來及時捕獲代碼中潛在的語句錯誤,統一代碼格式等。這裏就不展開了。常見工具和實踐有:Eslint + Prettier 代碼規範和樣式統一 husky + lint-staged (gitHooks 工具)可以自動在 commit 和 push 之前進行代碼掃描,阻止不規範代碼進入代碼庫,也可以設置在 push 之前跑一遍前端測試

前端測試策略

還是這張圖,我標記了一下:

在獎盃的形狀上每一層佔的面積代表了應該投入的重心比例。這裏集成測試的比重比單元測試大是因爲集成測試可以在成本很高的 e2e 測試和離最終用戶行爲較遠的單元測試之間取的一個平衡,它可以寫的很接近最終用戶的行爲,成本又相對的沒那麼高,屬於性價比很高的一部分。所以集成測試有一些原則

對於單元測試來說:

前端測試工具的分類

測試啓動工具 (Test Launchers)

測試啓動工具負責將測試運行在 Node.js 或瀏覽器環境。形式可能是 CLI 或 UI,並結合一定的配置。常見工具有:Jest / Karma / Jasmine / Cypress / TestCafe 等

測試結構工具 (Structure Providers)

測試結構工具提供一些方法和結構將測試組織的更好,擁有更好的可讀性和可擴展性。如今,測試結構通常以 BDD 形式來組織。測試結構如下方 Jest 例子:

// Jest test structure
describe('calculator', () => {
  // 第一層級: 標明測試的模塊名稱
    beforeEach(() => {
        // 每個測試之前都會跑,可以統一添加一些mock等
    })
    afterEach(() => {
        // 每個測試之後都會跑,可以統一添加一些清理功能等
    })
  describe('add', () => {
    // 第二層級: 標明測試的模塊功能分組
    test('should add two numbers', () => {
       // 實際的描述業務需求的測試
       ...
    })
  })
})

常見工具有:Jest / Mocha / Cucumber / Jasmine / Cypress / TestCafe 等

斷言庫 (Assertion Functions)

斷言庫會提供一系列的方法來幫助驗證測試的結果是否符合預期。如下方的例子:

// Jest expect (popular)
expect(foo).toEqual('bar')
expect(foo).not.toBeNull()

// Chai expect
expect(foo).to.equal('bar')
expect(foo).to.not.be.null

常見工具有:Jest / Chai / Assert / TestCafe 等

Mock 工具

有的時候我們在測試的時候需要隔離一些代碼,模擬一些返回值,或監控一些行爲的調用次數和參數,比如網絡請求的返回值,一些瀏覽器提供的功能,時間計時等,Mock 工具會幫助我們更容易的去完成這些功能。常見工具有:Sinon / Jest (spyOn, mock, useFakeTimers…) 等

快照測試工具 (Snapshot Comparison)

快照測試對於 UI 組件的渲染測試十分有效。原理是第一次運行時生成一張快照文件,需要開發人員確認快照的正確性,之後每一次運行測試都會生成一張快照並與之前的快照做比較,如果不匹配,則測試失敗。這時如果新的快照確實是更新代碼後的正確內容,則可以更新之前保存的快照。(這裏的快照通常都是框架渲染器生成的序列化後的字符串,而不是真實的圖片,這樣的測試效率比較高)這裏可以參考 Jest 官方的用例。常見工具有:Jest / Ava / Cypress

測試覆蓋率工具(Test Coverage)

測試覆蓋率工具可以產出測試覆蓋率報告,通常會包含行、分支、函數、語句等各個維度的代碼覆蓋率,還可以生成可視化的 html 報告來可視化代碼覆蓋率。如以下的 Jest 內置的代碼覆蓋率報告:

(圖片出處:https://jestjs.io/)常見工具有:Jest 內置 / Istanbul

E2E 測試工具(End to End Test)

上面在測試分層裏介紹過的。

可視化用戶界面測試(Visual Regression)

也在上面的測試分層裏介紹過。通常會和 e2e 測試工具組合在一起使用,一般主流的 e2e 測試工具也會有對應的庫去進行可視化用戶界面測試。

前端框架專屬測試庫

不同的前端框架還會有一些自帶的或推薦的測試庫,比如:React: React 官方的 Test Utils / Testing Library - React(推薦) / Enzyme (基於上面的測試策略,更推薦 React Testing Library,Enzyme 暴露了太多內部元素用來測試,雖然一時方便,但遠離了用戶行爲,之後帶來的修改頻率也比較高,性價比低)Vue: Vue 官方的 Test Utils / Testing Library - VueAngular: Angular 內置的測試框架 (Jasmine) / Testing Library - Angular

前端測試框架

基於上面的分類,大家可能發現幾乎哪哪都有 Jest,這類大而全的前端測試工具我們也可以稱爲前端測試框架。常見的有:Jest:大力推薦,幾乎有測試需要的所有工具,社區活躍,網上資源豐富,也是 React 官方推薦的測試框架 Mocha:雖然也功能豐富,但沒有斷言庫、測試覆蓋率工具和 Mock 工具,需要和其他第三方庫配合使用 Jasmine:比較老派的工具,功能也沒有 Jest 豐富,下載率逐年下降最後附上一張 stateOfJS 網站 2021 年的測試庫滿意度圖表供大家參考

(圖片出處:https://2021.stateofjs.com/en-US/libraries/testing/)

前端測試的常見問題

終於回到最開始的問題了,分享一下我的經驗和通常的解決辦法:前端測試感覺寫起來很複雜,會花很多時間,甚至經常是業務代碼時間的好幾倍這個問題可以分成三部分來下手:優化測試策略可以根據剛纔的測試策略部分,結合自己項目的實際情況,調整一下在不同的測試層分配的重心,定一下自己項目每個層級的測試粒度,這樣才能在保證交付的前提下達到測試信心值收益的最大化提升寫測試效率

  1. 抽取公共的部分,使具體的測試文件簡潔
  1. 統一測試規範,有優化及時重構所有測試,這樣大家可以放心的參考已有測試,不會有多種寫法影響可讀性

提升運行測試的效率

  1. 並行跑測試

  2. 測試裏常用如下方法使待測的異步請求返回,通常也會給 setTimeout 一個等待時間,大部分的情況 0 就可以達到目的了,除非是邏輯真的要等待一定的時間,如果默認值都設置的比較大,每個測試都會耽誤一些時間,加起來對測試運行性能的影響是很大的

// testUtils.js
export const flushPromises = (interval = 0) => {
  return new Promise((resolve) => {
    setTimeout(resolve, interval);
  });
};

// example.test.js
test('should show ...', async () => {
  //render component
  await flushPromises();
    //verify component
});

前端測試怎麼 TDD

通常問這個問題背後隱藏的問題是前端很難先寫測試,再寫實現。確實我也有同感,如果是一些 util/helper 方法是可以很容易的遵循 TDD 的步驟的,但當涉及頁面結構和樣式的時候,很難在寫測試的時候就想清楚頁面到底有哪些具體的元素,用到哪些需要 mock 的模塊。

所以在測試 UI 組件時,我通常會使用 BDD 的方式,具體步驟是:

  1. 建立組件文件,渲染返回空

  2. 建立測試文件,先寫一個 snapshot 測試,測試會通過,生成一個 snapshot 文件

  3. 再根據這個頁面 mockup 上已知的交互寫好 test case,通常這個時候不太容易寫實現,就先把測試用例都寫好,test 先 skip 起來,eslint 可以設置成 skip 的 test 用 warn 來展示,這樣之後方便補全

// Jest
describe('todo component', () => {
  test('should show todo list', () => {
     // Snapshot test
        const tree = renderer.create(<Todo />).toJSON();
    expect(tree).toMatchSnapshot();
  })

  test.skip('should add todo when click add and input todo content', () => {
  })

  test.skip('should remove todo when click delete icon of todo item', () => {
  })
  1. 隨着頁面重構,可能會給組件添加 props,這時也需要給不同的 props 添加 snapshot 測試或交互測試

  2. 最後可以根據測試跑完的測試覆蓋率報告看看是否覆蓋全面了,防止有遺漏

當然隨着前端代碼寫的越來越熟練,爲了提升效率,有時會簡化步驟,等一個小功能的組件都重構完了,樣式調好了,所有的子組件都抽完了,再根據每個組件的 props 和交互的點批量加測試,最後用測試覆蓋率來驗證是否都覆蓋到了,保證自己新寫的組件都儘可能是 100% 的覆蓋率。

測試一些第三方 UI 控件時,特別難模擬與之的交互

這個是我也很頭疼的問題,有的時候一些第三方組件因爲要實現一些複雜的效果,會使用不一樣的方式去監聽事件。比如我們有一個 Vue 項目上用到了 element-ui 的 select 組件,這個組件可以通過:remote-method 屬性開啓異步發請求加載選項的功能,測試裏想模擬異步拿到選項後並選擇某選項,就需要想辦法觸發它的@change 事件,通常一條await ***fireEvent***.update(input, 'S'); 就搞定了,但這個怎麼都不生效,仔細的查看它的實現才發現需要這麼一串操作才能觸發到@change 事件。

const input = getByPlaceholderText('Please input to search');
await fireEvent.click(input);
await fireEvent.keyUp(input, { key: 'A', code: 'KeyA' });
await fireEvent.update(input, 'A');
await flushPromises(500); // 這個方法上面有介紹,的作用是讓異步的代碼返回結果,並且等待500ms,因爲源碼有500ms的等待,這裏就也需要等待
await fireEvent.click(getByText('Apple'));

這裏我總結的經驗就是:

有些東西不知道怎麼 mock,比如時間,瀏覽器全局變量(window.location,local storage)等

這個可以結合使用的測試工具去搜索,一般都會有很多現成的解決方案,在這裏舉兩個例子:

Mock navigator.userAgent

// jest.setup.js
Object.defineProperty(
  global.navigator,
  'userAgent',
  ((value) => ({ 
        get() { return value; }, 
        set(v) { value = v; },
  }))(global.navigator['userAgent']),
);

// example.test.js
test('should show popup in Safari', () => {
  global.navigator.userAgent = 'user agent of Safari ...';
    // render and verify something
});

Mock window.open

//jest.setup.js
Object.defineProperty(
  window,
  'open',
  ((value) => ({
        get() { return value; }, 
        set(v) { value = v; },
  }))(window.open),
);
// example.test.js
test('should ...', () => {
    window.open = jest.fn();
    // render something
    expect(window.open).toBeCalledWith('xxx', '_blank');
});

測試裏準備數據,mock 依賴的代碼特別長,真正的測試代碼很靠後,要翻很久,不容易定位

上面有介紹,可以將公共的部分抽取出去,又能減少代碼重複,又能提升寫測試的效率比如準備數據的部分可以抽成公共的 fixture 文件,提供方法生成默認的數據,也可以通過參數去覆蓋修改部分數據,達到定製化的目的

export const generateUser = (user = {}) => {
  return {
    id: 1,
    firstName: 'San',
    lastName: 'Zhang',
    email: 'sanzhang@test.com',
    ...user,
  };
};

跑測試時會冒出很多 Error 或 Warn Log,好像不影響測試通過,修起來也很花時間,還用修麼?

測試裏的報錯通常都很有價值,需要重視。這裏面的錯誤有可能是:

雖然有的時候也會有一些由於第三方庫的原因引起的無法修復又沒有影響的 log,可以忽略,但測試裏大部分警告 Log 其實都是可以修復的,甚至在修復後可能得到意想不到的受益,比如發現真正業務代碼的問題,測試不再隨機掛了,測試運行性能提升了等等。

總結

對於前端測試,我覺得重心不是機械的去追求測試覆蓋率,而是儘可能的在成本和信心值中間找到一個平衡,應用一些好的實踐去降低寫測試的成本,提升寫測試帶來的回報,讓大家對於項目質量越來越有信心。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Q-vrp4XWllmt3wBK7xX_Pw