博客 / 詳情

返回

為React組件庫引入自動化測試:從零到完善的實踐之路

為什麼我們需要測試?

我們的 React+TypeScript 業務組件庫已經穩定運行了一段時間,主要承載各類UI展示組件,如卡片、通知等。項目初期,迫於緊張的開發週期,我們暫時擱置了自動化測試的引入。當時團隊成員對組件邏輯瞭如指掌,即便沒有測試也能遊刃有餘。

然而隨着時間推移,問題逐漸顯現。當新成員加入或老組件需要迭代時,我們常常陷入兩難:修改代碼可能破壞原有功能,但不修改又無法滿足新需求。特別是在處理那些具有多種交互狀態的複雜組件時,手動測試變得既耗時又不可靠。這時,引入自動化測試的必要性就凸顯出來了。

搭建測試環境

依賴安裝

我們首先從安裝核心測試依賴開始,這些工具將構成我們測試體系的基礎框架:

  • 測試運行核心:jest和jsdom環境包
  • TypeScript支持:確保類型安全的測試環境
  • React測試工具:專門為React組件設計的測試工具鏈
npm install jest jest-environment-jsdom @types/jest ts-jest @testing-library/react @testing-library/jest-dom @testing-library/user-event --save-dev

配置Jest

創建jest.config.ts配置文件時,有幾個關注點:

  • 針對TypeScript項目的特殊處理
  • 瀏覽器環境的模擬
  • 測試初始化流程
  • 文件轉換規則
module.exports = {
  preset: "ts-jest", // 為 TypeScript 項目準備的 Jest 配置預設
  testEnvironment: "jsdom", // 測試運行在模擬的瀏覽器環境中
  setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"], // 指定在測試環境初始化後立即執行的文件
  transform: {
    "^.+\\.(ts|tsx)$": "ts-jest", // 使用 ts-jest 處理所有 .ts 和 .tsx 文件
  },
  testPathIgnorePatterns: ["/node_modules/", "/dist/"], // 忽略指定目錄下的測試文件
  moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], // 定義 Jest 能識別的模塊文件擴展名
};

export {}; // 使文件成為模塊

創建jest.setup.ts文件引入斷言庫:

import "@testing-library/jest-dom";

TypeScript配置

修改tsconfig.json包含測試相關文件:

{
  "include": [
    "src",
    "jest.config.ts",
    "jest.setup.ts",
    "__mocks__/**/*.ts"
  ]
}

測試用例編寫

我們以一個通知組件為例,該組件有兩種UI形態:

  • 標題和描述組合的時間內容文案提示
  • 帶有喇叭圖標的提示,點擊關閉按鈕時調用接口保存用户狀態

特殊依賴處理

組件中有三類特殊引入需要處理:

import './index.less';
import { noticeIcon, closeIcon } from "$src/common/icon";
import request from "$src/request";

1、處理 CSS/LESS 資源
Jest 默認無法解析 CSS/LESS 文件,我們可以通過配置將其模擬為空對象:

// jest.config.js
module.exports = {
  moduleNameMapper: {
    "\\.(less|css)$': '<rootDir>/__mocks__/styleMock.ts", // 指向一個空文件
  },
};

// __mocks__/styleMock.ts
module.exports = {};

2、配置路徑別名
對於 $src 這樣的路徑別名,需要在 Jest 配置中映射:

// jest.config.js
module.exports = {
  moduleNameMapper: {
    '^\\$src/(.*)$': '<rootDir>/src/$1',
  },
};

3、模擬圖標資源
對於圖標這類靜態資源,我們可以在測試文件中直接模擬:

// __tests__/index.test.tsx
jest.mock('$src/common/icon', () => ({
  noticeIcon: 'notice-icon-path',
  closeIcon: 'close-icon-path',
}));

4、模擬 API 請求

對於網絡請求模塊,我們可以將其轉換為 Jest 模擬函數:

// __tests__/index.test.tsx
import request from '$src/request';

const mockedRequest = request as jest.MockedFunction<typeof request>;

jest.mock('$src/request', () => ({
  __esModule: true, // 標識這是 ES Module
  default: jest.fn(() => Promise.resolve({ data: {} })),
}));

通過以上配置,我們能夠有效地隔離組件測試環境,專注於組件邏輯本身的測試,而不受樣式、靜態資源和網絡請求等外部因素的影響。

基礎測試框架搭建

我們首先建立測試的基本結構:

describe("Notification組件", () => {
  // 公共props定義
  const baseProps = {
    body: {},
    tokenId: "test-token",
    urlPrefix: "https://api.example.com",
  };

  // 每個測試用例前的清理工作
  beforeEach(() => {
    jest.clearAllMocks();
    mockedRequest.mockReset();
  });
});

核心測試場景覆蓋

在配置好 Jest 測試環境後,我們將針對通知組件編寫全面的測試用例。該組件具有兩種展示形態和交互邏輯,我們將從四個關鍵維度進行測試覆蓋:

1、邊界情況測試

我們首先考慮最極端的場景——當傳入無效props時,組件是否能夠優雅處理:

it("當傳入無效body時應安全地返回null", () => {
  const { container } = render(<Notification {...baseProps} body={null} />);
  expect(container.firstChild).toBeNull();
});

2、日期類型展示驗證
對於日期類型的通知,我們需要確認:

  • 關鍵文本是否正確渲染
  • DOM結構是否符合預期
  • 樣式類是否準確應用
it("應正確渲染日期類型通知", () => {
  render(<Notification {...dateProps} />);
  
  expect(screen.getByText("今日公告")).toBeInTheDocument();
  expect(screen.getByText("2023-06-15")).toBeInTheDocument();
  
  const dateContainer = screen.getByText("今日公告").parentElement;
  expect(dateContainer).toHaveClass("notice-header-date");
});

3、廣播類型交互測試

廣播通知的測試更加複雜,需要驗證:

  • 初始狀態下的元素展示
  • 圖標資源是否正確加載
  • 點擊關閉後的行為
describe("BROADCAST_TYPE 類型", () => {
  const broadcastProps = {
    ...baseProps,
    body: {
      type: BROADCAST_TYPE,
      content: "重要通知內容",
      closeUrl: "/close-notice",
    },
  };

  it("初始狀態下應該顯示廣播內容", () => {
    render(<Notification {...broadcastProps} />);

    // 驗證內容
    expect(screen.getByText("重要通知內容")).toBeInTheDocument();

    // 驗證圖片
    const images = screen.getAllByRole("img");
    expect(images[0]).toHaveAttribute("src", "notice-icon-path");
    expect(images[1]).toHaveAttribute("src", "close-icon-path");

    // 驗證類名
    const broadcastContainer = screen
      .getByText("重要通知內容")
      .closest(".notice-header-broadcast");
    expect(broadcastContainer).toBeInTheDocument();
  });

  it("點擊關閉按鈕後應該隱藏廣播內容", () => {
    render(<Notification {...broadcastProps} />);

    // 找到關閉按鈕(假設是最後一個img元素)
    const closeButton = screen.getAllByRole("img")[1].parentElement;
    fireEvent.click(closeButton!);

    expect(screen.queryByText("重要通知內容")).not.toBeInTheDocument();
  });
});

4、網絡請求場景全覆蓋
對於涉及API調用的場景,我們設計了多維度測試:

  • 正常請求流程
  • 無請求場景請求
  • 失敗處理
  • 請求中的狀態管理
describe("網絡請求測試", () => {
  const broadcastPropsWithCloseUrl = {
    ...baseProps,
    body: {
      type: BROADCAST_TYPE,
      content: "重要通知內容",
      closeUrl: "/close-notice",
    },
  };

  const broadcastPropsWithoutCloseUrl = {
    ...baseProps,
    body: {
      type: BROADCAST_TYPE,
      content: "重要通知內容",
      // 沒有closeUrl
    },
  };

  it("點擊關閉時應該發送請求", async () => {
    // 模擬請求成功
    mockedRequest.mockResolvedValue({ data: {} });

    render(<Notification {...broadcastPropsWithCloseUrl} />);

    const closeButton = screen.getAllByRole("img")[1].parentElement;

    await act(async () => {
      fireEvent.click(closeButton!);
    });

    //驗證請求參數
    expect(request).toHaveBeenCalledWith({
      url: "https://api.example.com/close-notice",
      method: "post",
      data: {},
      headers: {
        tokenId: "test-token",
      },
    });

    // 驗證UI更新
    expect(screen.queryByText("重要通知內容")).not.toBeInTheDocument();
  });

  it("當沒有closeUrl時不發送請求", async () => {
    render(<Notification {...broadcastPropsWithoutCloseUrl} />);

    const closeButton = screen.getAllByRole("img")[1].parentElement;

    await act(async () => {
      fireEvent.click(closeButton!);
    });

    expect(request).not.toHaveBeenCalled();
    // 驗證UI仍然會更新
    expect(screen.queryByText("重要通知內容")).not.toBeInTheDocument();
  });

  it("請求失敗時仍然關閉通知", async () => {
    // 模擬請求失敗
    mockedRequest.mockResolvedValue(new Error("Request failed"));

    render(<Notification {...broadcastPropsWithCloseUrl} />);

    const closeButton = screen.getAllByRole("img")[1].parentElement;

    await act(async () => {
      fireEvent.click(closeButton!);
    });

    // 驗證即使請求失敗,UI也會更新
    expect(screen.queryByText("重要通知內容")).not.toBeInTheDocument();
    expect(request).toHaveBeenCalled();
  });

  it("請求期間UI應保持響應", async () => {
    // 創建一個未立即resolve的Promise
    let resolveRequest: any;
    const promise = new Promise((resolve) => {
      resolveRequest = resolve;
    });
    mockedRequest.mockReturnValue(promise);

    render(<Notification {...broadcastPropsWithCloseUrl} />);

    const closeButton = screen.getAllByRole("img")[1].parentElement;

    // 第一次點擊
    fireEvent.click(closeButton!);

    // 驗證UI已立即更新
    expect(screen.queryByText("重要通知內容")).not.toBeInTheDocument();

    // 完成請求
    await act(async () => {
      resolveRequest({ data: {} });
    });
});

測試執行與覆蓋率

基礎測試執行

在完成通知組件的測試用例編寫後,可以在 package.json 中配置測試腳本:

{
  "scripts": {
    "test": "jest"
  }
}

執行 npm run test 命令後,如下圖所示,Jest 會在終端輸出測試結果,包括:

  • 測試文件數量
  • 通過的測試用例數
  • 失敗的測試用例詳情(包含錯誤堆棧信息)

覆蓋率報告配置

為了更全面地評估測試質量,可以通過修改 jest.config.ts 啓用覆蓋率統計:

module.exports = {
  collectCoverage: true, // 啓用覆蓋率收集
  coverageDirectory: "coverage", // 指定覆蓋率報告的輸出目錄
  coverageReporters: ["text", "html", "lcov", "clover"], //指定生成的覆蓋率報告格式
  coverageThreshold: {
    // 設置覆蓋率的最低閾值,如果未達標,Jest 會報錯
    global: {
      // 全局覆蓋率要求
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
    "./src/components/**/*.tsx": {
      // 針對特定目錄/文件設置更高要求
      branches: 90,
      functions: 90,
      lines: 90,
      statements: 90,
    },
  },
}  

執行測試後:終端會顯示各維度的覆蓋率百分比,在 coverage/ 目錄下生成詳細報告:index.html 提供可視化分析可逐層查看未覆蓋的代碼路徑。

示例輸出中顯示 common/util.ts 僅 32.39% 覆蓋率,低於預設閾值。此時應該優先補充核心工具函數的測試用例。通過持續完善測試覆蓋,可以有效提升組件迭代的可靠性,併為後續重構提供安全保障。

通過引入自動化測試,我們實現了從"人肉測試"到系統化保障的轉變。精心設計的測試用例覆蓋了各種邊界情況,配合覆蓋率分析,構建了多層次的質量防護體系。

如果你對前端工程化有興趣,或者想了解更多相關的內容,歡迎查看我的其他文章,這些內容將持續更新,希望能給你帶來更多的靈感和技術分享~

user avatar xiaojiedian 頭像 hachimei 頭像
2 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.