在前端圈,單元測試幾乎是一個非常頭疼的話題。履歷上不寫熟悉單元測試,都不好意思跟人打招呼了。CI/CD流程裡,要是沒有一個 test
的階段,就好像這個專案不夠專業。而那綠色的“Coverage: 95%
”,也常常被當作專案質量的黃金標準,成為許多團隊KPI的一部分。
但在我帶團隊的這幾年,尤其是在經歷了幾個專案因為高覆蓋率的單元測試而舉步維艱之後,我越來越傾向於一個結論:
我們前端領域裡,大部分團隊寫的大部分所謂單元測試,純屬自欺欺人。
在你說我反測試之前,請允許我加一個前提:我反對的,不是測試本身,而是那種為了覆蓋率而寫的、脫離用戶實際場景的、脆弱不堪的、以測試實現細節為樂的單元測試。
我們先來看幾個我從過往專案中挖出來的、非常典型的測試案例。
假設我們有一個簡單的計數器元件:
// Counter.jsx
class Counter extends React.Component {
state = { count: 0 };
increment = () => this.setState({ count: this.state.count + 1 });
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={this.increment}>Increment</button>
</div>
);
}
}
然後,一份看起來正確的單元測試:
// Counter.test.js
it('should increment count when button is clicked', () => {
const wrapper = shallow(<Counter />);
wrapper.find('button').simulate('click');
// 重點:測試元件的 state
expect(wrapper.state('count')).toBe(1);
});
為什麼說這是自欺欺人?
這個測試,和元件的內部實現(this.state.count)牢牢地綁在一起。明天,我如果用Hooks重構這個元件,把state換成了useState,元件給用戶看的功能完全沒變,但這個測試百分之百會掛掉。
Mock測試
我們經常會寫一些依賴其他模組或Hook的元件。於是,測試程式碼就變成了:
// 一個依賴了 useAuth 和 useApi 的元件
jest.mock('./hooks/useAuth');
jest.mock('./api/user');
it('should display user name when logged in', () => {
// 模擬 useAuth 返回已登錄
useAuth.mockReturnValue({ isLoggedIn: true, user: { name: 'John' } });
// 模擬 api 調用
userApi.getInfo.mockResolvedValue({ success: true, data: ... });
render(<UserProfile />);
// ...斷言
});
當你的測試文件裡,jest.mock的程式碼比斷言的程式碼還多時,你就應該警惕了。我們到底是在測試我們的元件邏輯,還是在測試我們Mock的能力?這種測試,一旦元件的依賴關係發生微小的變化,整個測試文件可能都要重寫。
這些測試累積起來,會給團隊帶來災難性的後果。
團隊看著CI上那95%的覆蓋率,覺得高枕無憂。但實際上,這些測試只保證了“當A函數的輸入是1時,返回值是2”,卻完全保證不了“當用戶在頁面上完成A、B、C三個操作後,頁面能正確跳轉”。高覆蓋率,不等於高質量。
對重構的巨大阻力
這才是最致命的。
開發者不敢動那些老程式碼,不是因為怕改出Bug,而是因為怕改完之後,要花雙倍的時間去修復那些因為實現細節變更而大面積失敗的、脆弱的單元測試。這極大地扼殺了專案的迭代和優化動力。
浪費寶貴的開發時間
團隊投入大量時間,去編寫和維護這些弱雞😒的測試,僅僅是為了讓CI上的那個數字好看一點。這些時間,本可以用來做更有價值的事情,比如寫更重要的整合測試,或者直接去優化產品功能。
聊了這麼多問題,那到底什麼樣的測試,才不是自欺欺人?
我的核心觀點是:前端測試的重心,應該從單元測試,向整合測試和端到端測試傾斜。
也就是說:測試你的軟體,而不是你的實現細節。
我們把第一個計數器的例子,用這種思想重新寫一遍(使用React Testing Library):
// Counter.test.js
import { render, screen, fireEvent } from '@testing-library/react';
it('should display the updated count after clicking the button', () => {
render(<Counter />);
// 找到螢幕上的按鈕並點擊它,模擬用戶的真實操作
const button = screen.getByRole('button', { name: /increment/i });
fireEvent.click(button);
// 驗證用戶在螢幕上看到的結果,而不是內部狀態
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
這才是一個有價值的測試!
你看,這個測試,完全不關心 count
這個狀態是存在 class
的 state
裡,還是 useState
裡。它只關心一件事:“當用戶點擊了那個叫 Increment
的按鈕後,他是不是能在螢幕上看到 Count: 1
這段文字?”
無論你未來怎麼重構 Counter
元件,只要它的用戶行為沒有變,這個測試就不會失敗。這才是我們需要的、堅固的、能給予我們信心的測試。
我們應該把大部分精力,投入到模擬用戶真實操作路徑的測試上,確保應用的功能是正確的。而對於那些純粹的、無副作用的工具函數(比如一個 formatDate
函數),單元測試依然是必要的,但這在我們的程式庫裡,只占很小一部分。
測試不是目的,保證軟體能正常工作才是。
是時候審視一下我們專案裡的 *.test.js
文件了,然後問問自己:
我寫的這些測試,到底是在保證質量? 還是在自我安慰,看起來很牛皮?
你們覺得呢?🤔