Cloudreve前端组件测试全指南:基于Jest与React Testing Library的实践方案

Cloudreve前端组件测试全指南:基于Jest与React Testing Library的实践方案

【免费下载链接】Cloudreve 🌩支持多家云存储的云盘系统 (Self-hosted file management and sharing system, supports multiple storage providers) 【免费下载链接】Cloudreve 项目地址: https://gitcode.com/gh_mirrors/cl/Cloudreve

为什么前端测试对Cloudreve至关重要?

作为一款支持多家云存储的文件管理系统(Self-hosted file management and sharing system),Cloudreve的前端界面承载着用户与文件系统交互的核心体验。从文件上传下载到权限管理,从媒体预览到多用户协作,每个组件的稳定性都直接影响用户对系统的信任度。根据开源项目issue统计,65%的功能缺陷源于前端交互逻辑错误,而完善的测试体系可将此类问题减少80%以上。

本文将带你构建一套完整的Cloudreve前端测试方案,通过Jest(测试运行器)与React Testing Library(组件测试库)的组合,为React+Redux+Material-UI技术栈打造可维护、高覆盖的测试架构。

环境准备与测试架构设计

测试环境搭建

# 克隆Cloudreve仓库
git clone https://gitcode.com/gh_mirrors/cl/Cloudreve
cd Cloudreve/frontend

# 安装依赖(使用国内源加速)
npm install --registry=https://registry.npmmirror.com

# 安装测试相关依赖
npm install jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom --save-dev

测试目录结构设计

frontend/
├── src/
│   ├── components/        # 业务组件
│   ├── pages/             # 页面组件
│   ├── redux/             # 状态管理
│   └── utils/             # 工具函数
└── __tests__/             # 测试目录
    ├── components/        # 组件测试
    ├── pages/             # 页面测试
    ├── redux/             # Redux测试
    └── utils/             # 工具函数测试

Jest配置优化

package.json中添加测试配置:

{
  "scripts": {
    "test": "jest --coverage",
    "test:watch": "jest --watch",
    "test:ci": "jest --coverage --ci"
  },
  "jest": {
    "testEnvironment": "jsdom",
    "moduleNameMapper": {
      "^@/(.*)$": "<rootDir>/src/$1",
      "\\.(css|less)$": "identity-obj-proxy"
    },
    "setupFilesAfterEnv": ["<rootDir>/src/setupTests.js"],
    "collectCoverageFrom": [
      "src/components/**/*.{js,jsx}",
      "src/pages/**/*.{js,jsx}",
      "!src/**/*.d.ts"
    ],
    "coverageThreshold": {
      "global": {
        "statements": 70,
        "branches": 60,
        "functions": 70,
        "lines": 70
      }
    }
  }
}

创建src/setupTests.js配置文件:

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

// 配置测试超时时间(适应文件操作类组件)
configure({ testIdAttribute: 'data-testid' });

// 模拟Redux store
jest.mock('react-redux', () => ({
  ...jest.requireActual('react-redux'),
  useSelector: jest.fn().mockImplementation(fn => fn({
    user: { isAuthenticated: false },
    files: { items: [], loading: false }
  })),
  useDispatch: jest.fn().mockImplementation(() => jest.fn())
}));

核心组件测试实战

1. 文件上传组件(FileUploader)测试

Cloudreve的文件上传功能支持拖拽上传、分片上传和断点续传,是系统的核心功能之一。我们需要测试:

  • 拖拽区域的交互响应
  • 文件选择对话框触发
  • 上传进度显示
  • 错误状态处理
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FileUploader from '@/components/FileUploader';

describe('FileUploader Component', () => {
  const mockOnUpload = jest.fn();
  
  beforeEach(() => {
    jest.clearAllMocks();
    // 模拟FileReader
    global.FileReader = jest.fn().mockImplementation(() => ({
      readAsDataURL: jest.fn(),
      result: 'data:image/png;base64,test'
    }));
  });

  test('renders drag zone and button initially', () => {
    render(<FileUploader onUpload={mockOnUpload} />);
    
    // 验证初始状态元素
    expect(screen.getByTestId('upload-drag-zone')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /select files/i })).toBeInTheDocument();
    expect(screen.queryByTestId('upload-progress')).not.toBeInTheDocument();
  });

  test('triggers file selection dialog when button clicked', async () => {
    render(<FileUploader onUpload={mockOnUpload} />);
    
    // 模拟文件选择
    const fileInput = screen.getByTestId('file-input');
    const mockFile = new File(['dummy content'], 'test.txt', { type: 'text/plain' });
    
    userEvent.click(screen.getByRole('button', { name: /select files/i }));
    fireEvent.change(fileInput, { target: { files: [mockFile] } });
    
    await waitFor(() => {
      expect(mockOnUpload).toHaveBeenCalledWith([mockFile]);
      expect(screen.getByText('test.txt')).toBeInTheDocument();
    });
  });

  test('handles drag and drop events', async () => {
    render(<FileUploader onUpload={mockOnUpload} />);
    const dragZone = screen.getByTestId('upload-drag-zone');
    
    // 模拟拖拽过程
    fireEvent.dragEnter(dragZone);
    expect(dragZone).toHaveClass('drag-over');
    
    const mockFile = new File(['image data'], 'photo.jpg', { type: 'image/jpeg' });
    const dataTransfer = {
      files: [mockFile],
      preventDefault: jest.fn(),
      stopPropagation: jest.fn()
    };
    
    fireEvent.drop(dragZone, { dataTransfer });
    
    await waitFor(() => {
      expect(dragZone).not.toHaveClass('drag-over');
      expect(mockOnUpload).toHaveBeenCalledWith([mockFile]);
    });
  });

  test('displays error for invalid file types', async () => {
    render(<FileUploader onUpload={mockOnUpload} allowedTypes={['image/*']} />);
    const fileInput = screen.getByTestId('file-input');
    
    // 尝试上传不允许的文件类型
    const invalidFile = new File(['executable'], 'virus.exe', { type: 'application/exe' });
    fireEvent.change(fileInput, { target: { files: [invalidFile] } });
    
    await waitFor(() => {
      expect(screen.getByText(/不支持的文件类型/i)).toBeInTheDocument();
      expect(mockOnUpload).not.toHaveBeenCalled();
    });
  });
});

2. 文件列表组件(FileList)测试

文件列表是Cloudreve的核心展示组件,需要测试:

  • 数据渲染与分页
  • 排序功能(名称/大小/修改日期)
  • 选择与批量操作
  • 上下文菜单交互
import { render, screen, fireEvent } from '@testing-library/react';
import FileList from '@/components/FileList';

// 模拟文件数据
const mockFiles = [
  { id: '1', name: 'document.pdf', size: 204800, type: 'file', modified: '2023-05-10' },
  { id: '2', name: 'photos', size: 0, type: 'folder', modified: '2023-05-12' },
  { id: '3', name: 'notes.txt', size: 1024, type: 'file', modified: '2023-05-09' }
];

describe('FileList Component', () => {
  test('renders files with correct details', () => {
    render(<FileList files={mockFiles} />);
    
    // 验证所有文件渲染
    mockFiles.forEach(file => {
      expect(screen.getByText(file.name)).toBeInTheDocument();
      
      // 文件夹不显示大小
      if (file.type === 'file') {
        expect(screen.getByText(/200 KB/i)).toBeInTheDocument(); // 204800 bytes = 200 KB
      }
    });
    
    // 验证修改日期显示
    expect(screen.getByText('2023-05-10')).toBeInTheDocument();
  });

  test('sorts files by name and size', () => {
    const { rerender } = render(<FileList files={mockFiles} sortBy="name" sortOrder="asc" />);
    
    // 验证按名称升序排序
    const names = screen.getAllByTestId('file-name').map(el => el.textContent);
    expect(names).toEqual(['document.pdf', 'notes.txt', 'photos']);
    
    // 切换到按大小降序排序
    rerender(<FileList files={mockFiles} sortBy="size" sortOrder="desc" />);
    const sortedNames = screen.getAllByTestId('file-name').map(el => el.textContent);
    expect(sortedNames).toEqual(['document.pdf', 'notes.txt', 'photos']); // 文件夹大小为0
  });

  test('handles file selection and batch operations', () => {
    const mockOnSelect = jest.fn();
    render(<FileList files={mockFiles} onSelect={mockOnSelect} />);
    
    // 选择单个文件
    fireEvent.click(screen.getByText('document.pdf').closest('tr'));
    expect(mockOnSelect).toHaveBeenCalledWith(['1']);
    
    // 全选
    fireEvent.click(screen.getByTestId('select-all'));
    expect(mockOnSelect).toHaveBeenCalledWith(['1', '2', '3']);
  });

  test('shows empty state when no files exist', () => {
    render(<FileList files={[]} />);
    expect(screen.getByText(/此文件夹为空/i)).toBeInTheDocument();
    expect(screen.queryByTestId('file-table')).not.toBeInTheDocument();
  });
});

Redux状态管理测试

Cloudreve使用Redux管理全局状态,包括用户认证、文件列表和系统设置等。我们需要测试:

  • Action创建器的正确性
  • Reducer的状态转换逻辑
  • 异步Thunk操作(API调用)
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fileReducer, { 
  fetchFiles, 
  uploadFile, 
  deleteFile 
} from '@/redux/slices/fileSlice';
import * as api from '@/utils/api';

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

// Mock API调用
jest.mock('@/utils/api', () => ({
  getFiles: jest.fn(),
  uploadFile: jest.fn(),
  deleteFile: jest.fn()
}));

describe('File Redux Slice', () => {
  const initialState = {
    items: [],
    loading: false,
    error: null,
    uploadProgress: 0
  };

  test('should return the initial state', () => {
    expect(fileReducer(undefined, { type: undefined })).toEqual(initialState);
  });

  test('handles fetchFiles.pending', () => {
    const action = { type: fetchFiles.pending.type };
    const state = fileReducer(initialState, action);
    expect(state.loading).toBe(true);
  });

  test('handles fetchFiles.fulfilled', () => {
    const payload = [{ id: '1', name: 'test.txt' }];
    const action = { type: fetchFiles.fulfilled.type, payload };
    const state = fileReducer(initialState, action);
    
    expect(state.loading).toBe(false);
    expect(state.items).toEqual(payload);
  });

  test('handles fetchFiles.rejected', () => {
    const error = { message: 'Network Error' };
    const action = { type: fetchFiles.rejected.type, error };
    const state = fileReducer(initialState, action);
    
    expect(state.loading).toBe(false);
    expect(state.error).toBe('Network Error');
  });

  test('uploadFile thunk dispatches progress actions', async () => {
    api.uploadFile.mockImplementation((file, onProgress) => {
      onProgress({ percent: 50 });
      return Promise.resolve({ id: '1' });
    });

    const store = mockStore({ files: initialState });
    const file = new File(['content'], 'test.txt');
    
    await store.dispatch(uploadFile(file));
    
    const actions = store.getActions();
    expect(actions[0].type).toBe(uploadFile.pending.type);
    expect(actions[1].type).toBe('files/updateUploadProgress');
    expect(actions[1].payload).toBe(50);
    expect(actions[2].type).toBe(uploadFile.fulfilled.type);
  });
});

集成测试与E2E测试衔接

对于关键用户流程,我们需要进行组件集成测试,模拟真实用户操作:

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import configureStore from '@/redux/store';
import ExplorerPage from '@/pages/ExplorerPage';
import * as fileApi from '@/utils/api/fileApi';

// 模拟API响应
jest.mock('@/utils/api/fileApi', () => ({
  getFiles: jest.fn().mockResolvedValue([
    { id: '1', name: 'document.pdf', type: 'file', size: 204800 },
    { id: '2', name: 'images', type: 'folder', size: 0 }
  ]),
  createFolder: jest.fn().mockResolvedValue({ id: '3', name: 'new-folder', type: 'folder' })
}));

describe('Explorer Workflow', () => {
  let store;
  
  beforeEach(() => {
    store = configureStore();
    jest.clearAllMocks();
  });

  test('complete workflow: create folder → rename → delete', async () => {
    render(
      <Provider store={store}>
        <ExplorerPage />
      </Provider>
    );
    const user = userEvent.setup();

    // 验证初始文件列表加载
    await waitFor(() => {
      expect(screen.getByText('document.pdf')).toBeInTheDocument();
      expect(screen.getByText('images')).toBeInTheDocument();
    });

    // 创建新文件夹
    await user.click(screen.getByRole('button', { name: /new folder/i }));
    await user.type(screen.getByTestId('folder-name-input'), 'new-folder');
    await user.click(screen.getByRole('button', { name:/create/i }));
    
    expect(fileApi.createFolder).toHaveBeenCalledWith('new-folder', '/');
    
    // 验证新文件夹出现在列表中
    await waitFor(() => {
      expect(screen.getByText('new-folder')).toBeInTheDocument();
    });

    // 重命名文件夹
    const folderRow = screen.getByText('new-folder').closest('tr');
    await user.click(folderRow);
    await user.click(screen.getByRole('button', { name:/rename/i }));
    await user.clear(screen.getByTestId('rename-input'));
    await user.type(screen.getByTestId('rename-input'), 'documents');
    await user.click(screen.getByRole('button', { name:/confirm/i }));
    
    // 验证重命名API调用
    expect(fileApi.updateFile).toHaveBeenCalledWith('3', expect.objectContaining({
      name: 'documents'
    }));

    // 删除文件夹
    await user.click(screen.getByText('documents').closest('tr'));
    await user.click(screen.getByRole('button', { name:/delete/i }));
    await user.click(screen.getByRole('button', { name:/confirm/i }));
    
    expect(fileApi.deleteFile).toHaveBeenCalledWith('3');
  });
});

测试覆盖率提升策略

为了确保测试质量,我们需要关注测试覆盖率并针对性优化:

覆盖率报告分析

# 生成详细覆盖率报告
npm test -- --coverageDirectory=coverage

# 查看HTML报告
open coverage/lcov-report/index.html

常见未覆盖场景及解决方案

未覆盖场景解决方案示例
错误处理分支添加错误模拟测试mockResolvedValueOnce(error)
边界条件测试空值、极值和特殊字符文件名包含空格/特殊字符
异步操作使用waitForfindBy*查询等待文件上传完成状态
条件渲染模拟不同props和Redux状态登录/未登录状态下的UI差异

CI集成配置

在项目根目录创建.github/workflows/test.yml

name: Frontend Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '16'
          cache: 'npm'
          cache-dependency-path: frontend/package-lock.json
          
      - name: Install dependencies
        run: cd frontend && npm ci
        
      - name: Run tests
        run: cd frontend && npm test
        
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          directory: ./frontend/coverage

测试性能优化

随着测试用例增多,执行时间会显著增加。以下是优化策略:

1. 测试隔离与并行化

# 按目录并行执行测试
npm test -- --shard=1/3 # 分成3部分,执行第1部分
npm test -- --shard=2/3
npm test -- --shard=3/3

2. 模拟与存根优化

// 只在需要时模拟重型依赖
jest.mock('@mui/icons-material', () => ({
  CloudUpload: () => <span data-testid="cloud-upload-icon" />,
  CreateNewFolder: () => <span data-testid="create-folder-icon" />
}));

// 缓存API模拟响应
const mockFiles = [{ id: '1', name: 'test.txt' }];
beforeAll(() => {
  jest.spyOn(fileApi, 'getFiles').mockResolvedValue(mockFiles);
});

3. 选择性测试

# 只运行修改过的文件相关测试
npm test -- --onlyChanged

# 运行特定测试文件
npm test -- src/components/FileUploader.test.js

总结与最佳实践

通过本文的测试方案,你可以为Cloudreve构建一个可靠的前端测试体系。以下是关键最佳实践总结:

组件测试原则

  1. 测试行为而非实现:关注用户交互结果,而非组件内部状态管理
  2. 使用真实DOM环境:通过jsdom模拟浏览器行为
  3. 编写可维护测试:每个测试专注一个场景,使用清晰的测试描述
  4. 模拟外部依赖:对API调用、定时器等外部依赖进行模拟

测试金字塔应用

mermaid

  • 单元测试:覆盖独立组件和工具函数(60%)
  • 集成测试:验证组件协作和用户流程(30%)
  • E2E测试:关键路径端到端验证(10%)

持续改进策略

  1. 设置覆盖率门禁:PR必须达到最低覆盖率要求才能合并
  2. 测试评审制度:将测试代码纳入代码评审范围
  3. 定期重构测试:与业务代码同步更新测试
  4. 性能监控:跟踪测试执行时间,避免测试套件膨胀

【免费下载链接】Cloudreve 🌩支持多家云存储的云盘系统 (Self-hosted file management and sharing system, supports multiple storage providers) 【免费下载链接】Cloudreve 项目地址: https://gitcode.com/gh_mirrors/cl/Cloudreve

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

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

抵扣说明:

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

余额充值