Planka前端单元测试:Jest与React Testing Library全指南

Planka前端单元测试:Jest与React Testing Library全指南

【免费下载链接】planka planka - 一个优雅的开源项目管理工具,提供创建项目、看板、列表、卡片、标签和任务等功能,适用于需要进行项目管理和团队协作的程序员。 【免费下载链接】planka 项目地址: https://gitcode.com/GitHub_Trending/pl/planka

引言:为何前端测试对项目管理工具至关重要

你是否曾遇到过这样的困境:团队协作中,一个微小的UI修改导致任务卡片拖拽功能失效?或者用户登录表单在特定浏览器中无法提交?作为一款开源项目管理工具(Project Management Tool),Planka的前端稳定性直接影响团队协作效率。本文将系统讲解如何使用Jest(测试运行器)与React Testing Library(组件测试库)为Planka构建健壮的前端测试体系,解决测试覆盖率低、组件交互验证难等核心痛点。

读完本文你将掌握:

  • 从零配置Planka前端测试环境的完整流程
  • 针对看板(Board)、任务卡片(Card)等核心组件的测试策略
  • 异步数据交互与Redux状态管理的测试技巧
  • 测试驱动开发(TDD)在实际项目中的落地实践

一、Planka测试现状分析与环境搭建

1.1 现有测试架构调研

通过分析Planka项目结构,我们发现当前测试主要集中在端到端验收测试层面:

client/tests/acceptance/
├── features/          # Cucumber测试场景定义
│   └── login.feature  # 登录功能测试用例
├── pages/             # 页面对象模型
│   ├── HomePage.js
│   └── LoginPage.js
└── steps/             # 测试步骤实现
    └── login.step.js  # 登录步骤定义

验收测试使用Playwright实现UI自动化,但前端单元测试覆盖率为零。这导致组件层面的回归风险难以提前发现,特别是在频繁迭代的看板功能中。

1.2 测试环境配置步骤

1.2.1 安装核心依赖
# 安装测试核心库
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event

# 安装Babel支持(已在package.json中配置)
npm install --save-dev @babel/preset-react babel-jest

# 安装React测试工具
npm install --save-dev react-test-renderer
1.2.2 配置Jest(package.json)
{
  "jest": {
    "transform": {
      "^.+\\.(js|jsx)$": "babel-jest"
    },
    "testEnvironment": "jsdom",
    "moduleNameMapper": {
      "^@/(.*)$": "<rootDir>/src/$1",
      "\\.(css|scss)$": "identity-obj-proxy"
    },
    "setupFilesAfterEnv": ["<rootDir>/src/setupTests.js"],
    "collectCoverageFrom": [
      "src/**/*.{js,jsx}",
      "!src/**/*.d.ts",
      "!src/mocks/**",
      "!src/**/index.js"
    ]
  }
}
1.2.3 创建测试辅助文件

src/setupTests.js(测试初始化配置):

import '@testing-library/jest-dom';
import { configure } from '@testing-library/react';

// 配置测试超时时间
jest.setTimeout(10000);

// 启用自动清理
configure({ cleanupAfterEach: true });

二、核心组件测试实战

2.1 登录表单组件测试(LoginForm.jsx)

2.1.1 测试场景分析
测试场景重要性测试方法
表单验证逻辑fireEvent.change + 断言错误提示
提交按钮状态切换jest.spyOn + userEvent.click
异步登录请求处理mockServiceWorker模拟API
键盘事件支持(Enter提交)fireEvent.keyPress
2.1.2 测试代码实现
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import LoginForm from '../components/auth/LoginForm';
import { login } from '../actions/authActions';

// 创建模拟Redux store
const mockStore = configureStore([thunk]);

// Mock Redux action
jest.mock('../actions/authActions', () => ({
  login: jest.fn()
}));

describe('LoginForm Component', () => {
  let store;
  
  beforeEach(() => {
    store = mockStore({
      auth: {
        loading: false,
        error: null
      }
    });
    login.mockReset();
  });

  test('显示表单验证错误当输入为空时', async () => {
    render(
      <Provider store={store}>
        <LoginForm />
      </Provider>
    );

    // 点击提交按钮而不输入任何内容
    await userEvent.click(screen.getByRole('button', { name: /登录/i }));

    // 验证错误提示出现
    expect(await screen.findByText(/用户名或邮箱不能为空/i)).toBeInTheDocument();
    expect(screen.getByText(/密码不能为空/i)).toBeInTheDocument();
    
    // 验证login action未被调用
    expect(login).not.toHaveBeenCalled();
  });

  test('提交表单当输入有效凭证时', async () => {
    render(
      <Provider store={store}>
        <LoginForm />
      </Provider>
    );

    // 输入凭证
    await userEvent.type(screen.getByLabelText(/用户名或邮箱/i), 'demo@example.com');
    await userEvent.type(screen.getByLabelText(/密码/i), 'password123');
    
    // 点击提交
    await userEvent.click(screen.getByRole('button', { name: /登录/i }));

    // 验证login action被正确调用
    expect(login).toHaveBeenCalledWith({
      emailOrUsername: 'demo@example.com',
      password: 'password123'
    });
  });

  test('显示加载状态当登录请求进行中', () => {
    // 创建带有loading状态的store
    const loadingStore = mockStore({
      auth: {
        loading: true,
        error: null
      }
    });

    render(
      <Provider store={loadingStore}>
        <LoginForm />
      </Provider>
    );

    // 验证提交按钮被禁用且显示加载中
    expect(screen.getByRole('button', { name: /登录中/i })).toBeDisabled();
  });
});

2.2 任务卡片组件测试(Card.jsx)

2.2.1 组件结构分析

mermaid

2.2.2 测试代码实现
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import Card from '../components/cards/Card';
import { toggleCardCompletion } from '../actions/cardActions';

const mockStore = configureStore([]);

jest.mock('../actions/cardActions', () => ({
  toggleCardCompletion: jest.fn()
}));

describe('Card Component', () => {
  const mockCard = {
    id: 'card-1',
    title: '完成单元测试文档',
    description: '编写Planka前端测试指南',
    dueDate: '2025-12-31T00:00:00Z',
    labels: [
      { id: 'label-1', name: '文档', color: '#4CAF50' }
    ],
    isCompleted: false
  };

  let store;

  beforeEach(() => {
    store = mockStore({
      boards: {
        currentBoardId: 'board-1'
      }
    });
    toggleCardCompletion.mockReset();
  });

  test('渲染卡片基本信息', () => {
    render(
      <Provider store={store}>
        <BrowserRouter>
          <Card card={mockCard} />
        </BrowserRouter>
      </Provider>
    );

    // 验证标题和描述
    expect(screen.getByText('完成单元测试文档')).toBeInTheDocument();
    expect(screen.getByText('编写Planka前端测试指南')).toBeInTheDocument();
    
    // 验证标签
    expect(screen.getByText('文档')).toHaveStyle('background-color: #4CAF50');
    
    // 验证截止日期
    expect(screen.getByText('2025-12-31')).toBeInTheDocument();
  });

  test('点击复选框触发完成状态切换', () => {
    render(
      <Provider store={store}>
        <BrowserRouter>
          <Card card={mockCard} />
        </BrowserRouter>
      </Provider>
    );

    // 点击复选框
    const checkbox = screen.getByRole('checkbox');
    fireEvent.click(checkbox);

    // 验证action被调用
    expect(toggleCardCompletion).toHaveBeenCalledWith({
      cardId: 'card-1',
      isCompleted: true,
      boardId: 'board-1'
    });
  });

  test('拖拽功能正常初始化', () => {
    render(
      <Provider store={store}>
        <BrowserRouter>
          <Card card={mockCard} />
        </BrowserRouter>
      </Provider>
    );

    const cardElement = screen.getByTestId('card-draggable');
    
    // 验证拖拽属性
    expect(cardElement).toHaveAttribute('draggable', 'true');
    
    // 模拟拖拽开始事件
    fireEvent.dragStart(cardElement);
    
    // 验证拖拽数据被设置
    expect(cardElement).toHaveClass('dragging');
  });
});

三、异步数据与Redux测试策略

3.1 Redux Action测试

以任务列表获取action为例:

import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fetchTasks } from '../actions/taskActions';
import * as api from '../api/taskApi';

// Mock API
jest.mock('../api/taskApi');

const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);

describe('Task Actions', () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  test('fetchTasks成功获取任务列表', async () => {
    // 模拟API响应
    const mockTasks = [
      { id: 'task-1', title: '测试任务1', completed: false },
      { id: 'task-2', title: '测试任务2', completed: true }
    ];
    api.getTasks.mockResolvedValue(mockTasks);

    // 期望的actions
    const expectedActions = [
      { type: 'FETCH_TASKS_REQUEST' },
      { type: 'FETCH_TASKS_SUCCESS', payload: mockTasks }
    ];

    const store = mockStore({ tasks: [] });
    await store.dispatch(fetchTasks('card-1'));

    // 验证actions是否按预期派发
    expect(store.getActions()).toEqual(expectedActions);
    // 验证API调用参数
    expect(api.getTasks).toHaveBeenCalledWith('card-1');
  });

  test('fetchTasks处理API错误', async () => {
    // 模拟API错误
    const errorMessage = '无法连接到服务器';
    api.getTasks.mockRejectedValue(new Error(errorMessage));

    // 期望的actions
    const expectedActions = [
      { type: 'FETCH_TASKS_REQUEST' },
      { type: 'FETCH_TASKS_FAILURE', payload: errorMessage }
    ];

    const store = mockStore({ tasks: [] });
    await store.dispatch(fetchTasks('card-1'));

    expect(store.getActions()).toEqual(expectedActions);
  });
});

3.2 使用MSW模拟API请求

src/mocks/server.js

import { setupServer } from 'msw/node';
import { rest } from 'msw';

export const server = setupServer(
  // 模拟登录API
  rest.post('/api/auth/login', (req, res, ctx) => {
    const { emailOrUsername } = req.body;
    
    if (emailOrUsername === 'demo') {
      return res(
        ctx.json({
          user: { id: '1', username: 'demo', email: 'demo@example.com' },
          token: 'fake-auth-token'
        })
      );
    }
    
    return res(
      ctx.status(401),
      ctx.json({ error: 'Invalid credentials' })
    );
  }),
  
  // 模拟任务API
  rest.get('/api/cards/:cardId/tasks', (req, res, ctx) => {
    const { cardId } = req.params;
    
    return res(
      ctx.json([
        { id: 'task-1', cardId, title: '任务1', completed: false },
        { id: 'task-2', cardId, title: '任务2', completed: true }
      ])
    );
  })
);

// 在所有测试前启动服务器
beforeAll(() => server.listen());
// 在每个测试后重置处理程序
afterEach(() => server.resetHandlers());
// 在所有测试后关闭服务器
afterAll(() => server.close());

测试用例示例

import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import TaskList from '../components/tasks/TaskList';
import { fetchTasks } from '../actions/taskActions';

// Mock action
jest.mock('../actions/taskActions');

const mockStore = configureStore([thunk]);

describe('TaskList Component with API', () => {
  test('加载并显示任务列表', async () => {
    // Mock store
    const store = mockStore({
      tasks: [],
      loading: false,
      error: null
    });
    
    // Mock action返回已解决的promise
    fetchTasks.mockResolvedValueOnce();

    render(
      <Provider store={store}>
        <BrowserRouter>
          <TaskList cardId="card-1" />
        </BrowserRouter>
      </Provider>
    );

    // 验证加载状态
    expect(screen.getByText(/加载中/i)).toBeInTheDocument();
    
    // 等待API响应并验证任务渲染
    await waitFor(() => {
      expect(screen.getByText('任务1')).toBeInTheDocument();
      expect(screen.getByText('任务2')).toBeInTheDocument();
      // 验证已完成任务样式
      expect(screen.getByText('任务2')).toHaveStyle('text-decoration: line-through');
    });
  });

  test('显示错误信息当API请求失败', async () => {
    // Mock store with error
    const store = mockStore({
      tasks: [],
      loading: false,
      error: '无法加载任务列表'
    });

    render(
      <Provider store={store}>
        <BrowserRouter>
          <TaskList cardId="card-1" />
        </BrowserRouter>
      </Provider>
    );

    // 验证错误信息显示
    expect(screen.getByText('无法加载任务列表')).toBeInTheDocument();
    // 验证重试按钮
    const retryButton = screen.getByRole('button', { name: /重试/i });
    expect(retryButton).toBeInTheDocument();
    
    // 点击重试按钮
    fireEvent.click(retryButton);
    
    // 验证fetchTasks被调用
    expect(fetchTasks).toHaveBeenCalledWith('card-1');
  });
});

四、测试驱动开发(TDD)实践

4.1 TDD开发流程

mermaid

4.2 标签筛选功能TDD示例

步骤1:编写失败测试
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import LabelFilter from '../components/filters/LabelFilter';

const mockStore = configureStore([]);

describe('LabelFilter Component', () => {
  const mockLabels = [
    { id: 'label-1', name: '前端', color: '#FF5733' },
    { id: 'label-2', name: '后端', color: '#33FF57' },
    { id: 'label-3', name: '文档', color: '#3357FF' }
  ];

  test('选择标签筛选卡片', () => {
    const store = mockStore({
      labels: mockLabels,
      filter: { selectedLabels: [] }
    });

    render(
      <Provider store={store}>
        <LabelFilter />
      </Provider>
    );

    // 验证所有标签显示
    mockLabels.forEach(label => {
      expect(screen.getByText(label.name)).toBeInTheDocument();
    });

    // 选择"前端"标签
    fireEvent.click(screen.getByText('前端'));
    
    // 验证筛选action被派发
    const actions = store.getActions();
    expect(actions).toContainEqual({
      type: 'SET_LABEL_FILTER',
      payload: ['label-1']
    });

    // 选择"后端"标签
    fireEvent.click(screen.getByText('后端'));
    
    // 验证多个标签被选中
    expect(store.getActions()).toContainEqual({
      type: 'SET_LABEL_FILTER',
      payload: ['label-1', 'label-2']
    });

    // 取消选择"前端"标签
    fireEvent.click(screen.getByText('前端'));
    
    // 验证标签被移除
    expect(store.getActions()).toContainEqual({
      type: 'SET_LABEL_FILTER',
      payload: ['label-2']
    });
  });
});
步骤2:实现最小可行代码
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import classNames from 'classnames';
import { setLabelFilter } from '../actions/filterActions';
import './LabelFilter.scss';

const LabelFilter = () => {
  const dispatch = useDispatch();
  const { labels, selectedLabels } = useSelector(state => ({
    labels: state.labels.items,
    selectedLabels: state.filter.selectedLabels
  }));

  const toggleLabel = (labelId) => {
    let newSelectedLabels;
    
    if (selectedLabels.includes(labelId)) {
      newSelectedLabels = selectedLabels.filter(id => id !== labelId);
    } else {
      newSelectedLabels = [...selectedLabels, labelId];
    }
    
    dispatch(setLabelFilter(newSelectedLabels));
  };

  return (
    <div className="label-filter">
      <h3>标签筛选</h3>
      <div className="label-options">
        {labels.map(label => (
          <span
            key={label.id}
            className={classNames('label-option', {
              'selected': selectedLabels.includes(label.id)
            })}
            style={{ backgroundColor: label.color }}
            onClick={() => toggleLabel(label.id)}
          >
            {label.name}
          </span>
        ))}
      </div>
    </div>
  );
};

export default LabelFilter;
步骤3:重构与优化
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import classNames from 'classnames';
import { setLabelFilter } from '../actions/filterActions';
import { useLabels } from '../hooks/useLabels'; // 抽取自定义Hook
import './LabelFilter.scss';

const LabelFilter = () => {
  const dispatch = useDispatch();
  const { selectedLabels } = useSelector(state => state.filter);
  const { labels, isLoading, error } = useLabels(); // 使用自定义Hook获取标签

  // 使用useCallback优化性能
  const toggleLabel = useCallback((labelId) => {
    dispatch(setLabelFilter(
      selectedLabels.includes(labelId)
        ? selectedLabels.filter(id => id !== labelId)
        : [...selectedLabels, labelId]
    ));
  }, [selectedLabels, dispatch]);

  if (isLoading) return <div className="loading">加载标签中...</div>;
  if (error) return <div className="error">无法加载标签: {error}</div>;
  if (!labels.length) return <div className="no-labels">暂无标签</div>;

  return (
    <div className="label-filter">
      <h3>标签筛选</h3>
      <div className="label-options">
        {labels.map(label => (
          <span
            key={label.id}
            className={classNames('label-option', { selected: selectedLabels.includes(label.id) })}
            style={{ backgroundColor: label.color }}
            onClick={() => toggleLabel(label.id)}
            data-testid={`label-${label.id}`}
            aria-pressed={selectedLabels.includes(label.id)}
          >
            {label.name}
          </span>
        ))}
        
        {selectedLabels.length > 0 && (
          <button
            className="clear-filters"
            onClick={() => dispatch(setLabelFilter([]))}
          >
            清除筛选
          </button>
        )}
      </div>
    </div>
  );
};

export default React.memo(LabelFilter); // 使用memo优化性能

五、测试覆盖率与CI集成

5.1 Jest覆盖率配置

在package.json中添加:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:ci": "jest --coverage --ci --reporters=default --reporters=jest-junit"
  },
  "jest": {
    "coverageThreshold": {
      "global": {
        "statements": 80,
        "branches": 70,
        "functions": 80,
        "lines": 80
      },
      "./src/components/": {
        "statements": 85,
        "branches": 75,
        "functions": 85,
        "lines": 85
      }
    },
    "coverageDirectory": "coverage",
    "coverageReporters": ["text", "lcov", "clover"]
  }
}

5.2 GitLab CI配置(.gitlab-ci.yml)

stages:
  - test
  - build

frontend-test:
  stage: test
  image: node:20-alpine
  before_script:
    - cd client
    - npm ci
  script:
    - npm run lint
    - npm run test:coverage
  artifacts:
    reports:
      junit: client/junit.xml
    paths:
      - client/coverage/
  only:
    - merge_requests
    - main

frontend-build:
  stage: build
  image: node:20-alpine
  before_script:
    - cd client
    - npm ci
  script:
    - npm run build
  artifacts:
    paths:
      - client/dist/
  only:
    - main

5.3 覆盖率报告分析

典型的覆盖率报告解读:

-------------------|---------|----------|---------|---------|-------------------
File               | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-------------------|---------|----------|---------|---------|-------------------
All files          |   82.35 |    73.12 |   78.95 |   83.12 |                   
 components/       |   87.50 |    76.47 |   85.00 |   88.24 |                   
  Card.jsx         |   90.00 |    80.00 |   90.00 |   90.00 | 45-47             
  LabelFilter.jsx  |   85.00 |    72.73 |   80.00 |   85.00 | 32, 56            
  LoginForm.jsx    |   89.47 |    75.00 |   87.50 |   90.00 | 68                
 actions/          |   78.57 |    70.00 |   75.00 |   78.57 |                   
  taskActions.js   |   78.57 |    70.00 |   75.00 |   78.57 | 34-36, 52         
-------------------|---------|----------|---------|---------|-------------------

针对未覆盖行,优先处理:

  1. 错误处理分支
  2. 条件渲染逻辑
  3. 边界情况处理

六、总结与最佳实践

6.1 测试金字塔在Planka中的应用

mermaid

6.2 核心最佳实践

  1. 组件测试

    • 使用data-testid标识关键元素
    • 优先测试用户行为而非实现细节
    • 使用React Testing Library的查询优先级
  2. Redux测试

    • 单独测试actions、reducers、selectors
    • 使用mockStore测试thunk
    • 复杂逻辑考虑集成测试
  3. 异步测试

    • 使用MSW模拟API请求
    • 避免使用真实定时器,使用jest.useFakeTimers()
    • 优先使用waitFor而非getBy*+setTimeout
  4. 性能优化

    • 使用React.memo避免不必要渲染
    • 合理使用useCallback和useMemo
    • 测试大数据集下的性能表现
  5. 可访问性测试

    • 验证ARIA属性
    • 测试键盘导航
    • 使用axe-core进行可访问性审计

6.3 未来测试计划

  1. 扩展测试覆盖

    • 为所有核心组件添加单元测试
    • 实现关键用户流程的集成测试
    • 添加视觉回归测试(如Percy)
  2. 测试工具链优化

    • 迁移到Vitest提升测试速度
    • 实现组件驱动开发(CDD)
    • 添加测试代码质量检查
  3. 开发体验提升

    • 实现测试驱动开发工作流
    • 添加预提交钩子自动运行相关测试
    • 开发测试辅助工具库

通过本文介绍的测试策略,Planka项目可以构建起健壮的前端测试体系,显著降低回归风险,提升代码质量,为用户提供更稳定可靠的项目管理体验。无论你是Planka贡献者还是企业内部开发者,这些测试实践都能帮助你构建更高质量的React应用。

本文配套代码示例已上传至Planka测试示例仓库,遵循MIT开源协议。欢迎提交Issue和PR共同完善Planka的测试体系。

【免费下载链接】planka planka - 一个优雅的开源项目管理工具,提供创建项目、看板、列表、卡片、标签和任务等功能,适用于需要进行项目管理和团队协作的程序员。 【免费下载链接】planka 项目地址: https://gitcode.com/GitHub_Trending/pl/planka

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值