自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

5倍效率+覆蓋率90%,大部分程序員不知道的 Cursor 單測生成黑科技

開發(fā) 前端
一個跑失敗的測試可能表明代碼有錯誤, 但一個跑成功的測試什么也證明不了!單元測試最有效的使用場合是在一個較低的層級驗證并文檔化需求, 以及 回歸測試: 開發(fā)或重構(gòu)代碼時,不會破壞已有功能的正確性。

背景

單元測試(Unit Test)是一種用于測試軟件最小可測試單元的方法與技術(shù),通常針對一個函數(shù)、類、模塊粒度展開,內(nèi)容上基本遵循三步法:設(shè)定上下文(Arrange)、執(zhí)行代碼單元(Act)、觀測返回值或 Side-Effect 是否符合預(yù)期(Assert) ,進而驗證代碼效果是否符合設(shè)計預(yù)期。

那么,為什么要在業(yè)務(wù)代碼之外,費勁巴拉寫單測呢?《單元測試的藝術(shù)(https://book.douban.com/subject/25934516/)》書中提到一個案例:兩個研發(fā)能力相近的團隊,同時開發(fā)相似需求,實施單測團隊開發(fā)時間增加1倍,但集成測試階段 bug 少,調(diào)試定位速度更快,最終交付時間和bug數(shù)均表現(xiàn)更好:

圖片圖片

誠然,編寫單測代碼確實有較高的時間人力成本,但一方面單測能在開發(fā)階段快速反饋代碼的正確性,發(fā)現(xiàn)代碼中的邏輯錯誤、邊界條件處理不當(dāng)?shù)葐栴},起到查漏補缺效果,最終交付出質(zhì)量更好的代碼;另一方面,在后續(xù)維護過程中,無論是業(yè)務(wù)迭代、技術(shù)優(yōu)化,都能規(guī)避意外改動引入的質(zhì)量風(fēng)險;以及更重要的,單測越完善重構(gòu)成本越低,更容易引入各類 LLM 工具自動化地完成各種重復(fù)任務(wù)(例如:移除 FG、批量重構(gòu)變量名、自動提取公共方法等),項目的生命力也就越強。

圖片圖片

從質(zhì)量和長期可維護角度考慮,單測的 ROI 是非常高的。

過去我們總說寫單測很難很麻煩,但當(dāng)下,在 Cursor、Marscode 等輔助編碼工具的加持下,只要使用恰當(dāng)?shù)?Prompt 配合一些實踐技巧,開發(fā)者少量介入甚至完全無需介入即可生成質(zhì)量不錯的單元測試代碼,生成/編寫單測的時間成本已經(jīng)急劇下降。因此,強烈建議將單測視作必要項目的必要組成部分,日常提交業(yè)務(wù)代碼時應(yīng)習(xí)慣性地同步補充單測代碼(推薦由 AI 生成),保證較高的單測覆蓋率。

在后續(xù)章節(jié)中,我會總結(jié)若干基于 AI 生成 UT 的最佳實踐與技巧,幫助各位更高效地借助 cursor 生成單測。

使用 Cursor 生成單測

目前市面上已經(jīng)出現(xiàn)了很多輔助編程工具,cursor、windsuft、cline 等,但體驗下來 Cursor 的自動化程度最高,效果最好,所以這里以 Cursor 為例,介紹如何生成單測,前置步驟:

  • 安裝 cursor;
  • 開啟 codebase indexing,這能讓 Cursor 更好地理解整個倉庫,也能讓 Cursor 有機會學(xué)習(xí)存量單測代碼的寫法;
  • 開啟 cursor yolo 模式(要求 0.43 以上版本),這是一個強大的 AI Agent,能自行調(diào)用各類工具(eslint/ts/vitest 等)判斷生成代碼的合法性;

圖片圖片

  • 編寫適當(dāng)?shù)?nbsp;.cursorrules 文件;
  • 模型切換為 claude;
  • 打開目標(biāo)文件后,ctrl + i 打開 composer 面板,輸入 prompt:
為 @xxx 文件生成單測

// 或者

為 @xxx 包生成單測
為 @xxx 目錄生成單測

圖片圖片

到這里相信已經(jīng)能生成一些單測代碼,簡單場景通常能一遍過,但遇到復(fù)雜場景時,生成效果可能并不好,例如源碼中存在遞歸邏輯時,生成的質(zhì)量通常很差,這是因為 LLM 是基于概率演算的,并不真正具有邏輯推導(dǎo)能力,嚴格來說并不具備分析復(fù)雜代碼并生成相應(yīng)單測的能力,對此我們可以借助一些實踐方法,寫出一些更適合 LLM 推導(dǎo)單測的源碼;同時使用各類技巧更高效地調(diào)試單測代碼直至完善所有測試用例,進一步降低單測開發(fā)成本。

前置準(zhǔn)備

1.  以 Vitest 為測試框架

Vitest 是一個面向現(xiàn)代前端項目的測試框架,設(shè)計上與 Vite 兼容,并且致力于提供高性能、易于配置的測試環(huán)境。它使用 esbuild 進行快速編譯,并且支持許多現(xiàn)代 JavaScript 和 TypeScript 特性。作為對比,Jest 是一個老牌的 JS 測試框架,功能齊全但執(zhí)行速度相對較慢,且依賴結(jié)構(gòu)非常復(fù)雜,難以維護管理。兩者詳細對比:


特性

Jest

Vitest

性能

慢一些,使用 Babel 編譯

快速,使用 esbuild

現(xiàn)代化支持

部分 ESM 支持

原生 ESM 支持


與 Vite 集成

必須手動配置

原生支持,幾乎零配置

TypeScript 支持

通過 ts-jest 或 Babel

原生支持

插件生態(tài)

獨立的插件生態(tài)

與 Vite 共享插件生態(tài)

開發(fā)體驗

稍微慢一些

更快的反饋循環(huán)

依賴結(jié)構(gòu)

安裝 Jest 后會遞歸安裝許多下游依賴,結(jié)構(gòu)復(fù)雜度較高

許多代碼都被 Bundle 進 Vitest 的產(chǎn)物包,因此依賴結(jié)構(gòu)要簡單的多

因此,我個人更推薦使用 Vitest 作為測試框架,雖然也遇到了不少問題,但整體還是比較高效絲滑的。

2.  做好技術(shù)選型

在 vitest 之外,如果測試的主題是 React 組件,那么還需要引入更多工具實現(xiàn)組件渲染、hook 執(zhí)行等邏輯,這里羅列幾個你很可能會用到的工具:

  • @testing-library/react:提供一系列方法用于渲染 React 組件,并且可以方便地查詢和操作渲染后的組件實例(使用 render);同時支持測試組件的交互邏輯(fireEvent),比如模擬用戶的點擊、輸入等操作,從而驗證組件在不同交互下的行為是否符合預(yù)期;
// 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();

// 觸發(fā)點擊事件,檢查props.onClick是否被調(diào)用
  fireEvent.click(buttonNode);
  expect(mockOnClick).toHaveBeenCalledTimes(1);
});
});
  • @testing-library/react-hooks:專門用于測試 React Hooks 的庫,主要提供 renderHook 方法,可使用該方法調(diào)用要測試的 Hook,并獲取其返回值、狀態(tài)以及副作用等信息,并且允許測試不同依賴變化后 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);

// 執(zhí)行increment操作,計數(shù)值應(yīng)增加1
act(() => {
  result.current.increment();
});

expect(result.current.count).toBe(1);
});
  • @testing-library/user-event:用于模擬用戶與應(yīng)用程序交互的庫,可模擬各類用戶操作如點擊、輸入文本、選擇下拉選項等。相對而言,@testing-library/react 的 fireEvent 適用于測試組件內(nèi)部狀態(tài)流轉(zhuǎn)觸發(fā)的各類響應(yīng)事件邏輯,而 @testing-library/user-event 更適用于測試真實用戶交互所引發(fā)的副作用;
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 測試相關(guān)的庫,提供了一系列自定義的 Jest 匹配器(斷言函數(shù)),這些匹配器使得對 DOM 元素的斷言更加簡潔和直觀。例如,toBeInTheDocument 匹配器可以用來檢查某個元素是否在渲染后的 DOM 中;toHaveTextContent 可以用來驗證元素是否包含特定的文本內(nèi)容;
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.  目錄結(jié)構(gòu)

Vitest 會自動識別你的package 下面所有以test.ts 結(jié)尾的文件,理論來說,你可以按照自己的喜好進行組織,但這里建議:

  • 在 pakcage 根目錄下設(shè)立 __tests__ 文件夾,與 src 同級;
  • Package 內(nèi)所有的測試用例,都保存在上一步創(chuàng)建的文件夾中;
  • 為 src 目錄每一個源碼模塊 foo.ts,創(chuàng)建對應(yīng)同名測試模塊 foo.test.ts,且測試代碼的目錄結(jié)構(gòu)與源碼保持一致,方便對應(yīng);
  • 所有單測文件名均以 test.ts 結(jié)束;

最終形成如下結(jié)構(gòu):

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 相關(guān)單測代碼集中在 __tests__/root.test.tsx 中;
  • src/config-panel/foo.tsx 則集中在 __tests__/config-panel/foo.test.tsx 中,單測目錄結(jié)構(gòu)與源碼目錄結(jié)構(gòu)保持一致;

另外,期望源碼文件與單測文件一一對應(yīng),若出現(xiàn)某些測試文件代碼行數(shù)過多時,請不要拆解出多個單測文件,而應(yīng)該優(yōu)先判斷對應(yīng)源碼模塊的邏輯是否過于復(fù)雜,是否應(yīng)該做進一步模塊拆解。

4.  遵循 AAA 結(jié)構(gòu)

單元測試本質(zhì)上就是“在可控環(huán)境中,模擬觸發(fā)代碼邏輯,驗證執(zhí)行結(jié)果”的過程,一個標(biāo)準(zhǔn)的單測用例通常包含如下要素:

  • arrange:調(diào)用 vi.mock 等接口模擬上下文狀態(tài),構(gòu)建“可控”的測試環(huán)境;
  • act:調(diào)用測試目標(biāo)代碼,觸發(fā)執(zhí)行效果;
  • assert:檢測,驗證 act 的響應(yīng)效果是否符合預(yù)期,注意,單測中務(wù)必包含足夠完整的 assert,否則無法達成驗證效果的目標(biāo)。

建議后續(xù) UT 代碼均 AAA(Arrange-Act-Assert) 結(jié)構(gòu)組織代碼,遵循如下結(jié)構(gòu)要求:

  1. 除 vi.importActual 等特殊語句外,所有 import 語句均保存到文件開頭;
  2. import 語句之后,放置全局 vi.mock 調(diào)用,原則上應(yīng) mock 掉所有下游模塊;

圖片圖片

  • Mock 語句之后放置 describe 測試套件函數(shù),函數(shù)內(nèi)原則上不可嵌套多個 describe;函數(shù)內(nèi)應(yīng)包含多個 it 用例;
  • it 用例內(nèi)部遵循 arrange => act => asset 順序,例如:

圖片圖片

完整實例:

import { describe, it, expect, vi, beforeEach } from'vitest';
import { exec } from'shelljs';

import { ensureNotUncommittedChanges } from'@/utils/git';
// 導(dǎo)入被mock的模塊,以便我們可以訪問mock函數(shù)

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('應(yīng)該在 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.  頻繁提交代碼

初次生成可能問題不大,但后續(xù)使用 llm 迭代過程中,隨時可能會被改的面目全非,影響存量單測,因此建議頻繁提交、合入代碼,或者在本地將穩(wěn)定的單測內(nèi)容通過 git add 加入 staged 狀態(tài),之后再觸發(fā) LLM 生成新的代碼,有問題也方便隨時回滾。

2.  使用 only 方法

每次 LLM 生成的代碼都有可能測試不通過,使用 Vitest 的 [only](https://vitest.dev/api/#describe-only) 接口配合 Filter 能力,只跑存在問題的用例,降低信息噪音。

3.  使用 add to composer

單測出現(xiàn)問題時,可以使用terminal 右上角的 Add to Composer 按鈕,讓 LLM 繼續(xù)幫你解決問題。

圖片圖片

4.  合理配置 .cursorrules

Cursor 等輔助編程工具通常都具備一定的學(xué)習(xí)能力,能夠在持續(xù)交互迭代過程中學(xué)習(xí)到你的編程偏好,上下文技術(shù)棧等信息,

最佳實踐

1.  使用合適的模型與工具

以我的經(jīng)驗來說,Cursor + Claude 是當(dāng)下最適合輔助編程的工具組合。Cursor 的 Composer Agent 有極高的自動化能力,在給定任務(wù)后,Cursor 會自行規(guī)劃任務(wù)實現(xiàn)路徑,生成代碼并調(diào)用合適的工具驗證代碼是否符合預(yù)期。在生成單測的場景中,它會不斷調(diào)用 npm run test 等命令,直至測試代碼成功完成,或者達到預(yù)設(shè)的次數(shù)閾值才終止,結(jié)果就是,在使用時只需輸入一次 Prompt,之后等待任務(wù)執(zhí)行完成即可。

圖片圖片

其次,目前市面上可選的模型中,Claude 應(yīng)該是最適合用于編碼的模型,舉個例子,針對同一份源碼 GPT 與 Claude 生成結(jié)果對比:

  • GPT 4o 生成結(jié)果:
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'; // 假設(shè) main 函數(shù)是導(dǎo)出的

describe('Server Tests', () => {
let app: express.Application;

  beforeAll(async () => {
    app = express();
    await main(); // 初始化服務(wù)器
  });

  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 生成結(jié)果:
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'; // 假設(shè) main 函數(shù)是導(dǎo)出的

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(); // 初始化服務(wù)器
  });

  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 的結(jié)果要更完整些,且準(zhǔn)確率要高很多,從我的使用經(jīng)驗來看(陸續(xù)生成過 2w+ 行單測代碼),編程場景可以無腦選擇 Claude,這是最適合編程的模型。

PS:Deepseek 也有很高的潛力,不過目前 Cursor Composer Agent 模式還不支持 DS,因此棄了。

2.  簡化,簡化,簡化?。?!

理論上,源碼的復(fù)雜程度與編寫/生成單測的難度呈正相關(guān)關(guān)系。源碼越簡單,其邏輯結(jié)構(gòu)和功能實現(xiàn)往往更為清晰直觀,編寫/生成單測的難度也就越低。相反,源碼越復(fù)雜,分支鏈路越多,涉及的下游模塊越繁雜,邊界情況、異常處理和交互邏輯等都會相應(yīng)復(fù)雜許多,需要更多更復(fù)雜的測試用例,因此 LLM 生成單測的難度也隨之增高。

因此,為了提高測試效率和質(zhì)量,提高 LLM 生成 UT 的質(zhì)量,建議在開發(fā)過程中盡可能對優(yōu)化測試模塊的代碼結(jié)構(gòu),盡可能降低復(fù)雜度。有幾個維度可以輔助判斷模塊的復(fù)雜度:

  • 代碼行;
  • for/if 等語句的嵌套數(shù)量;
  • 參數(shù)過多;
  • React 組件中,嵌套的子組件數(shù)量、hooks 調(diào)用數(shù)量等;
  • React 組件中,JSX 結(jié)構(gòu)的長度、嵌套數(shù)等;
  • 是否存在遞歸結(jié)構(gòu),實測,LLM 對遞歸的理解難度很高,應(yīng)盡可能規(guī)避;

出現(xiàn)復(fù)雜結(jié)構(gòu)時,可參考如下方法逐步拆解優(yōu)化,降低模塊復(fù)雜度:

  • 單一職責(zé):代碼模塊/函數(shù)都只做一件事情,例如若函數(shù)中包含多層 for 循環(huán),通??砂囱h(huán)邏輯拆開,每個循環(huán)都整理成單獨的函數(shù),再通過函數(shù)之間的互相調(diào)用實現(xiàn);
  • 簡化邏輯,避免過度嵌套的條件判斷。
  • 對于復(fù)雜的邏輯,使用設(shè)計模式如策略模式、責(zé)任鏈模式等進行重構(gòu);定期審查代碼,識別和消除不必要的復(fù)雜性。
  • 對性能要求較高的模塊,進行性能測試和優(yōu)化,確保復(fù)雜度的降低不會影響系統(tǒng)的性能。
  • 保持一個 tsx 文件只有一個 react 組件或 hook,保持簡單,不允許出現(xiàn)嵌套組件;
  • 當(dāng)組件中包含過多 hooks 調(diào)用時,考慮將其提煉為單獨的hooks;
  • 避免副作用,可盡量使用純函數(shù)實現(xiàn)代碼;
  • 減少使用全局變量;

在我過往使用 Cursor 生成單測的過程中,最大的卡點就出現(xiàn)在復(fù)雜模塊上,源碼越復(fù)雜越難以正確生成單測,因此強烈建議各位在編寫代碼時多考慮如何為模塊編寫單測,盡可能保持簡潔簡單,盡可能寫出 UT 友好的代碼。

3.  mock 所有上下文

單元測試的核心要素在于“單元”,測試目標(biāo)應(yīng)聚焦在特定模塊/函數(shù)上,不應(yīng)該關(guān)注模塊之間的交互效果(這方面可由集成測試完成),因此需要營造一個“孤立”的環(huán)境,mock 掉所有可能影響測試結(jié)果的外部要素,將重點聚焦在單個模塊的內(nèi)在邏輯上。按經(jīng)驗,這里所說的外部要素包括:

  • 下游模塊:理應(yīng) mock 掉目標(biāo)模塊所引用的所有下游模塊(使用 vi.mock),特別是一些會產(chǎn)生副作用的下游調(diào)用,例如:接口請求、io 操作、store 操作、命令行調(diào)用等,這樣不僅能將測試的注意力聚焦在目標(biāo)模塊,而且能降低 LLM 生成單測時需要關(guān)注的上下文信息量。相反,若未正確 mock 下游模塊,還可能引發(fā)一些穩(wěn)定性問題:

     a.Case 1: 代碼中經(jīng)常會使用 ts alias 特性,在 vitest 環(huán)境中若沒有妥善設(shè)置對應(yīng) alias,則可能報下述錯誤,此時務(wù)必使用 vi.mock 處理相關(guān)下游模塊,方可正常運行

圖片圖片

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,引用一個文件時可能會向下遞歸引用非常多子孫模塊,若其中某些模塊存在副作用時,可能影響測試穩(wěn)定性;
  • Case 3:下游模塊變動導(dǎo)致目標(biāo)模塊測試結(jié)果不通過;
  • 環(huán)境變量:理應(yīng) mock 掉所有環(huán)境變量(使用 vi.stubGlobal、vi.stubEnv 等方法)與全局對象,避免在不同環(huán)境(CI/本地)中,由于環(huán)境變量不同導(dǎo)致測試結(jié)果不穩(wěn)定;
  • 時間:當(dāng)源碼中調(diào)用 Date 等函數(shù)獲取時間,且邏輯與時間強相關(guān)時,請務(wù)必使用 vi.useFakeTimers 函數(shù)設(shè)置模擬時間,如:
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', () => {
// 將時間設(shè)置在工作時間之內(nèi)
const date = newDate(2000, 1, 1, 13)
  vi.setSystemTime(date)

// 訪問 Date.now() 將生成上面設(shè)置的日期
  expect(purchase()).toEqual({ message: 'Success' })
})
})
  • 定時器:當(dāng)源碼中包含 setTimeout、setInterval 等定時邏輯時,應(yīng)使用 vi.advanceTimersByTime 等方法主動觸發(fā)定時器,避免單測超時,或時差導(dǎo)致測試結(jié)果不穩(wěn)定等問題,例如:
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)

// 確認回調(diào)還未被調(diào)用
  expect(callback).not.toHaveBeenCalled()

// 前進 1000ms
  vi.advanceTimersByTime(1000)

// 驗證回調(diào)被調(diào)用,且參數(shù)正確
  expect(callback).toHaveBeenCalledWith('Hello!')
})

it('should count three times with periodic timer', () => {
const callback = vi.fn()

  periodicCounter(callback)

// 第一次調(diào)用
  vi.advanceTimersByTime(1000)
  expect(callback).toHaveBeenCalledWith(1)

// 第二次調(diào)用
  vi.advanceTimersByTime(1000)
  expect(callback).toHaveBeenCalledWith(2)

// 第三次調(diào)用
  vi.advanceTimersByTime(1000)
  expect(callback).toHaveBeenCalledWith(3)

// 確認總共調(diào)用了三次
  expect(callback).toHaveBeenCalledTimes(3)
})
})

如果你想測試模塊之間的交互效果,應(yīng)該使用集成測試方案,這是另一個話題,不在本文討論。

4.  減少使用定時器

減少使用 setTimeout、setInterval 等定時器函數(shù),因為時序邏輯是一個復(fù)雜概念,針對定時器的測試邏輯非常麻煩,實測 LLM 生成單測時,即使使用 vitest 的各類 fakeTimers 也容易出現(xiàn)問題,推薦將這部分代碼提煉為公共函數(shù),如基于 setTimeout + Promise 封裝 wait 函數(shù);基于 setInterval + 迭代器實現(xiàn) Tick 函數(shù):

const wait = (ms: number): Promise<void> => {
  return new Promise(resolve => setTimeout(resolve, ms));
};
const tick = (interval = 1000) => {
let resolve = null;
let timer = null;

// 創(chuàng)建一個 Promise 和迭代器的橋接
const createPromise = () =>newPromise(r => resolve = r);

// 返回一個異步生成器
return {
    [Symbol.asyncIterator]() {
      // 啟動定時器
      timer = setInterval(() => {
        if (resolve) {
          resolve();
          resolve = null;
        }
      }, interval);

      return {
        async next() {
          // 等待下一次定時器觸發(fā)
          await createPromise();
          return { value: undefined, done: false };
        },
        return() {
          // 清理定時器
          if (timer) {
            clearInterval(timer);
            timer = null;
          }
          return { done: true };
        }
      };
    }
  };
}

forawait (const _ of tick(1000)) {
console.log('每秒執(zhí)行一次');
}

這樣做的好處是,在對上游模塊測試時可以 mock 掉 wait/tick 的執(zhí)行時機,避免依賴 js runtime 的定時器功能,也不必各處使用 vi.fakeTimer 等函數(shù),更容易控制單測邏輯。

5.  避免使用反義邏輯

如下圖:

圖片圖片

這段代碼是非常典型的詞不達意,代碼中 matches 變量的語義應(yīng)該是“匹配到的”,但變量值卻是 !useMediaQuery ,注意前面的 ! 邏輯,這個值的語義應(yīng)該是“沒有匹配到的”,兩者根本是相反的邏輯,這類代碼由人類理解尚且費力,何況 LLM。

因此,推薦盡可能減少出現(xiàn)這類詞不達意的代碼,盡可能減少使用反義邏輯,上述邏輯完全可以改為:

const isMatched = useMediaQuery(xxx)

規(guī)避副作用代碼

如果不加注意,我們很容易寫出具有 side-effect 的 ES Module,例如:

const reportToSSE = IS_OVERSEA
  ? () => {
      console.log("reportToSSE oversea");
    }
  : () => {
      console.log("reportToSSE china");
    };

針對這類代碼,在編寫單測時需要額外注意,不能直接 import 模塊,而是使用 vi.importActual 接口動態(tài)引入模塊,例如:

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');
  });
});

因此針對這類有副作用的代碼,單測的復(fù)雜度會高一些,并且實測 LLM 并不擅長處理 IS_OVERSEA 等非標(biāo)準(zhǔn)的環(huán)境變量,難以正確生成用例。另外,副作用越大復(fù)雜度越高,例如:

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 根作用域中,很難編寫測試,更好的方式應(yīng)該將其封裝為函數(shù),之后 Export 出來,供用例不斷調(diào)用測試。

因此,建議后續(xù)盡可能避免在 ESM 根作用域中直接編寫代碼,規(guī)避副作用。

7.  降低代碼規(guī)范要求

在過去,測試代碼純粹由人類智能編寫時,出于可讀性可維護性考慮,有必要保持一定程度的代碼規(guī)范,遵循各類 ESLint 規(guī)則等。而當(dāng)下,UT 這類有明確終結(jié)條件(單測是否通過 + 覆蓋率達到多少)的場景非常適合,也非常建議優(yōu)先使用 LLM 生成,雖然還無法做到 L5 級別的完全自動駕駛,但完全能達到 L3 到 L4 之間的效果,已經(jīng)能極大降低人力投入。

圖片圖片

不過,從我的使用經(jīng)驗來看,LLM 生成的代碼通常很難完全能適配團隊現(xiàn)行規(guī)范,經(jīng)常出現(xiàn) TS 類型不匹配、ESLint 錯誤等問題,甚至可能生成一些過于復(fù)雜的測試代碼,影響可讀性,但我認為這些問題在 LLM 加持的新開發(fā)模式下顯得不重要。

新模式下,開發(fā)者不斷迭代調(diào)用 LLM 生成、優(yōu)化測試代碼,直至單測通過,覆蓋率達標(biāo),這個過程雖然需要人工介入解決一些疑難雜癥(例如錯誤的mock設(shè)置、錯誤的 ts alias 等),但編碼主體應(yīng)該是 LLM,因此產(chǎn)出的代碼對人類而言的可讀性已經(jīng)不太重要,可讀性好壞對下次 LLM 生成效果而言影響并不大,建議遇到這類錯誤不必過多糾結(jié),適當(dāng) ignore 即可,將主要精力放在準(zhǔn)確性上。

避免使用快照測試

快照測試是一種特殊的測試方法,它通過為組件或?qū)ο笊梢粋€快照(即當(dāng)前狀態(tài)的快照),并將其與先前保存的快照進行比較,以檢測是否有意外的變化,例如對于如下組件:

import React from 'react';
const MyComponent = ({ title }) => {
  return (
    <div>
      <h1>{title}</h1>
      <p>This is a simple react component.</p>
    </div>
  );
};
export default MyComponent;

生成快照結(jié)果如:

// 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> 
`;

后續(xù)每次執(zhí)行測試命令時,Vitest 會重新渲染組件,對比前后快照內(nèi)容是否一致,判定是否出現(xiàn)意料之外的變更??煺諟y試方法非常方便好使,但存在許多問題:

  • 僅面向結(jié)果,不測試過程:難以測試組件行為,例如組件本身包含事件交互時,僅憑快照測試無法觸發(fā)也無法驗證這類事件交互是否符合要求,導(dǎo)致雖然單測覆蓋率看起來很高,但實際測試意義并不大,降低用例質(zhì)量;
  • 對結(jié)果極度敏感:組件本身的微小變動,例如加了一個 Class,刪了一個 Class,都會導(dǎo)致快照測試失敗,二者又會導(dǎo)致快照需要頻繁更新,增加維護成本;
  • 難以定位問題:快照測試失敗時,從 Vitest 的結(jié)果只能感知到出問題了,節(jié)點對不上了,但難以定位問題的根因,調(diào)試修復(fù)難度較高;

因此,雖然這是一個捷徑,但請務(wù)必盡量規(guī)避快照測試,即使使用了快照測試,也應(yīng)該及時補充更多圍繞功能邏輯相關(guān)的測試代碼。

9.  遵循 BCE 原則

BCE 既 Border-Correct-Error,在編寫測試用例時不能僅僅覆蓋函數(shù)主流程,優(yōu)秀的單測應(yīng)至少覆蓋 BCE 場景:

  • Border:邊界值測試,包括循環(huán)邊界、特殊取值、特殊時間點、數(shù)據(jù)順序等;
  • Correct: 正確輸入,得到正確輸出,判斷結(jié)果是否符合預(yù)期;
  • Error:偽造錯誤輸入,校驗結(jié)果是否符合預(yù)期,特別關(guān)注是否會導(dǎo)致程序崩潰等;

例如,在開發(fā)一個文件上傳功能時,應(yīng)關(guān)注:文件大小為0,或高于上限時的測試用例(Border);關(guān)注文件規(guī)則符合預(yù)期時,是否能夠正確觸發(fā)文件上傳的網(wǎng)絡(luò)請求,上傳的文件內(nèi)容是否與用戶輸入一致(Correct);文件上傳過程中若網(wǎng)絡(luò)意外斷開,程序是否可正常報錯而不至于崩潰(Error)等等。

附錄

有沒有可能實現(xiàn)無人值守的 UT 生成?

我認為很難很難,我做過很多嘗試,在使用相同模型,并且預(yù)設(shè)了許多自認為合理的 Prompt 的情況下,直接調(diào)用 Claude API 生成的單測質(zhì)量都很差,基本無法直接跑通。其次,即使 Cursor 生成的用例,成功率大概也只有 70% 左右,大部分時候會被各類小問題卡住,例如:

  • 沒有正確 Mock 下游模塊,或者全局變量;
  • 配置或底層包缺失;
  • 用例本身有邏輯問題;
  • 等等;

因此現(xiàn)階段,我認為還只能盡可能降低 UT 生成的時間成本,但無法針對任意代碼實現(xiàn)完全無人值守的 UT 生成,遇到復(fù)雜模塊的時候必然還是需要人工介入的,只是這個成本會越來越低。

UT 的局限

單元測試永遠無法證明代碼的正確性!!

一個跑失敗的測試可能表明代碼有錯誤, 但一個跑成功的測試什么也證明不了!單元測試最有效的使用場合是在一個較低的層級驗證并文檔化需求, 以及 回歸測試: 開發(fā)或重構(gòu)代碼時,不會破壞已有功能的正確性。

因此,應(yīng)該重視但不必過度迷信單測,應(yīng)該更進一步搭建完整的自動化測試體系,包括:集成測試、E2E 測試等,自動化程度越高,回歸成本越低,項目越能敏捷迭代。

責(zé)任編輯:武曉燕 來源: Tecvan
相關(guān)推薦

2020-04-03 08:42:08

Servelt3程序員Tomcat

2012-11-30 10:07:49

大數(shù)據(jù)云儲存數(shù)據(jù)挖掘

2021-02-08 22:32:43

程序員 靜態(tài)網(wǎng)頁

2019-09-12 09:56:13

程序員技能開發(fā)者

2019-10-11 10:05:30

程序員固態(tài)硬盤Google

2020-07-29 09:53:09

VSCode編碼工具插件

2018-05-08 15:30:46

程序員代碼框架

2019-11-24 19:34:04

HTTP長連接短連接

2024-04-01 08:26:30

單測覆蓋率字節(jié)碼

2019-06-12 10:35:49

程序員高效工具開源

2022-08-08 11:13:35

API接口前端

2011-08-23 13:50:17

程序員

2020-03-03 18:59:47

CDN緩存程序員

2018-09-20 17:05:01

前端程序員JavaScript

2019-07-12 15:28:41

緩存數(shù)據(jù)庫瀏覽器

2020-04-15 16:07:01

程序員技術(shù)數(shù)據(jù)

2021-11-30 22:59:28

程序員IT架構(gòu)師

2025-04-07 00:01:00

C#性能Debug

2021-03-01 19:13:45

YAML程序員數(shù)據(jù)

2013-11-21 13:35:19

程序員牛人
點贊
收藏

51CTO技術(shù)棧公眾號