剛開始寫前端測試時,總覺得是多此一舉——代碼能跑起來不就行了?直到一次上線後,一個簡單的表單驗證邏輯出錯,導致用户無法提交數據,才意識到測試的重要性。前端測試不僅能幫我們提前發現問題,還能讓代碼結構更清晰,重構時更有底氣。Jest 配合 React Testing Library 是目前 React 項目最流行的測試組合,前者負責測試運行和斷言,後者專注於模擬用户行為,兩者結合能寫出既可靠又貼近實際使用場景的測試。

一、環境準備:搭建測試框架

首先在 React 項目中安裝必要的依賴:

# 創建 React 項目(如果還沒有)
npx create-react-app my-app
cd my-app

# 安裝測試相關依賴(create-react-app 已內置 Jest)
# React Testing Library 相關包
npm install @testing-library/react @testing-library/jest-dom @testing-library/user-event --save-dev

安裝完成後,package.json 中會自動生成測試腳本,運行 npm test 即可啓動測試。

二、測試基礎:從一個按鈕組件開始

先從簡單的組件入手,比如一個點擊後會改變文本的按鈕組件:

// Button.js
import { useState } from 'react';

function Button() {
  const [count, setCount] = useState(0);
  
  return (
    <button onClick={() => setCount(count + 1)}>
      點擊了 {count} 次
    </button>
  );
}

export default Button;

對應的測試文件 Button.test.js 放在同一目錄下,測試內容包括:組件是否渲染、點擊後文本是否變化。

// Button.test.js
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';

// 測試套件
describe('Button 組件', () => {
  // 測試用例 1:組件能正常渲染
  test('渲染初始文本', () => {
    // 渲染組件
    render(<Button />);
    
    // 查找文本內容為“點擊了 0 次”的元素
    const buttonElement = screen.getByText(/點擊了 0 次/i);
    
    // 斷言元素存在
    expect(buttonElement).toBeInTheDocument();
  });
  
  // 測試用例 2:點擊後計數增加
  test('點擊後計數加 1', async () => {
    // 初始化用户事件模擬
    const user = userEvent.setup();
    
    render(<Button />);
    const buttonElement = screen.getByText(/點擊了 0 次/i);
    
    // 模擬用户點擊
    await user.click(buttonElement);
    
    // 斷言文本變為“點擊了 1 次”
    expect(screen.getByText(/點擊了 1 次/i)).toBeInTheDocument();
  });
});

運行 npm test 後,測試會通過,顯示兩個測試用例都成功。這裏的關鍵點是:

  • render 渲染組件到測試環境的 DOM 中
  • screen.getByText 查找元素(類似用户通過文本識別元素)
  • userEvent.click 模擬真實用户點擊(比 fireEvent 更貼近實際行為)
  • expect 斷言結果是否符合預期

三、表單測試:驗證用户輸入

表單是前端最常見的組件,測試重點包括輸入驗證、提交邏輯等。以一個簡單的登錄表單為例:

// LoginForm.js
import { useState } from 'react';

function LoginForm({ onSubmit }) {
  const [formData, setFormData] = useState({
    username: '',
    password: ''
  });
  const [error, setError] = useState('');
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (!formData.username || !formData.password) {
      setError('用户名和密碼不能為空');
      return;
    }
    setError('');
    onSubmit(formData);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      {error && <div className="error" role="alert">{error}</div>}
      <div>
        <label htmlFor="username">用户名</label>
        <input
          id="username"
          name="username"
          value={formData.username}
          onChange={handleChange}
        />
      </div>
      <div>
        <label htmlFor="password">密碼</label>
        <input
          id="password"
          name="password"
          type="password"
          value={formData.password}
          onChange={handleChange}
        />
      </div>
      <button type="submit">登錄</button>
    </form>
  );
}

export default LoginForm;

測試這個組件需要覆蓋:空值提交顯示錯誤、輸入內容後能正常提交。

// LoginForm.test.js
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

describe('LoginForm 組件', () => {
  test('提交空表單顯示錯誤', async () => {
    const user = userEvent.setup();
    // 模擬提交回調
    const mockSubmit = jest.fn();
    
    render(<LoginForm onSubmit={mockSubmit} />);
    
    // 點擊提交按鈕
    await user.click(screen.getByRole('button', { name: /登錄/i }));
    
    // 斷言錯誤信息顯示
    expect(screen.getByRole('alert')).toHaveTextContent(/用户名和密碼不能為空/i);
    // 斷言回調沒被調用
    expect(mockSubmit).not.toHaveBeenCalled();
  });
  
  test('輸入內容後能正常提交', async () => {
    const user = userEvent.setup();
    const mockSubmit = jest.fn();
    
    render(<LoginForm onSubmit={mockSubmit} />);
    
    // 輸入用户名
    await user.type(screen.getByLabelText(/用户名/i), 'testuser');
    // 輸入密碼
    await user.type(screen.getByLabelText(/密碼/i), 'testpass');
    // 提交表單
    await user.click(screen.getByRole('button', { name: /登錄/i }));
    
    // 斷言錯誤信息消失
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
    // 斷言回調被正確調用
    expect(mockSubmit).toHaveBeenCalledWith({
      username: 'testuser',
      password: 'testpass'
    });
  });
});

這裏用到了幾個實用技巧:

  • getByLabelText 通過標籤查找輸入框(符合用户通過標籤識別輸入框的習慣)
  • user.type 模擬用户輸入文本
  • jest.fn() 創建模擬函數,通過 toHaveBeenCalledWith 驗證調用參數
  • queryByRole 代替 getByRole 檢查元素是否不存在(getBy 找不到會報錯)

四、異步測試:處理 API 請求

實際項目中很多組件會調用 API,測試時需要模擬異步請求。以一個加載用户列表的組件為例:

// UserList.js
import { useState, useEffect } from 'react';
import axios from 'axios';

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');
  
  useEffect(() => {
    axios.get('/api/users')
      .then(res => {
        setUsers(res.data);
        setLoading(false);
      })
      .catch(err => {
        setError('加載失敗');
        setLoading(false);
      });
  }, []);
  
  if (loading) return <div>加載中...</div>;
  if (error) return <div role="alert">{error}</div>;
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

export default UserList;

測試時需要模擬 axios.get 方法,分別測試加載中、加載成功、加載失敗三種狀態:

// UserList.test.js
import { render, screen, waitFor } from '@testing-library/react';
import axios from 'axios';
import UserList from './UserList';

// 模擬 axios 模塊
jest.mock('axios');

describe('UserList 組件', () => {
  test('顯示加載狀態', () => {
    render(<UserList />);
    expect(screen.getByText(/加載中.../i)).toBeInTheDocument();
  });
  
  test('加載成功顯示用户列表', async () => {
    // 模擬 axios 返回成功數據
    axios.get.mockResolvedValue({
      data: [
        { id: 1, name: '張三' },
        { id: 2, name: '李四' }
      ]
    });
    
    render(<UserList />);
    
    // 等待加載完成,用户列表出現
    await waitFor(() => {
      expect(screen.getByText('張三')).toBeInTheDocument();
    });
    
    expect(screen.getByText('李四')).toBeInTheDocument();
  });
  
  test('加載失敗顯示錯誤信息', async () => {
    // 模擬 axios 返回失敗
    axios.get.mockRejectedValue(new Error('網絡錯誤'));
    
    render(<UserList />);
    
    // 等待錯誤信息出現
    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent(/加載失敗/i);
    });
  });
});

這裏的關鍵是用 jest.mock('axios') 替換真實的 axios 模塊,再通過 mockResolvedValuemockRejectedValue 模擬不同的返回結果。waitFor 用於等待異步操作完成,確保斷言在正確的時機執行。

五、測試原則與最佳實踐

  1. 測試行為而非實現:關注組件“做什麼”而不是“怎麼做”。比如測試按鈕點擊後文本變化,而不是測試 count 狀態是否加 1——即使內部實現從 useState 改成 useReducer,測試依然有效。
  2. 模擬用户真實行為:用 userEvent 而不是 fireEvent,前者更貼近用户實際操作(比如輸入文本會觸發 change 事件和光標移動)。
  3. 保持測試獨立:每個測試用例應該互不影響,避免一個測試的結果影響另一個。可以用 beforeEach 重置狀態。
  4. 只測必要的內容:不必追求 100% 覆蓋率,重點測試核心功能(如支付流程、表單提交),簡單的展示組件可以少測。
  5. 使用語義化查詢:優先用 getByRole(按角色,如按鈕、輸入框)、getByLabelText(按標籤)查找元素,而不是 getByClassNamegetByTestId——前者更符合用户體驗,也更能適應代碼重構。

總結

Jest + React Testing Library 讓前端測試變得簡單直觀,核心不是記住多少 API,而是理解“測試用户行為”這個理念。從簡單的組件開始,逐步覆蓋表單、異步請求等場景,你會發現測試其實是開發過程的一部分,能幫你更早發現問題,讓代碼更健壯。

剛開始寫測試可能覺得麻煩,但隨着項目變大,測試帶來的收益會越來越明顯——重構時不用手動點點點驗證功能,改完代碼跑一遍測試就知道有沒有問題。這大概就是為什麼成熟的前端團隊都離不開測試吧。