5倍效率+覆蓋率90%,大部分程序員不知道的 Cursor 單測生成黑科技
背景
單元測試(Unit Test)是一種用于測試軟件最小可測試單元的方法與技術,通常針對一個函數、類、模塊粒度展開,內容上基本遵循三步法:設定上下文(Arrange)、執行代碼單元(Act)、觀測返回值或 Side-Effect 是否符合預期(Assert) ,進而驗證代碼效果是否符合設計預期。
那么,為什么要在業務代碼之外,費勁巴拉寫單測呢?《單元測試的藝術(https://book.douban.com/subject/25934516/)》書中提到一個案例:兩個研發能力相近的團隊,同時開發相似需求,實施單測團隊開發時間增加1倍,但集成測試階段 bug 少,調試定位速度更快,最終交付時間和bug數均表現更好:
圖片
誠然,編寫單測代碼確實有較高的時間人力成本,但一方面單測能在開發階段快速反饋代碼的正確性,發現代碼中的邏輯錯誤、邊界條件處理不當等問題,起到查漏補缺效果,最終交付出質量更好的代碼;另一方面,在后續維護過程中,無論是業務迭代、技術優化,都能規避意外改動引入的質量風險;以及更重要的,單測越完善重構成本越低,更容易引入各類 LLM 工具自動化地完成各種重復任務(例如:移除 FG、批量重構變量名、自動提取公共方法等),項目的生命力也就越強。
圖片
從質量和長期可維護角度考慮,單測的 ROI 是非常高的。
過去我們總說寫單測很難很麻煩,但當下,在 Cursor、Marscode 等輔助編碼工具的加持下,只要使用恰當的 Prompt 配合一些實踐技巧,開發者少量介入甚至完全無需介入即可生成質量不錯的單元測試代碼,生成/編寫單測的時間成本已經急劇下降。因此,強烈建議將單測視作必要項目的必要組成部分,日常提交業務代碼時應習慣性地同步補充單測代碼(推薦由 AI 生成),保證較高的單測覆蓋率。
在后續章節中,我會總結若干基于 AI 生成 UT 的最佳實踐與技巧,幫助各位更高效地借助 cursor 生成單測。
使用 Cursor 生成單測
目前市面上已經出現了很多輔助編程工具,cursor、windsuft、cline 等,但體驗下來 Cursor 的自動化程度最高,效果最好,所以這里以 Cursor 為例,介紹如何生成單測,前置步驟:
- 安裝 cursor;
- 開啟 codebase indexing,這能讓 Cursor 更好地理解整個倉庫,也能讓 Cursor 有機會學習存量單測代碼的寫法;
- 開啟 cursor yolo 模式(要求 0.43 以上版本),這是一個強大的 AI Agent,能自行調用各類工具(eslint/ts/vitest 等)判斷生成代碼的合法性;
圖片
- 編寫適當的 .cursorrules 文件;
- 模型切換為 claude;
- 打開目標文件后,ctrl + i 打開 composer 面板,輸入 prompt:
為 @xxx 文件生成單測
// 或者
為 @xxx 包生成單測
為 @xxx 目錄生成單測
圖片
到這里相信已經能生成一些單測代碼,簡單場景通常能一遍過,但遇到復雜場景時,生成效果可能并不好,例如源碼中存在遞歸邏輯時,生成的質量通常很差,這是因為 LLM 是基于概率演算的,并不真正具有邏輯推導能力,嚴格來說并不具備分析復雜代碼并生成相應單測的能力,對此我們可以借助一些實踐方法,寫出一些更適合 LLM 推導單測的源碼;同時使用各類技巧更高效地調試單測代碼直至完善所有測試用例,進一步降低單測開發成本。
前置準備
1. 以 Vitest 為測試框架
Vitest 是一個面向現代前端項目的測試框架,設計上與 Vite 兼容,并且致力于提供高性能、易于配置的測試環境。它使用 esbuild 進行快速編譯,并且支持許多現代 JavaScript 和 TypeScript 特性。作為對比,Jest 是一個老牌的 JS 測試框架,功能齊全但執行速度相對較慢,且依賴結構非常復雜,難以維護管理。兩者詳細對比:
特性 | Jest | Vitest |
性能 | 慢一些,使用 Babel 編譯 | 快速,使用 esbuild |
現代化支持 | 部分 ESM 支持 | 原生 ESM 支持 |
與 Vite 集成 | 必須手動配置 | 原生支持,幾乎零配置 |
TypeScript 支持 | 通過 ts-jest 或 Babel | 原生支持 |
插件生態 | 獨立的插件生態 | 與 Vite 共享插件生態 |
開發體驗 | 稍微慢一些 | 更快的反饋循環 |
依賴結構 | 安裝 Jest 后會遞歸安裝許多下游依賴,結構復雜度較高 | 許多代碼都被 Bundle 進 Vitest 的產物包,因此依賴結構要簡單的多 |
因此,我個人更推薦使用 Vitest 作為測試框架,雖然也遇到了不少問題,但整體還是比較高效絲滑的。
2. 做好技術選型
在 vitest 之外,如果測試的主題是 React 組件,那么還需要引入更多工具實現組件渲染、hook 執行等邏輯,這里羅列幾個你很可能會用到的工具:
- @testing-library/react:提供一系列方法用于渲染 React 組件,并且可以方便地查詢和操作渲染后的組件實例(使用 render);同時支持測試組件的交互邏輯(fireEvent),比如模擬用戶的點擊、輸入等操作,從而驗證組件在不同交互下的行為是否符合預期;
// https://web-bnpm.byted.org/package/@testing-library/react
import { render, fireEvent } from'@testing-library/react';
import { Button } from'../src/button';
describe('testing button', () => {
it('測試Button組件的文本和點擊事件', () => {
const mockOnClick = vi.fn();
const { getByText } = render(
<Button text="Submit" onClick={mockOnClick} />,
);
// 檢查props.text是否正確渲染到按鈕上
const buttonNode = getByText('Submit');
expect(buttonNode).not.toBeNull();
// 觸發點擊事件,檢查props.onClick是否被調用
fireEvent.click(buttonNode);
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
});
- @testing-library/react-hooks:專門用于測試 React Hooks 的庫,主要提供 renderHook 方法,可使用該方法調用要測試的 Hook,并獲取其返回值、狀態以及副作用等信息,并且允許測試不同依賴變化后 Hook 的行為;
// https://web-bnpm.byted.org/package/@testing-library/react-hooks
import { renderHook, act } from'@testing-library/react-hooks';
import { useCounter } from'../src/use-counter';
test('測試useCounter自定義hook', () => {
const { result } = renderHook(useCounter);
// 初始值為0
expect(result.current.count).toBe(0);
// 執行increment操作,計數值應增加1
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
- @testing-library/user-event:用于模擬用戶與應用程序交互的庫,可模擬各類用戶操作如點擊、輸入文本、選擇下拉選項等。相對而言,@testing-library/react 的 fireEvent 適用于測試組件內部狀態流轉觸發的各類響應事件邏輯,而 @testing-library/user-event 更適用于測試真實用戶交互所引發的副作用;
it('should handle quick jump correctly', async () => {
const onPageChange = vi.fn();
render(
<Pagination total={100} showQuickJumper={true} onChange={onPageChange} />,
);
const input = screen.getByRole('spinbutton');
await userEvent.clear(input);
await userEvent.type(input, '5');
await userEvent.keyboard('{Enter}');
await waitFor(
() => {
expect(onPageChange).toHaveBeenCalledWith(5, 10);
},
{ timeout: 1000 },
);
});
- @testing-library/jest-dom:這是一個與 Jest 和 DOM 測試相關的庫,提供了一系列自定義的 Jest 匹配器(斷言函數),這些匹配器使得對 DOM 元素的斷言更加簡潔和直觀。例如,toBeInTheDocument 匹配器可以用來檢查某個元素是否在渲染后的 DOM 中;toHaveTextContent 可以用來驗證元素是否包含特定的文本內容;
import '@testing-library/jest-dom';
it('should render the <ConfigPanel/> when `opening` is true', () => {
const { getByRole } = render(<RootContainer />);
// Show Indicator by default
expect(screen.getByText('Indicator')).toBeInTheDocument();
// Show ConfigPanel after clicking Indicator
fireEvent.click(getByRole('button')); // Assuming Indicator component is a button
expect(screen.getByText('ConfigPanel')).toBeInTheDocument();
// Show Indicator after clicking ConfigPanel
fireEvent.click(getByRole('button')); // Assuming ConfigPanel component is a button
expect(screen.getByText('Indicator')).toBeInTheDocument();
});
讀者按需選用即可。
3. 目錄結構
Vitest 會自動識別你的package 下面所有以test.ts 結尾的文件,理論來說,你可以按照自己的喜好進行組織,但這里建議:
- 在 pakcage 根目錄下設立 __tests__ 文件夾,與 src 同級;
- Package 內所有的測試用例,都保存在上一步創建的文件夾中;
- 為 src 目錄每一個源碼模塊 foo.ts,創建對應同名測試模塊 foo.test.ts,且測試代碼的目錄結構與源碼保持一致,方便對應;
- 所有單測文件名均以 test.ts 結束;
最終形成如下結構:
infra/xxx-devtool/
├── README.md
├── OWNERS
├── vitest.config.ts
├── package.json
├── src/
│ ├── index.tsx
│ ├── root.tsx
│ ├── indicator.tsx
│ ├── global.d.ts
│ ├── index.module.less
│ ├── hooks/
│ ├── utils/
│ └── config-panel/
├── stories/
├── setup/
└── __tests__/
├── root.test.tsx
├── index.test.tsx
├── indicator.test.tsx
├── hooks/
├── utils/
└── config-panel/
上述示例中:
- src/root.tsx 相關單測代碼集中在 __tests__/root.test.tsx 中;
- src/config-panel/foo.tsx 則集中在 __tests__/config-panel/foo.test.tsx 中,單測目錄結構與源碼目錄結構保持一致;
另外,期望源碼文件與單測文件一一對應,若出現某些測試文件代碼行數過多時,請不要拆解出多個單測文件,而應該優先判斷對應源碼模塊的邏輯是否過于復雜,是否應該做進一步模塊拆解。
4. 遵循 AAA 結構
單元測試本質上就是“在可控環境中,模擬觸發代碼邏輯,驗證執行結果”的過程,一個標準的單測用例通常包含如下要素:
- arrange:調用 vi.mock 等接口模擬上下文狀態,構建“可控”的測試環境;
- act:調用測試目標代碼,觸發執行效果;
- assert:檢測,驗證 act 的響應效果是否符合預期,注意,單測中務必包含足夠完整的 assert,否則無法達成驗證效果的目標。
建議后續 UT 代碼均 AAA(Arrange-Act-Assert) 結構組織代碼,遵循如下結構要求:
- 除 vi.importActual 等特殊語句外,所有 import 語句均保存到文件開頭;
- import 語句之后,放置全局 vi.mock 調用,原則上應 mock 掉所有下游模塊;
圖片
- Mock 語句之后放置 describe 測試套件函數,函數內原則上不可嵌套多個 describe;函數內應包含多個 it 用例;
- it 用例內部遵循 arrange => act => asset 順序,例如:
圖片
完整實例:
import { describe, it, expect, vi, beforeEach } from'vitest';
import { exec } from'shelljs';
import { ensureNotUncommittedChanges } from'@/utils/git';
// 導入被mock的模塊,以便我們可以訪問mock函數
import { env } from'@/ai-scripts';
// arrange
// Mock shelljs
vi.mock('shelljs', () => ({
exec: vi.fn(),
}));
// Mock ../ai-scripts
vi.mock('@/ai-scripts', () => ({
env: vi.fn(),
}));
describe('git utils', () => {
it('應該在 BYPASS_UNCOMMITTED_CHECK 為 true 時直接返回 true', async () => {
// arrange
// mock
vi.mocked(env).mockReturnValue('true');
// act
const result = await ensureNotUncommittedChanges('/fake/path');
// assert
expect(result).toBe(true);
expect(exec).not.toHaveBeenCalled();
});
});
技巧
1. 頻繁提交代碼
初次生成可能問題不大,但后續使用 llm 迭代過程中,隨時可能會被改的面目全非,影響存量單測,因此建議頻繁提交、合入代碼,或者在本地將穩定的單測內容通過 git add 加入 staged 狀態,之后再觸發 LLM 生成新的代碼,有問題也方便隨時回滾。
2. 使用 only 方法
每次 LLM 生成的代碼都有可能測試不通過,使用 Vitest 的 [only](https://vitest.dev/api/#describe-only) 接口配合 Filter 能力,只跑存在問題的用例,降低信息噪音。
3. 使用 add to composer
單測出現問題時,可以使用terminal 右上角的 Add to Composer 按鈕,讓 LLM 繼續幫你解決問題。
圖片
4. 合理配置 .cursorrules
Cursor 等輔助編程工具通常都具備一定的學習能力,能夠在持續交互迭代過程中學習到你的編程偏好,上下文技術棧等信息,
最佳實踐
1. 使用合適的模型與工具
以我的經驗來說,Cursor + Claude 是當下最適合輔助編程的工具組合。Cursor 的 Composer Agent 有極高的自動化能力,在給定任務后,Cursor 會自行規劃任務實現路徑,生成代碼并調用合適的工具驗證代碼是否符合預期。在生成單測的場景中,它會不斷調用 npm run test 等命令,直至測試代碼成功完成,或者達到預設的次數閾值才終止,結果就是,在使用時只需輸入一次 Prompt,之后等待任務執行完成即可。
圖片
其次,目前市面上可選的模型中,Claude 應該是最適合用于編碼的模型,舉個例子,針對同一份源碼 GPT 與 Claude 生成結果對比:
- GPT 4o 生成結果:
import request from'supertest';
import express from'express';
import { McpServer } from'@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from'@modelcontextprotocol/sdk/server/sse.js';
import { main } from'./index'; // 假設 main 函數是導出的
describe('Server Tests', () => {
let app: express.Application;
beforeAll(async () => {
app = express();
await main(); // 初始化服務器
});
it('should respond to SSE connection', async () => {
const response = await request(app).get('/sse');
expect(response.status).toBe(200);
// 其他斷言
});
it('should handle message post', async () => {
const response = await request(app).post('/messages').send({ sessionId: 'test' });
expect(response.status).toBe(200);
// 其他斷言
});
});
- Claude 生成結果:
import { McpServer } from'@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from'@modelcontextprotocol/sdk/server/sse.js';
import express from'express';
import request from'supertest';
import { Server } from'http';
import { main } from'./packages/2_server/src/index'; // 假設 main 函數是導出的
jest.mock('@modelcontextprotocol/sdk/server/mcp.js');
jest.mock('@modelcontextprotocol/sdk/server/sse.js');
describe('Server Tests', () => {
let app: express.Application;
let server: Server;
beforeAll(async () => {
app = express();
server = app.listen(3003);
await main(); // 初始化服務器
});
afterEach(() => {
server.close();
jest.clearAllMocks();
});
describe('MCP Server', () => {
it('should create MCP server with correct configuration', async () => {
const mcpServer = new McpServer({
name: 'Demo',
version: '1.0.0',
});
expect(mcpServer).toBeDefined();
expect(McpServer).toHaveBeenCalledWith({
name: 'Demo',
version: '1.0.0',
});
});
it('should register add tool correctly', async () => {
const mcpServer = new McpServer({
name: 'Demo',
version: '1.0.0',
});
const mockTool = jest.spyOn(mcpServer, 'tool');
mcpServer.tool(
'add',
{ a: expect.any(Function), b: expect.any(Function) },
expect.any(Function)
);
expect(mockTool).toHaveBeenCalled();
});
});
describe('Express Routes', () => {
it('should handle SSE connection', async () => {
const response = await request(app).get('/sse');
expect(response.status).toBe(200);
});
it('should handle messages endpoint', async () => {
const response = await request(app).post('/messages').query({ sessionId: 'test-session' });
expect(response.status).toBe(200);
});
it('should handle message post', async () => {
const response = await request(app).post('/messages').send({ sessionId: 'test' });
expect(response.status).toBe(200);
});
});
});
相對而言 Claude 的結果要更完整些,且準確率要高很多,從我的使用經驗來看(陸續生成過 2w+ 行單測代碼),編程場景可以無腦選擇 Claude,這是最適合編程的模型。
PS:Deepseek 也有很高的潛力,不過目前 Cursor Composer Agent 模式還不支持 DS,因此棄了。
2. 簡化,簡化,簡化?。?!
理論上,源碼的復雜程度與編寫/生成單測的難度呈正相關關系。源碼越簡單,其邏輯結構和功能實現往往更為清晰直觀,編寫/生成單測的難度也就越低。相反,源碼越復雜,分支鏈路越多,涉及的下游模塊越繁雜,邊界情況、異常處理和交互邏輯等都會相應復雜許多,需要更多更復雜的測試用例,因此 LLM 生成單測的難度也隨之增高。
因此,為了提高測試效率和質量,提高 LLM 生成 UT 的質量,建議在開發過程中盡可能對優化測試模塊的代碼結構,盡可能降低復雜度。有幾個維度可以輔助判斷模塊的復雜度:
- 代碼行;
- for/if 等語句的嵌套數量;
- 參數過多;
- React 組件中,嵌套的子組件數量、hooks 調用數量等;
- React 組件中,JSX 結構的長度、嵌套數等;
- 是否存在遞歸結構,實測,LLM 對遞歸的理解難度很高,應盡可能規避;
出現復雜結構時,可參考如下方法逐步拆解優化,降低模塊復雜度:
- 單一職責:代碼模塊/函數都只做一件事情,例如若函數中包含多層 for 循環,通??砂囱h邏輯拆開,每個循環都整理成單獨的函數,再通過函數之間的互相調用實現;
- 簡化邏輯,避免過度嵌套的條件判斷。
- 對于復雜的邏輯,使用設計模式如策略模式、責任鏈模式等進行重構;定期審查代碼,識別和消除不必要的復雜性。
- 對性能要求較高的模塊,進行性能測試和優化,確保復雜度的降低不會影響系統的性能。
- 保持一個 tsx 文件只有一個 react 組件或 hook,保持簡單,不允許出現嵌套組件;
- 當組件中包含過多 hooks 調用時,考慮將其提煉為單獨的hooks;
- 避免副作用,可盡量使用純函數實現代碼;
- 減少使用全局變量;
在我過往使用 Cursor 生成單測的過程中,最大的卡點就出現在復雜模塊上,源碼越復雜越難以正確生成單測,因此強烈建議各位在編寫代碼時多考慮如何為模塊編寫單測,盡可能保持簡潔簡單,盡可能寫出 UT 友好的代碼。
3. mock 所有上下文
單元測試的核心要素在于“單元”,測試目標應聚焦在特定模塊/函數上,不應該關注模塊之間的交互效果(這方面可由集成測試完成),因此需要營造一個“孤立”的環境,mock 掉所有可能影響測試結果的外部要素,將重點聚焦在單個模塊的內在邏輯上。按經驗,這里所說的外部要素包括:
- 下游模塊:理應 mock 掉目標模塊所引用的所有下游模塊(使用 vi.mock),特別是一些會產生副作用的下游調用,例如:接口請求、io 操作、store 操作、命令行調用等,這樣不僅能將測試的注意力聚焦在目標模塊,而且能降低 LLM 生成單測時需要關注的上下文信息量。相反,若未正確 mock 下游模塊,還可能引發一些穩定性問題:
a.Case 1: 代碼中經常會使用 ts alias 特性,在 vitest 環境中若沒有妥善設置對應 alias,則可能報下述錯誤,此時務必使用 vi.mock 處理相關下游模塊,方可正常運行
圖片
FAIL __tests__/bot/components/bot-store-chat-area-provider/utils.test.ts [ __tests__/bot/components/bot-store-chat-area-provider/utils.test.ts ]
Error: Failed to resolve import "@/utils" from "../../components/xxx-design/src/components/avatar/avatar.tsx". Does the file exist?
? formatError ../../../common/temp/default/node_modules/.pnpm/vite@5.1.6_@types+node@18.18.9_less@4.2.0_stylus@0.55.0/node_modules/vite/dist/node/chunks/dep-jvB8WLp9.js:50647:46
- Case 2:代碼中存在許多 bucket file,引用一個文件時可能會向下遞歸引用非常多子孫模塊,若其中某些模塊存在副作用時,可能影響測試穩定性;
- Case 3:下游模塊變動導致目標模塊測試結果不通過;
- 環境變量:理應 mock 掉所有環境變量(使用 vi.stubGlobal、vi.stubEnv 等方法)與全局對象,避免在不同環境(CI/本地)中,由于環境變量不同導致測試結果不穩定;
- 時間:當源碼中調用 Date 等函數獲取時間,且邏輯與時間強相關時,請務必使用 vi.useFakeTimers 函數設置模擬時間,如:
import { afterEach, beforeEach, describe, expect, it, vi } from'vitest'
const businessHours = [9, 17]
// 要被測試的邏輯代碼
const purchase = () => {
const currentHour = newDate().getHours()
const [open, close] = businessHours
if (currentHour > open && currentHour < close)
return { message: 'Success' }
return { message: 'Error' }
}
// 測試代碼
describe('purchasing flow', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('purchases within business hours returnSuccess', () => {
// 將時間設置在工作時間之內
const date = newDate(2000, 1, 1, 13)
vi.setSystemTime(date)
// 訪問 Date.now() 將生成上面設置的日期
expect(purchase()).toEqual({ message: 'Success' })
})
})
- 定時器:當源碼中包含 setTimeout、setInterval 等定時邏輯時,應使用 vi.advanceTimersByTime 等方法主動觸發定時器,避免單測超時,或時差導致測試結果不穩定等問題,例如:
import { afterEach, beforeEach, describe, expect, it, vi } from'vitest'
// 要被測試的邏輯代碼
const delayedGreeting = (callback: (message: string) =>void) => {
setTimeout(() => {
callback('Hello!')
}, 1000)
}
const periodicCounter = (callback: (count: number) =>void) => {
let count = 0
const timer = setInterval(() => {
count++
callback(count)
if (count >= 3) {
clearInterval(timer)
}
}, 1000)
}
// 測試代碼
describe('timer tests', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should call callback with greeting after 1 second', () => {
const callback = vi.fn()
delayedGreeting(callback)
// 確認回調還未被調用
expect(callback).not.toHaveBeenCalled()
// 前進 1000ms
vi.advanceTimersByTime(1000)
// 驗證回調被調用,且參數正確
expect(callback).toHaveBeenCalledWith('Hello!')
})
it('should count three times with periodic timer', () => {
const callback = vi.fn()
periodicCounter(callback)
// 第一次調用
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledWith(1)
// 第二次調用
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledWith(2)
// 第三次調用
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledWith(3)
// 確認總共調用了三次
expect(callback).toHaveBeenCalledTimes(3)
})
})
如果你想測試模塊之間的交互效果,應該使用集成測試方案,這是另一個話題,不在本文討論。
4. 減少使用定時器
減少使用 setTimeout、setInterval 等定時器函數,因為時序邏輯是一個復雜概念,針對定時器的測試邏輯非常麻煩,實測 LLM 生成單測時,即使使用 vitest 的各類 fakeTimers 也容易出現問題,推薦將這部分代碼提煉為公共函數,如基于 setTimeout + Promise 封裝 wait 函數;基于 setInterval + 迭代器實現 Tick 函數:
const wait = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms));
};
const tick = (interval = 1000) => {
let resolve = null;
let timer = null;
// 創建一個 Promise 和迭代器的橋接
const createPromise = () =>newPromise(r => resolve = r);
// 返回一個異步生成器
return {
[Symbol.asyncIterator]() {
// 啟動定時器
timer = setInterval(() => {
if (resolve) {
resolve();
resolve = null;
}
}, interval);
return {
async next() {
// 等待下一次定時器觸發
await createPromise();
return { value: undefined, done: false };
},
return() {
// 清理定時器
if (timer) {
clearInterval(timer);
timer = null;
}
return { done: true };
}
};
}
};
}
forawait (const _ of tick(1000)) {
console.log('每秒執行一次');
}
這樣做的好處是,在對上游模塊測試時可以 mock 掉 wait/tick 的執行時機,避免依賴 js runtime 的定時器功能,也不必各處使用 vi.fakeTimer 等函數,更容易控制單測邏輯。
5. 避免使用反義邏輯
如下圖:
圖片
這段代碼是非常典型的詞不達意,代碼中 matches 變量的語義應該是“匹配到的”,但變量值卻是 !useMediaQuery ,注意前面的 ! 邏輯,這個值的語義應該是“沒有匹配到的”,兩者根本是相反的邏輯,這類代碼由人類理解尚且費力,何況 LLM。
因此,推薦盡可能減少出現這類詞不達意的代碼,盡可能減少使用反義邏輯,上述邏輯完全可以改為:
const isMatched = useMediaQuery(xxx)
規避副作用代碼
如果不加注意,我們很容易寫出具有 side-effect 的 ES Module,例如:
const reportToSSE = IS_OVERSEA
? () => {
console.log("reportToSSE oversea");
}
: () => {
console.log("reportToSSE china");
};
針對這類代碼,在編寫單測時需要額外注意,不能直接 import 模塊,而是使用 vi.importActual 接口動態引入模塊,例如:
const mockConsole = {
log: vi.fn(),
};
vi.stubGlobal('console', mockConsole);
describe('reportToSSE', () => {
it('should report to SSE oversea', () => {
vi.stubGlobal('IS_OVERSEA', true);
const {reportToSSE} = vi.importActual('./report');
reportToSSE();
expect(mockConsole.log).toHaveBeenCalledWith('reportToSSE oversea');
});
it('should report to SSE oversea', () => {
vi.stubGlobal('IS_OVERSEA', false);
const {reportToSSE} = vi.importActual('./report');
reportToSSE();
expect(mockConsole.log).toHaveBeenCalledWith('reportToSSE oversea');
});
});
因此針對這類有副作用的代碼,單測的復雜度會高一些,并且實測 LLM 并不擅長處理 IS_OVERSEA 等非標準的環境變量,難以正確生成用例。另外,副作用越大復雜度越高,例如:
import { McpServer } from'@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from'@modelcontextprotocol/sdk/server/sse.js';
import { z } from'zod';
import express, { Request, Response } from'express';
import morgan from'morgan';
// Create an MCP server
const server = new McpServer({
name: 'Demo',
version: '1.0.0',
});
// Add an addition tool
server.tool('add', { a: z.number(), b: z.number() }, async ({ a, b }) => ({
content: [{ type: 'text', text: String(a + b) }],
}));
// Create Express application
const app = express();
app.use(morgan('tiny'));
// Store transports for each connection
let transport: SSEServerTransport;
// SSE endpoint
app.get('/sse', async (req: Request, res: Response) => {
// Create a unique ID for each connection
console.log('New connection');
transport = new SSEServerTransport('/messages', res);
await server.connect(transport);
});
// Message processing endpoint
app.post('/messages', async (req: Request, res: Response) => {
console.log('New message: ' + req.query.sessionId);
await transport.handlePostMessage(req, res);
});
// Start the server
const port = process.env.PORT || 3003;
app.listen(port, () => {
console.log(`Server started on port ${port}`);
});
上述示例將所有邏輯都直接寫在 ESM 根作用域中,很難編寫測試,更好的方式應該將其封裝為函數,之后 Export 出來,供用例不斷調用測試。
因此,建議后續盡可能避免在 ESM 根作用域中直接編寫代碼,規避副作用。
7. 降低代碼規范要求
在過去,測試代碼純粹由人類智能編寫時,出于可讀性可維護性考慮,有必要保持一定程度的代碼規范,遵循各類 ESLint 規則等。而當下,UT 這類有明確終結條件(單測是否通過 + 覆蓋率達到多少)的場景非常適合,也非常建議優先使用 LLM 生成,雖然還無法做到 L5 級別的完全自動駕駛,但完全能達到 L3 到 L4 之間的效果,已經能極大降低人力投入。
圖片
不過,從我的使用經驗來看,LLM 生成的代碼通常很難完全能適配團隊現行規范,經常出現 TS 類型不匹配、ESLint 錯誤等問題,甚至可能生成一些過于復雜的測試代碼,影響可讀性,但我認為這些問題在 LLM 加持的新開發模式下顯得不重要。
新模式下,開發者不斷迭代調用 LLM 生成、優化測試代碼,直至單測通過,覆蓋率達標,這個過程雖然需要人工介入解決一些疑難雜癥(例如錯誤的mock設置、錯誤的 ts alias 等),但編碼主體應該是 LLM,因此產出的代碼對人類而言的可讀性已經不太重要,可讀性好壞對下次 LLM 生成效果而言影響并不大,建議遇到這類錯誤不必過多糾結,適當 ignore 即可,將主要精力放在準確性上。
避免使用快照測試
快照測試是一種特殊的測試方法,它通過為組件或對象生成一個快照(即當前狀態的快照),并將其與先前保存的快照進行比較,以檢測是否有意外的變化,例如對于如下組件:
import React from 'react';
const MyComponent = ({ title }) => {
return (
<div>
<h1>{title}</h1>
<p>This is a simple react component.</p>
</div>
);
};
export default MyComponent;
生成快照結果如:
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MyComponent renders correctly 1`] = `
<div>
<h1>
Hello, World!
</h1>
<p>
This is a simple react component.
</p>
</div>
`;
后續每次執行測試命令時,Vitest 會重新渲染組件,對比前后快照內容是否一致,判定是否出現意料之外的變更。快照測試方法非常方便好使,但存在許多問題:
- 僅面向結果,不測試過程:難以測試組件行為,例如組件本身包含事件交互時,僅憑快照測試無法觸發也無法驗證這類事件交互是否符合要求,導致雖然單測覆蓋率看起來很高,但實際測試意義并不大,降低用例質量;
- 對結果極度敏感:組件本身的微小變動,例如加了一個 Class,刪了一個 Class,都會導致快照測試失敗,二者又會導致快照需要頻繁更新,增加維護成本;
- 難以定位問題:快照測試失敗時,從 Vitest 的結果只能感知到出問題了,節點對不上了,但難以定位問題的根因,調試修復難度較高;
因此,雖然這是一個捷徑,但請務必盡量規避快照測試,即使使用了快照測試,也應該及時補充更多圍繞功能邏輯相關的測試代碼。
9. 遵循 BCE 原則
BCE 既 Border-Correct-Error,在編寫測試用例時不能僅僅覆蓋函數主流程,優秀的單測應至少覆蓋 BCE 場景:
- Border:邊界值測試,包括循環邊界、特殊取值、特殊時間點、數據順序等;
- Correct: 正確輸入,得到正確輸出,判斷結果是否符合預期;
- Error:偽造錯誤輸入,校驗結果是否符合預期,特別關注是否會導致程序崩潰等;
例如,在開發一個文件上傳功能時,應關注:文件大小為0,或高于上限時的測試用例(Border);關注文件規則符合預期時,是否能夠正確觸發文件上傳的網絡請求,上傳的文件內容是否與用戶輸入一致(Correct);文件上傳過程中若網絡意外斷開,程序是否可正常報錯而不至于崩潰(Error)等等。
附錄
有沒有可能實現無人值守的 UT 生成?
我認為很難很難,我做過很多嘗試,在使用相同模型,并且預設了許多自認為合理的 Prompt 的情況下,直接調用 Claude API 生成的單測質量都很差,基本無法直接跑通。其次,即使 Cursor 生成的用例,成功率大概也只有 70% 左右,大部分時候會被各類小問題卡住,例如:
- 沒有正確 Mock 下游模塊,或者全局變量;
- 配置或底層包缺失;
- 用例本身有邏輯問題;
- 等等;
因此現階段,我認為還只能盡可能降低 UT 生成的時間成本,但無法針對任意代碼實現完全無人值守的 UT 生成,遇到復雜模塊的時候必然還是需要人工介入的,只是這個成本會越來越低。
UT 的局限
單元測試永遠無法證明代碼的正確性!!
一個跑失敗的測試可能表明代碼有錯誤, 但一個跑成功的測試什么也證明不了!單元測試最有效的使用場合是在一個較低的層級驗證并文檔化需求, 以及 回歸測試: 開發或重構代碼時,不會破壞已有功能的正確性。
因此,應該重視但不必過度迷信單測,應該更進一步搭建完整的自動化測試體系,包括:集成測試、E2E 測試等,自動化程度越高,回歸成本越低,項目越能敏捷迭代。