寫給前端仔的自動化測試入門小作文
前言:前端領域的自動化測試
一直以來對于前端同學來說,自動化測試都是一個比較特殊的命題。一方面,大家其實都知道自動化測試的好處,做了什么改動只要跑一遍測試用例就知道有沒有改掛了之前的邏輯,進行修改時也更有底氣。而另一方面,前端本身就具有特殊性,活動頁從需求評審到正式上線可能在一周內就完成了,這種迭代速度還寫測試用例就是折磨自己。
但實際上,自動化測試在前端工程中也是相當重要的一部分。即使是快速迭代的活動頁面,也會有通用的工具函數(shù)與 SDK,對這一部分的代碼進行測試用例的完善是有必要且意義重大的,而對于某些流量巨大且長期存在的頁面,我們甚至需要進行多種測試場景的保障。
然而由于這兩種情況的存在,很多前端同學其實都對自動化測試的認知相當空白,它有哪些分類?有哪些推薦的實踐?有哪些框架與方案?而這篇文章的目的就是進行一個基礎的掃盲,至少完成閱讀以后你會知道如何為項目編寫測試用例,以及應該編寫哪些場景的測試用例。
單元測試與集成測試
單元測試(Unit Testing)正如其名,其中的測試用例應當是針對代碼中的各個單元的,如前端代碼中,每一個工具方法都可以被作為一個單元,對應一個獨立的測試用例。但這么說并不意味著你要寫出非常細粒度的代碼——這不是沒事折磨自己嗎?我通常使用“功能單元”的方式來確定粒度,比如生產薯條的流水線上,清洗-削皮-切片-包裝就是四個完全獨立的功能單元。
你可能會感到疑惑,這四個功能單元明明存在依賴關系,為何說是完全獨立的?這是因為在單元測試時,非常重要的一個步驟就是對當前測試單元的外部依賴進行模擬,比如我在測試削皮功能時,會直接給到“已經清洗完畢的土豆”,然后檢查“削皮后的土豆”,而不會真的去調用前后的功能單元。
常見的模擬操作可以分為 Fake、Stub、Mock、Spy 這么幾種,我們在下文會有更詳細的介紹。
一種常見的情況是工具方法中會基于外部依賴的表現(xiàn)執(zhí)行不同分支的代碼(if/else,try/catch,resolve/reject 等)。這種時候,我們需要做的就是通過修改外部依賴的表現(xiàn),來檢查工具方法內部各個代碼分支的執(zhí)行情況。比如,在 fetch 成功返回時應當調用 processData 方法,在 fetch 失敗時應當調用 reportError 方法,此時你就可以篡改掉 fetch 的實現(xiàn),然后檢查 processData 、reportError 方法是否被調用(注意,這兩個方法也需要被模擬(Stub / Spy) ,然后才能檢查它們的調用情況)。
當然,完全模擬所有外部依賴是最理想的情況,在很多時候一個工具方法可能具有許多外部依賴,此時你可以省略掉其中能確定無副作用(如 logger 這樣的純函數(shù)),或者是與核心邏輯無關的部分。
我們知道,測試用例也可以反過來對代碼產生檢查作用,而在單元測試階段這種作用基本是最明顯的,比如你可以很容易發(fā)現(xiàn)某一處功能單元設計得過于耦合,或是某一外部依賴將導致代碼進入錯誤分支等情況。
目前推薦的單元測試方案主要有這么幾種,Jest、Mocha、Sinon、Jasime 以及 AVA,它們之間各有優(yōu)劣,這里不做比較。但需要注意的是,一套完整的,能夠滿足實際需求的單元測試方案,通常意味著需要包括這么幾個功能:
斷言,Jest 提供了注入到全局的 expect 風格斷言(expect(1+1).toBe(2)),而 Sinon 提供的則是類似 NodeJs asserts 模塊風格的斷言(``sinon.assert.pass(1 + 1 === 2)`),而 Mocha 則不綁定斷言庫,你可以使用 asserts 模塊或者 Chai 來進行斷言。另外,斷言又包括了幾種不同的風格,我們同樣在下文講解。
用例收集,編寫測試用例時我們同樣需要基于功能單元區(qū)分,常見的方式就是 describe 收集一個功能單元,內部又使用 it / test 來進行功能單元各個邏輯分支的驗證。如:
describe('Utils.Reporter', () => {
it('should report error when xxx', () => {})
it('should show warnings when xxx', () => {})
})
- 模擬功能(Stub 、Fake Timers 等),包括對一個對象的 Spy,一個函數(shù)的 Stub,對一個模塊的 Mock,都屬于模擬的范疇。
 - 測試覆蓋率報告,這一功能常見的方式是通過 istanbul (1.0版本,2.0 更名為 nyc)或 c8 來進行實現(xiàn),其原理包括代碼插樁與使用 V8 引擎內置功能兩種,這里不再贅述。另外一個常見的場景是輸出其他語言格式的覆蓋率報告(如 JUnit),社區(qū)也通過 Reporters 的機制為這些測試框架做了支持。
 
如果你此前并沒有對這些單元測試方案非常熟悉,那我推薦你了解一下 Vitest ,來自 antfu 的作品,特色是快(畢竟基于 Vite)以及對 TypeScript、ES Module 的良好支持,我目前在工作中的單元測試也已經全部遷移到 Vitest,同時 Vitest 還自帶了 UI 界面,讓你可以更享受編寫測試并看著它們一個個通過的過程。

如果說單元測試是為了測試單個功能單元,那么集成測試(Integration Testing)很明顯就是為了測試多個功能單元的協(xié)作。但需要注意的是,多個功能單元協(xié)作并不意味著對整個系統(tǒng)(流水線)進行完整的功能測試,通常我們還會將幾個功能單元分散開進行組合,成為系統(tǒng)的某一部分,比如清洗-削皮作為預處理功能,需要確定一籮筐土豆能否正確地變成干凈的去皮土豆,切片-包裝作為核心功能,需要確定去皮土豆能變成冷凍薯條。
而要進行集成測試的編寫,其實我們仍然只需要使用單元測試方案即可,因為本質上集成測試就是同時對多個功能單元進行測試,我們驗證的范疇也隨之擴大了而已。
而關于集成測試的維度拆分則并沒有準確的界限,你可以像上面那樣將預處理功能作為一個系統(tǒng)部分,也可以將整個流水線作為一個系統(tǒng)部分(還有供應鏈部分、烹飪部分與服務部分),按照你的實際業(yè)務場景就行。
Mock、Fake、Stub
很多時候測試用例的運行時是受限的,比如我們并不希望真的發(fā)起網(wǎng)絡請求,或者是和數(shù)據(jù)庫交互,以及 DOM API 的操作等。這個時候我們會使用一系列模擬手段,來特定地模擬一個可交互的對象,并通過修改它的行為來檢查預期的處理邏輯是否執(zhí)行。
這個模擬行為通常被直接稱為 Mock,但實際上,由于模擬的對象類型以及注入的模擬邏輯,更準確的描述是將這些行為劃分為三大類。首先是最常用的 Stub ,假設我們在為 UserService 編寫單元測試,其內部注入的 PrismaService 負責數(shù)據(jù)庫的讀寫,我們可以使用一個 PrismaServiceStub 替換掉實際的服務,并且在其內部提供對應 PrismaService.user.findUnique 這樣的方法,然后在我們調用 UserService.queryUser 時,就可以檢查 PrismaServiceStub 上對應的方法是否被預期的入?yún)⒄{用,而其出參是否被預期地處理后返回。Spy 也可以認為是 Stub 的一種,但它更強調“是否按照預期調用”這個過程,我們甚至可以僅僅監(jiān)聽一個對象而無需提供模擬實現(xiàn)(如 console 這樣的 API)。
而如果我們不希望替換掉 PrismaService,而是希望它真的去進行數(shù)據(jù)讀寫,但不是對真實的數(shù)據(jù)庫,就可以提供一個 Fake 的數(shù)據(jù)庫——比如一個對象,這樣對數(shù)據(jù)庫的讀寫就變成了對內存對象的讀寫,變得更加快捷和穩(wěn)定,這就是 Fake。另外一個常見的 Fake 場景就是定時器,常見的單元測試框架都提供了 Fake Timers 的功能支持。
而 Mock 其實和 Stub 也非常類似,但 Mock 更像是其中“預期的入?yún)ⅰ保⒉魂P注返回值,我個人理解通常項目中 fixtures 文件夾下的各種對象和 JSON 就是典型的 Mock 。
當然,Mock、Stub、Spy 三者還是非常相似的,我們也并不是必須搞清楚其中的差異,因為它們的本質都是模擬罷了。
斷言:expect、assert、should
我們常見的斷言包括 expect 與 assert 形式,NodeJs 提供了原生的 asserts 模塊讓你來編寫一些簡單的斷言,你可以在實際代碼中也使用斷言來確保邏輯正確運行,而 expect 形式則通常只見于測試用例中。如檢查一個函數(shù)的調用和比較兩個對象,兩種風格分別是這樣的:
expect(mockFn).toBeCalledWith("linbudu");
assert.pass(mockFn.calls[0].arg === "linbudu");
expect(obj1).toEqual(obj2);
assert.equal(obj1, obj2);
通常我個人更喜歡命令式風格明顯的 expect 斷言,而除了這兩種風格以外,其實還有一種 should 形式的鏈式風格斷言,它寫起來是這樣的:
mockFn.should.be.called();
obj1.should.equal(obj2);
值得一提的是在 Chai 這個斷言庫中對以上三種斷言風格都進行了支持,如果你有興趣,不妨都試一試。
前端頁面中的組件測試與 E2E 測試
單元測試和集成測試是前后端應用中通用的概念,而完成了對基礎功能單元的測試以后,我們需要更進一步,關注領域中特定的功能,比如從前端視角來看一個組件的 UI 與功能,從后端視角來看一個接口面對千奇百怪入?yún)⒌捻憫?/p>
在當今的前端項目中,組件化應該是最明顯的一個趨勢,那么進行組件維度的測試也自然是相當有必要的。以 React 組件為例,我們可以模擬這個組件的入?yún)?,并觀察其實際渲染的 UI 組件是否正確,以及使用快照的方式,來檢查組件的實際渲染是否一致。
目前使用的組件測試方案通常是和框架綁定的,如 React 下的 @testing-library/react? 和 Enzyme,Vue 下的 @vue/test-utils?,Svelte 下的 @testing-library/svelte,這是因為本質上我們是在孤立地渲染這個組件,并模擬框架行為來驗證其表現(xiàn)。
在組件測試方案中,我更推薦 @testing-library/react? (還包括 @testing-library/react-hooks),Enzyme 的 API 要更加復雜,同時其目前應該已經不再維護(或是維護力度堪憂)。使用其編寫的測試用例是這樣的:
import * as React from 'react'
function HiddenMessage({children}) {
const [showMessage, setShowMessage] = React.useState(false)
return (
<div>
<label htmlFor="toggle">Show Message</label>
<input
id="toggle"
type="checkbox"
onChange={e => setShowMessage(e.target.checked)}
checked={showMessage}
/>
{showMessage ? children : null}
</div>
)
}
export default HiddenMessage
import * as React from 'react'
import {render, fireEvent, screen} from '@testing-library/react'
import HiddenMessage from '../hidden-message'
test('shows the children when the checkbox is checked', () => {
const testMessage = 'Test Message'
// 將組件模擬渲染出來
render(<HiddenMessage>{testMessage}</HiddenMessage>)
// 基于模糊查詢來驗證 DOM 元素的存在
expect(screen.queryByText(testMessage)).toBeNull()
// 同樣基于模糊查詢來觸發(fā)事件
fireEvent.click(screen.getByLabelText(/show/i))
// 驗證結果是否符合預期
expect(screen.getByText(testMessage)).toBeInTheDocument()
})
單元測試、集成測試、組件測試,看起來我們已經非常完美地使用自動化測試從不同場景與不同維度進行了功能的驗證,但實際上,我們還少了一個非常重要的維度——用戶視角。在程序最終交付驗收時,我們可愛的測試同學會來把各個功能和鏈路都檢查一遍,而即使你已經寫了巨量的測試用例,還是有可能會被發(fā)現(xiàn)大量的問題,這就是因為視角不同。作為程序的開發(fā)者,你清楚地了解程序的控制流走向,也對每一個分支了然于胸,所以在編寫測試用例時你其實更像是上帝視角。
要從用戶的視角出發(fā),實際上我們只需要屏蔽對程序內部的所有感知,而只是去使用這個程序即可。這樣的測試被稱為端到端測試(End-to-End Testing,E2E),它不再關注內部功能單元的細節(jié),而是完全從外部還原一個真實的用戶視角,如前端應用中,用戶登錄-搜索商品-加入購物車-編輯商品-結算商品的一系列交互,誰管你的登錄背后隱藏了多少權限分級,商品貨架分級設計得多么精細,只要這個流程無法順利走通,那你的系統(tǒng)就是有問題的。
而既然 E2E 測試是在模擬用戶行為,那么其實我們所需要做的就是使用用戶的環(huán)境來運行系統(tǒng)罷了。如對于前端頁面,其實就是瀏覽器(更準確地說是瀏覽器內核),而對于后端服務則是客戶端。
以 Cypress 的功能為例,來看看我們是如何模擬用戶行為的:

在前端領域中編寫 E2E 測試,常見的 E2E 測試框架主要包括 Puppeteer、Cypress、Playwright、Selenium 這么幾種。它們之間各有優(yōu)劣,適用場景也有所不同,我們會在下面進行比較。
與其他測試場景的重要不同之一,就是 E2E 測試是可以由測試同學來編寫的(如支持 Python 和 Java 的 Selenium),在產品進行迭代的同時,測試同學會按照功能點變化對應地完善測試用例,同時確保以往所有功能的測試用例不受影響。
Puppeteer、Cypress、Playwright 的取舍
前端 E2E 測試目前常用的包括 Puppeteer、Cypress、Playwright 這么幾款,這就可能讓你感到選擇困難,到底應該選哪個?萬一選了一個,寫著寫著發(fā)現(xiàn)不符合需求了,咋辦?這一部分我們就來簡單介紹一下它們。
先上結論:非常簡單的場景使用 Puppeteer(需要搭配 Jest-Puppeteer),PC 應用使用 Cypress,移動端應用使用 Playwright。
接著我們再來一個個解釋。首先是 Puppeteer,認真地說,它就不應該用來做 E2E 測試,因為人家真的就只是一個無頭瀏覽器,你要用它來寫寫爬蟲之類的倒還好,強行霸王硬上弓要人家給你干 E2E,一方面是只支持 Chrome + Chromium 內核,另一方面人家不帶斷言庫,你還得帶一個 Jest-Puppeteer 一起。但如果是真的非常非常簡單的場景,你還是可以用 Puppeteer ,加上 NodeJs 的基礎斷言庫,通過自動化方式確定一些頁面功能還是沒問題的。
然后是 Cypress ,其場景從人家的 Slogan 其實也能感覺出來:Fast, easy and reliable testing for anything that runs in a browser,注意 in a browser,其實人家就是提供了無頭瀏覽器,斷言,GUI,以及 Web 下的各種 API ,然后你就可以完全模擬使用瀏覽器進行的一切行為了。同時 Cypress 也通過代碼插樁的方式支持了覆蓋率報告相關的能力。需要注意的是,Cypress 只支持瀏覽器維度的配置,如 chrome(也支持chromium)、edge、firefox。
因此,如果你更側重于檢查應用在移動端的表現(xiàn),那其實應該使用 Playwright 。為什么?Playwright 支持同時運行多個 project,這些 project 可以被配置為使用瀏覽器內核(檢驗 PC、桌面端場景),也可以被配置為使用內置的 devices 預設,來檢驗其在移動端的表現(xiàn),這些預設包括了視口大小、默認瀏覽器內核(chromium,webkit,safari 等)等等,參考官網(wǎng)的示例配置:
// playwright.config.ts
import { type PlaywrightTestConfig, devices } from '@playwright/test';
const config: PlaywrightTestConfig = {
projects: [
{
name: 'Desktop Chromium',
use: {
browserName: 'chromium',
viewport: { width: 1280, height: 720 },
},
},
{
name: 'Desktop Safari',
use: {
browserName: 'webkit',
viewport: { width: 1280, height: 720 },
}
},
{
name: 'Desktop Firefox',
use: {
browserName: 'firefox',
viewport: { width: 1280, height: 720 },
}
},
{
name: 'Mobile Chrome',
use: devices['Pixel 5'],
},
{
name: 'Mobile Safari',
use: devices['iPhone 12'],
},
],
};
export default config;
在我所在的團隊,目前也正在基于 Playwright 建立 E2E 測試用例,來更方便快捷地保障核心應用的頁面功能。
最后,如果你并不是在開發(fā)一個前端應用,而是在開發(fā)一個 UI 組件庫,那你可以使用 StoryBook 提供的測試能力(基于 Jest 與 Playwright),這樣一來你既能夠基于 StoryBook 獲得組件的可視化文檔說明,也可以獲得自動生成的 E2E 測試用例:

后端服務中的 E2E 測試與壓力測試
而對于后端服務中的測試,由于我暫時還沒有比較深入地實踐,這里就只簡單介紹下 Node API 中的 E2E 測試、壓力測試。
上面我們已經提到,對于后端服務來說其實用戶就是各個客戶端,而我們也只需要模擬客戶端,向 API 發(fā)起請求,模擬登錄態(tài)信息和各種參數(shù),然后查看最終返回的結果是否符合預期即可。在這個過程中,API 由哪個 Controller 承接,調用了哪些 Service,走過了哪些 Middleware ,我們都不應該也無需關心。而假裝自己是客戶端的方式就簡單多了,常見的方式是使用 supertest 。另外,通常后端服務的 E2E 測試也應該是盡量模擬完整的交互過程:上傳商品-編輯商品-上架商品-下架商品-...,只不過這個過程并不像在前端那樣直觀。
另外后端服務中的 E2E 測試如何 Mock 也有不同的情況,如果希望盡可能模擬用戶,可以使用專用的測試環(huán)境數(shù)據(jù)庫,但這樣測試的執(zhí)行就不完全穩(wěn)定。如果希望從簡,那么可以像單元測試與集成測試中那樣模擬掉外部依賴。另外,部分 NodeJs 框架也直接提供了原生的測試支持,如 @nestjs/testing?,@midwayjs/mock 等等。
另外一個后端服務特殊的測試場景則是壓力測試,在某些時候也可以被等價于性能測試,從某些方面它其實也是在模擬用戶,只不過不是模擬一個用戶的交互行為,而是模擬較大量級的用戶訪問,以此來測試服務的性能。本質上壓力測試并不是在測試 API 的邏輯,而是承載 API 的服務器性能與負載均衡相關邏輯。進行壓力測試可以很簡單地使用腳本開多線程并發(fā)請求,也可以使用 Apache Bench、Webbench、wrk(wrk2)測試工具,或者 npm 社區(qū)也有 autocannon 這樣的實現(xiàn)。
在壓力測試下,我們主要關注這么幾個指標:
- 每秒請求數(shù) RPS,Request Per Second,更常見的稱呼是每秒查詢數(shù) QPS,Query Per Second,它代表了到達服務器的請求數(shù)量。
 - 并發(fā)用戶數(shù) CL,Concurrency Level,不同于 RPS,并發(fā)數(shù)代表了當前仍未完結的等待處理的請求。舉例來說,假設某個神奇 API 的請求處理速度非??欤總€請求的處理時間無限趨近于 0 ,那么即使其 RPS 可能達到一百萬,并發(fā)數(shù)卻也非常低(趨近于0)——因為它處理的實在是太快了,幾乎不需要同時處理兩個請求。
 - 每秒事務數(shù) TPS,Transactions Per Second,TPS 有點類似于 QPS,但它所關注的事務其實是比請求-響應過程更具象的過程,舉例來說,訪問 server/index.html ,實際上還訪問了 server/index.css 與 server/index.js 文件,那么這個過程實際上只會記為一次事務,但會記為三次查詢。
 - 響應時間 RT,Response Time,一個請求從進入到帶走響應的耗時,這個耗時包括了等待時間-處理時間-IO讀寫時間-響應到達時間。
 
除了這些指標以外,我們還會關注服務器當前的性能指標,如內存與 CPU 占用率,駐留集(RSS,當前進程獲得分配的物理內存,包括堆、棧與執(zhí)行代碼段等),你也可以使用 NodeJs 提供的 --prof? --prof-process 等啟動參數(shù),或使用 heapdump 提供的內存快照打印功能來幫助分析 Node API 的性能。
尾聲
除了以上介紹的這些自動化測試分類,其實還有著前端頁面的性能測試(如基于 LightHouse、Performance API),主要關注各種“首次”的指標,如首屏繪制、可交互時間、最大內容繪制等等,基于 axe-core 的可訪問性測試(Accessibility Testing),關注網(wǎng)頁的可訪問性,以及一些相對少見的場景,如基于 Needle 的 CSS 測試、基于 Coffee 的命令行應用測試,以及混沌工程理念中的混沌測試等,這些概念要么在社區(qū)里已經存在大量的高質量介紹文章,要么我并沒有深入了解過,在這里就不贅述了。
另外,想要進一步地保障頁面的功能穩(wěn)定性,監(jiān)控平臺(白屏,JS Error,404)這一類的存在也是相當有意義的,但這一部分功能已經存在太多方案,社區(qū)的 Sentry,以及各大廠內部自己建設的平臺等等,這里就不再贅述。
這篇不長也不短的小作文里,我們基本上把前端開發(fā)者會接觸到的自動化測試種類都了解了一遍,包括它們的使用場景,實踐方式,以及可選的庫/框架。在完成全文閱讀后,如果你恰好在開發(fā)“值得投入精力編寫測試”的應用,不妨思考下,上面是否恰好有符合你所需求的部分。















 
 
 







 
 
 
 