动态

详情 返回 返回

告別代碼焦慮,單元測試讓你代碼自信力一路飆升! - 动态 详情

本文由體驗技術團隊董福俊原創。

背景

一次偶然,我看到了 Kent C. Dodds 的文章中的一個觀點:寫測試代碼的原因,是為了獲得對自己代碼的信心。我覺得深有感觸,於是翻看了kent的所有文章,結合我自己的開發體會,總結了一些關於前端單元測試的觀點。

認識單元測試

單元測試是什麼?

單元測試(UT)是測試系統中的一環,測試系統還包含很多其它環,例如:端到端測試E2E、集成測試Integration、靜態檢查Lint。

日常工作中,每個業務版本都在進行着這樣的一個流程:需求分析 > 代碼設計 > 開發 > 測試 > 上線。其中,測試環節是為了檢查代碼是否符合預期、是否能正常工作。對於前端而言,測試手段至少有這4種:

  1. 端到端測試(End to End,即E2E):

用一個機器人/腳本,完全模仿真實用户去訪問整個系統。(前後端都被覆蓋)

  1. 集成測試(Integration):

Mock掉後端、可能也Mock掉前端部分耗時操作(eg:動畫),測試前端部分的輸入/輸出是否正確。(前端被覆蓋)

  1. 單元測試(Unit,即UT):

針對一個代碼模塊、代碼函數,測試輸入/輸出是否正確。(某個模塊被覆蓋)

  1. 靜態檢查(Static):

語法檢查,TypeScript、ESLint等。

這4種測試手段的成本不同,收益也不同。越往上信心越大。但同時,實施開銷也越大,寫用例和維護用例的時間越長,用例越容易崩,且崩了的定位和修復精力耗費越多,用例執行速度也越慢。

那麼,什麼時候應該使用 UT呢?這取決於我們要針對的場景。這種選擇,本質上是在做成本和收益的權衡

不同的手段有不同的擅長點,選擇正確的測試策略是大前提。這就像刷牆,面對一面凹凸不平的牆,如果只用滾筒刷,那麼細節將得不到覆蓋;如果只用小刷子,那麼人會被累死。

而我們搭建測試系統,也是這個道理:

  1. 如果想測試商品購買頁中,組合商品的價格是否正確,我們應該選擇 E2E
  2. 如果想驗證參數配置頁中,參數間的關聯關係是否正確,我們應該選擇 Integration
  3. 如果想驗證 i18n模塊,在不同語種下取詞是否正確,我們應該選擇UT
  4. 如果想檢查 format函數的所有調用點,是不是都傳入了一個string,我們應該選擇Typescript

所以,在決定使用UT之前,梳理一下自己的業務,列出最重視的功能,思考最痛的痛點,給它們找一個合適的測試手段。不要企圖用一種手段去解決所有的問題,而應該組合使用這些手段來編織一張防護網:通過E2E去覆蓋核心功能點,通過Integration去覆蓋前端邏輯,通過UT去覆蓋核心模塊,通過靜態檢查去守護每一行代碼

(PS: 或許不同的人會有不同的分類觀點,但怎麼分類並不是關鍵,關鍵是我們應該組合使用各種手段,用UT去做UT最擅長的事兒,而不是一招鮮吃遍天的哪哪都靠UT)

單元測試應該測什麼?

單元測試不應該測細節,搞清楚我們的“用户”是誰,“用户”怎麼用,我們就怎麼測。

由於代碼覆蓋率指標的驅動,我們很容易走進測細節的死衚衕。例如,針對下面這段代碼寫UT,可能會寫出這樣的測試用例

// 這個函數用來過濾undefined和null
function filterEmpty(arr) {
  if (Array.isArray(arr)) {
    return arr.filter(item => item);
  } else {
    return [arr].filter(item => item);
  }
}
// 用例1:要想進if,arr必須是個數組
expect(filterEmpty([1, 2, 3])).toEqual([1, 2, 3]);
// 用例2:要想進else,arr必須是非數組
expect(filterEmpty(1)).toEqual([1]);

這裏,雖然2個用例覆蓋了被測代碼的所有行,但arr中如果有0、空字符串、false 也會被過濾掉。所以,針對代碼細節寫測試,即使覆蓋率100%,也無法給我們提供足夠的信心。這是因為,測代碼細節,無法避免假正確(用例跑過了,但功能不通)和假錯誤(用例沒通過,但功能是好的) 。這種用例沒法給我們帶來代碼信心,只會徒增工作量,而這可能是很多人不喜歡寫UT的原因。

那麼,如何避免陷入測細節的陷阱呢?答案是:搞清楚我們的“用户”是誰。如果被測模塊是個交互組件,那麼用户可能是真實的界面使用者;如果被測的是一個工具模塊,那麼用户可能是模塊調用者;更多時候,是二者的混合情況。對於真實用户場景,想想他們可能會輸入什麼?可能會點擊什麼?再想想此刻我們的組件應該作何表現。對於模塊調用場景,想想調用點可能輸入什麼?預期獲得什麼樣的返回?再想想我們的模塊應該作何表現。

例如,上面的函數,從用户的視角,可以這樣寫

// 這個函數用來過濾undefined和null
function filterEmpty(arr) {
  if (Array.isArray(arr)) {
    return arr.filter(item => item);
  } else {
    return [arr].filter(item => item);
  }
}
// 用例1:用户輸入的數組可能包含各種基本類型的數據,其中應該只有undefined和null被過濾掉
expect(filterEmpty([0, 1, 'abc', '', undefined, null, false, true, NaN])).toEqual([0, 1, 'abc', '', false, true, NaN]);
// 用例2:輸入非數組時,如果是undefined 或 null,則應被過濾掉,其它類型的值應該通過
expect(filterEmpty(undefined)).toEqual([]);
expect(filterEmpty(null)).toEqual([]);
expect(filterEmpty(0)).toEqual([0]);
// ...

始終記住:不要追求代碼覆蓋率,而應該追求用例覆蓋率。

但很可惜,當前沒有一個 用例覆蓋率 統計工具,有的往往是行覆蓋率、分支覆蓋率,這就很容易導致我們陷入測細節的陷阱。但轉變一下思維:如果從用户視角出發來寫用例,最終結果應該就是100%的行覆蓋率&分支覆蓋率。倘若用例已經做到了100%的覆蓋使用場景,而行覆蓋率還沒達到100%,只能説明這裏面有冗餘代碼&分支!

如何看待TDD?

僅在當我們覺得TDD(測試驅動開發)能提升我們的效率時,才使用它。

我們在前面的討論,都是希望單元測試能幫我們攔截代碼問題,這是從防守方的視角來看待單元測試。但TDD(測試驅動開發)的觀點認為,業務開發應該先寫用例再寫代碼,通過用例去指導開發。

TDD一般包含上圖的這3個步驟:

  1. 先寫測試用例,此時用例會執行失敗(紅圈)。因為業務代碼還沒寫呢
  2. 再寫業務代碼,讓用例能通過(綠圈)
  3. 審視剛寫的代碼,看是否能優化重構(藍圈)

如此往復循環,直到需求開發完成,用例也覆蓋完成。

其實TDD的好處是:強迫我們必須從用户視角出發來寫用例。因為在寫用例時,代碼還不存在呢!但是TDD循環要能順利完成,是有前提的:

  1. 首先,要能根據業務需求,設計出合理的代碼結構(eg:拆分哪些單元 module/class/function?它們分別承載什麼功能?它們的輸入輸出是怎麼樣的?)
  2. 其次,根據各個單元的功能和輸入輸出,設計UT用例
  3. 第三,對代碼架構和UT框架足夠了解,用例失敗時能迅速搞清楚,是代碼的問題還是用例本身寫錯了
  4. 最後,也是最重要的,要有足夠的試錯時間

所以,我的觀點是:只要我們明白單元測試用例應該是從用户視角出發來寫就可以了。

TDD固然是好,但前置條件也比較多,不要硬上。我們可以從簡單的bugfix開始,嘗試使用TDD,等我們變的熟練了,並且發現自己喜歡這個模式,再投入到需求開發。

(PS:TDD作為一種方法論,其實施效果是因人而異的,我們應該去了解它,但不應該無腦硬上。我們可以先找簡單場景嘗試一下,看看是否合自己的口味,再決定是否擴大使用)

單元測試框架的組成

前面的討論中,我們直到了UT是測試系統的一環,應該在合適的地方使用它。寫UT時應該瞄準用户,而不是瞄準代碼細節。下面我們瞭解一下UT大概長什麼樣,UT框架大概是什麼樣。

一個典型的UT代碼

一個前端單元測試腳本,本質還是一個js文件,只不過多了一些全局變量而已:describe、beforeAll、afterAll、beforeEach、afterEach、test、expect ...

一個單元測試腳本,寫出來大概會是這種形態:

// userService.test.js - 測試套件
import { UserService } from './userService';
describe('UserService 類測試', () => {
  let userService;
  let testUser;
  // 整個測試套件前執行一次
  beforeAll(() => {
    console.log('===== 啓動用户服務測試 =====');
  });
  // 整個測試套件後執行一次
  afterAll(() => {
    console.log('===== 完成用户服務測試 =====');
  });
  // 每個測試用例前執行
  beforeEach(() => {
    userService = new UserService();
    testUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
    userService.addUser(testUser);
  });
  // 每個測試用例後執行
  afterEach(() => {
    console.log(`測試完成,當前日誌條目: ${userService.getLogCount()}`);
  });
  // BDD 風格測試組
  describe('BDD 風格測試 (行為驅動開發)', () => {
    test('應該正確添加用户', () => {
      // BDD 斷言風格
      expect(userService.addUser({...})).toMatchObject({...});
    });
  });
  // TDD 風格測試組
  describe('TDD 風格測試 (測試驅動開發)', () => {
    test('添加用户後用户數量應增加', () => {
      // 初始狀態
      const initialCount = userService.users.length;
      // 執行操作
      userService.addUser({...});
      // TDD 斷言風格
      expect(userService.users.length).toBe(initialCount + 1);
    });
  });
});

這個腳本里面,調用了被測模塊UserService,組裝了一些模擬數據投餵給它,然後觀察它的返回結果是否符合預期,從而判斷其功能是否正常。其實可以想象,最原始的方式實現一個單元測試腳本,大概是這樣的:

import { UserService } from './userService';
function test() {
  const userService = new UserService();
  const result = userService.addUser({...});
  if (isEqual(result, {...})) {
    console.log('測試通過');
  } else {
    console.err('測試失敗!');
  }
}
function isEqual(obj1, obj2) {
  // 判斷兩個對象是否值相等
}
// 執行測試
test();

但這種做法在實際使用中會遇到很多麻煩,比如:要測試DOM相關的界面操作怎麼辦?有很多個用例,但其中某個執行失敗了怎麼辦?執行結果的比對可能還涉及到異步情況,怎麼辦?於是,UT框架出現了,它幫我們搞定了這些問題,讓我們可以專心寫用例。

UT框架的作用&選型

UT框架負責構建測試環境、組織測試用例、提供用例執行引擎、並蒐集執行結果。

構建測試環境

UT用例往往是在Nodejs環境下執行的。但有一些被測模塊或用例的執行,需要用到DOM、window、document等瀏覽器特有環境的資源。所以UT框架往往會提供瀏覽器環境的模擬能力。甚至,提供代碼編譯能力(例如支持ts寫用例,但nodejs本身並不支持直接運行ts);甚至,提供模塊打樁能力(也就是mock掉某個特定模塊)。

組織測試用例

UT框架往往用describe表示一套用例,用it/test表示一個具體的用例。同時提供beforeAll、afterAll、beforeEach、afterEach等聲明週期鈎子。

提供用例執行引擎

真實項目中,用例數往往成百上千。這些用例以什麼順序(或並行)執行,異步用例如何執行,如果一個用例跑崩了,其它用例要能正常執行。這些也都是UT框架提供的能力。

蒐集執行結果

當用例執行完成之後,整體成功了多少,失敗了多少,覆蓋率如何。生成不同格式的報告,來對接不同的分析工具/平台。這些也都是UT框架提供的能力。

常見前端單元測試框架對比

前端單測框架有很多種,能力各異。這裏選常見的幾種,就上述4個方面進行對比,如下:

一般來説,Jest是大而全且快的框架,適合大多數場景。Mocka是小而精的框架,可定製性比較強。我們可以根據自己的項目實際情況,來選擇合適的測試框架。

斷言庫的作用&選型

相對於自己寫if-else、console.log而言,斷言庫提供更語義化、更簡潔的表達方式,並能跟UT框架協作,易於生成測試報告。

斷言庫的本質是一種測試專用DSL(domain-specific language領域特定語言),它讓我們寫出來的測試代碼更易懂。它有兩種語言風格:

  1. BDD風格:更貼近自然語言,例如 expect(user).to.be.loggedIn
  2. TDD風格:更貼近編程語言,例如 assert.isTrue(user.isLoggedIn)

語言風格的選擇,主要是看個人合團隊的喜好。選哪一種風格不重要,重要的是整個團隊應該是同一種風格。

不同斷言庫使用的語言風格不同,但也有一些能同時支持兩種風格。常見的幾種斷言庫的特點對比如下:

寫UT對開發者有什麼好處?

通過寫UT,我們能直觀看到好/壞代碼的差距,能挖掘出對業務更深的理解。

代碼中的壞味道越多,我們越會感覺到UT難寫。

直觀體會代碼壞味道

案例:一個訂單處理邏輯

// 被測代碼(壞味道示例)
export function processOrder(order) {
  if (!order) return null;
  const tax = order.items.reduce((sum, item) => {
    // 深度嵌套 + 業務耦合
    if (item.type === 'book') return sum + item.price * 0.1;
    else if (item.type === 'food') return sum + item.price * 0.05;
  }, 0);
  return { ...order, tax };
}
// 測試代碼(暴露問題)
test('處理空訂單應返回null', () => {
  expect(processOrder(null)).toBeNull(); // 通過
});
test('計算圖書税率為10%', () => {
  const order = { items: [{ type: 'book', price: 100 }] };
  expect(processOrder(order).tax).toBe(10); // 通過
});
// 新增需求:電子產品税率15%,需修改原函數,違反開閉原則

在寫測試代碼的過程中,我們會發現這裏有職責不單一的問題,這個函數同時包含了 處理訂單和計算税率 的邏輯。而且後續如果有新增的類別(很可能發生),則要修改這個重要函數,違反開閉原則。為了解決這些問題,可以用策略模式去重構。並且用例能幫忙保證重構不引入問題。

const taxRules = {
  book: price => price * 0.1,
  food: price => price * 0.05,
  electronics: price => price * 0.15 // 擴展不修改主函數
};
export function processOrder(order) {
  if (!order) return null;
  const tax = order.items.reduce(
    (sum, item) => sum + (taxRules[item.type]?.(item.price) || 0),
    0
  );
  return { ...order, tax };
}

加深對業務的理解

案例:商品税率處理邏輯

// 被測代碼(壞味道示例)
export function processOrder(order) {
  if (!order) return null;
  const tax = order.items.reduce((sum, item) => {
    // 深度嵌套 + 業務耦合
    if (item.type === 'book') return sum + item.price * 0.1;
    else if (item.type === 'food') return sum + item.price * 0.05;
  }, 0);
  return { ...order, tax };
}
// 測試用例(覆蓋未考慮的場景)
test('商品類型不存在時應忽略税額', () => {
  const order = { items: [{ type: 'unknown', price: 100 }] };
  expect(processOrder(order).tax).toBe(0); // 原代碼報錯,暴露缺陷
});

在寫測試代碼的過程中,我們會發現代碼沒有處理未知的商品類型。為了修復這個問題,我們需要在原碼中補一個else分支。

案例:金融計算精度問題

// 金融計算的陷阱
function calculateInterest(principal, rate, days) {
  const dailyRate = rate / 365;
  return principal * Math.pow(1 + dailyRate, days);
}
// 測試暴露的業務漏洞
test('10萬元年化5%存30天應得409.58元利息', () => {
  const interest = calculateInterest(100000, 0.05, 30);
  expect(interest).toBeCloseTo(409.58, 2); // 失敗!實際411.77
});

在寫測試代碼的過程中,我們會發現js在小數計算方面的精度能力較弱。這裏需要一些輔助工具才能達到業務精度要求

總結

合理且正確的使用單元測試,能幫我們有效的提升我們對自己代碼的信心,而不是產生累贅。嘗試將我們的思維轉型一下:

  1. “這段代碼如何實現?”“這段代碼該如何被使用?”
  2. “它能做什麼?”“它不該做什麼?”
  3. “功能完成”“變更安全”

這將給我們帶來別樣的體會。

關於OpenTiny

歡迎加入 OpenTiny 開源社區。添加微信小助手:opentiny-official 一起參與交流前端技術~

OpenTiny 官網:https://opentiny.design
OpenTiny 代碼倉庫:https://github.com/opentiny
TinyVue 源碼:https://github.com/opentiny/tiny-vue
TinyEngine 源碼: https://github.com/opentiny/tiny-engine
歡迎進入代碼倉庫 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor ~

如果你也想要共建,可以進入代碼倉庫,找到 good first issue標籤,一起參與開源貢獻 ~

user avatar kobe_fans_zxc 头像 solvep 头像 febobo 头像 woniuseo 头像 guixiangyyds 头像 munergs 头像 tanggoahead 头像 lin494910940 头像 lovecola 头像 ldh-blog 头像 haixiudezhusun 头像 shuyuanutil 头像
点赞 61 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.