本文详解在 UmiJS 项目中从零配置 Jest 单元测试与 Playwright 端到端测试的完整实践方案,涵盖虚拟模块 Mock、路径别名解析、测试脚本编排与持续集成自动化流水线。
UmiJS 项目中 Jest 单元测试与 Playwright E2E 测试配置
时间:2026-11-05
浏览:590
文章目录
在企业级前端项目开发中,构建健壮的质量保障防线通常依赖双层测试体系:一层是通过 Jest 配合 React Testing Library 针对基础工具函数、自定义 Hooks 以及原子/业务组件实施高频、毫秒级反馈的单元测试;另一层则是借助 Playwright 驱动真实浏览器引擎,模拟用户真实交互链路执行端到端(E2E)验收。本文将基于主流的 UmiJS 项目环境,完整演示从环境依赖安装、虚拟模块打桩、核心配置文件编写到自动化运行全流程,帮助团队搭建起边界清晰、执行高效的前端测试流水线。
一、UmiJS 架构特性与双轨测试选型
UmiJS 具备极强的约定式路由与运行时代码生成机制。在项目启动或构建时,Umi 会在 .umi 临时目录中动态生成包含路由管理、插件挂载、状态管理(如 useModel)和全局上下文的运行时代码。开发者在源码中经常通过 import { useModel, history } from 'umi' 导入内容,这些模块在编译期由 Umi 虚拟打包系统动态注入。
脱离 Umi 编译流水线直接执行测试工具时,往往会面临两个典型问题:一是单元测试在单纯的 Node.js 进程中无法识别 umi 这个虚拟导出;二是测试框架无法直接解析源码中的 @/ 路径别名与 CSS Modules(如 .less、.css)。因此,针对不同层级的测试,我们采用 Jest 与 Playwright 各司其职的方案:
- Jest + React Testing Library:适合逻辑密集型代码。在内存虚拟 DOM(jsdom)中运行,速度极快(百个用例数秒内执行完毕),重点验证组件内部状态流转、Props 渲染、工具类纯函数与自定义 Hook。
- Playwright:适合端到端业务闭环。它直接拉起真实的 Umi 开发服务器或生产构建预览,控制真实浏览器内核,完整验证页面加载、登录会话维持、路由拦截跳转和复杂接口网络交互。
二、Jest 单元测试全流程配置
在 UmiJS(以 Umi 4 / @umijs/max 为典型)中配置 Jest,最关键的核心在于让 Jest 能够脱离完整 Webpack/Vite 打包器,正确编译 TypeScript/JSX,并且对 Umi 的专属别名、CSS 模块和虚拟模块进行合理重定向与 Mock 打桩。
1. 安装单元测试依赖
在 UmiJS 项目根目录下,执行以下命令安装测试框架及相关工具链:
npm install --save-dev jest @types/jest ts-jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event identity-obj-proxy
各依赖职责说明:
jest与ts-jest:核心测试运行器,并负责实时转译 TypeScript/TSX。jest-environment-jsdom:提供基于浏览器的模拟 DOM 上下文(window、document)。@testing-library/react:React 官方推荐的测试库,倡导以“用户看到什么”进行测试断言。identity-obj-proxy:将 CSS/Less Modules 编译为一个返回类名键值对应的 Proxy 虚拟对象,防止编译样式语法报错。
2. 编写 `jest.config.ts` 配置文件
在项目根目录新建 jest.config.ts,配置模块映射与测试环境:
import type { Config } from 'jest';
const config: Config = {
// 设置测试运行环境为 jsdom,模拟浏览器 DOM API
testEnvironment: 'jest-environment-jsdom',
// 匹配测试文件规则
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{ts,tsx}',
'<rootDir>/src/**/*.{spec,test}.{ts,tsx}',
],
// 关键:解决 Umi 的别名与样式模块解析
moduleNameMapper: {
// 映射 @/ 到项目的 src 目录
'^@/(.*)$': '<rootDir>/src/$1',
// 拦截 less/css 模块,返回属性名本身作为样式 class 字符串
'\.(css|less|sass|scss)$': 'identity-obj-proxy',
// 拦截静态图片资源
'\.(jpg|jpeg|png|gif|webp|svg)$': '<rootDir>/tests/mocks/fileMock.js',
},
// TypeScript 转译器配置
transform: {
'^.+\.(ts|tsx)$': ['ts-jest', {
tsconfig: {
jsx: 'react-jsx',
},
}],
},
// 执行测试前注入的环境初始化补丁
setupFilesAfterEnv: ['<rootDir>/tests/jest.setup.ts'],
// 覆盖率收集范围
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/.umi/**',
'!src/.umi-production/**',
'!src/**/*.d.ts',
'!src/app.{ts,tsx}',
],
};
export default config;
3. 配置 `jest.setup.ts` 注入虚拟打桩与 DOM 补丁
由于 Umi 的 useModel、history 等均来自虚拟模块,在 tests/jest.setup.ts 中对其统一打桩是最简洁健壮的做法。同时补充浏览器缺失的常用 API(如 matchMedia):
import '@testing-library/jest-dom';
// 补齐 jsdom 缺失的 window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// 对 Umi 的核心导出进行 Mock
jest.mock('umi', () => {
return {
__esModule: true,
// 模拟路由历史跳转对象
history: {
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
location: { pathname: '/', search: '', hash: '', state: null },
},
// 模拟微模块状态管理 useModel
useModel: jest.fn((modelName: string) => {
if (modelName === 'useUserModel') {
return {
user: { id: 1001, name: '测试开发者', role: 'admin' },
loading: false,
refresh: jest.fn(),
};
}
return {};
}),
useLocation: jest.fn(() => ({ pathname: '/' })),
Link: ({ children, to, ...rest }: any) => {
return <a href={to} {...rest}>{children}</a>;
},
};
});
对于静态图片文件的 mock,建立一个空模块 tests/mocks/fileMock.js 即可:
module.exports = 'test-file-stub';
4. 编写实际组件单元测试用例
以一个典型的 Umi 业务用户卡片组件 src/components/UserCard/index.tsx 为例进行测试:
// src/components/UserCard/index.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { history } from 'umi';
import UserCard from './index';
describe('UserCard 组件单元测试', () => {
const mockProps = {
userId: 1001,
userName: '张三',
initialStatus: 'offline' as const,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('能正确渲染用户信息并展示初始状态', () => {
render(<UserCard {...mockProps} />);
expect(screen.getByText('用户: 张三')).toBeInTheDocument();
expect(screen.getByTestId('status-tag')).toHaveTextContent('离线');
});
it('点击切换状态按钮后,状态文案发生变更', () => {
render(<UserCard {...mockProps} />);
const toggleBtn = screen.getByRole('button', { name: /切换在线状态/i });
fireEvent.click(toggleBtn);
expect(screen.getByTestId('status-tag')).toHaveTextContent('在线');
});
it('点击“查看详情”能够调用 Umi 的 history.push 跳转至对应路由', () => {
render(<UserCard {...mockProps} />);
const detailBtn = screen.getByRole('button', { name: /查看详情/i });
fireEvent.click(detailBtn);
expect(history.push).toHaveBeenCalledTimes(1);
expect(history.push).toHaveBeenCalledWith('/users/1001');
});
});
三、Playwright E2E 测试全流程配置
单元测试保障了组件和状态函数的逻辑正确性,但在路由守卫跳转、全页面排版样式、真实 DOM 事件交互以及接口数据拉取阶段,依然可能发生线上故障。Playwright 可以在完全真实的 Chromium、WebKit、Firefox 引擎中运行,通过监听端口并在测试启动前自动拉起 Umi 服务,实现高保真的端到端验证。
1. 安装 Playwright 依赖与内核
运行官方脚手架快速安装 Playwright 测试包并下载匹配的无头浏览器二进制文件:
npm install --save-dev @playwright/test
# 下载测试专用的 Chromium、Firefox 和 WebKit 浏览器内核
npx playwright install --with-deps
2. 编写 `playwright.config.ts`
Playwright 支持通过 webServer 配置项管理 Umi 本地启动进程。在测试开始前,它会自动检查指定端口(通常为 8000),如果服务未启动则自动唤起,并在测试执行完毕后自动回收:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// 测试用例所在目录
testDir: './tests/e2e',
// 单个测试用例最大超时时间 (毫秒)
timeout: 45 * 1000,
// 断言超时
expect: {
timeout: 5000,
},
// 失败用例在 CI 环境重试 1 次,本地不重试
retries: process.env.CI ? 1 : 0,
// 并行度:CI 环境下单进程,本地根据 CPU 核心数适度并发
workers: process.env.CI ? 1 : undefined,
// 报告器输出形式:结合控制台清单与交互式 HTML 报告
reporter: [
['list'],
['html', { outputFolder: 'playwright-report', open: 'never' }],
],
use: {
// 基础访问地址,测试脚本中可直接使用相对路径,如 page.goto('/login')
baseURL: 'http://localhost:8000',
// 追踪录制策略:在用例首次失败时保留完整的 Trace 文件供排查
trace: 'on-first-retry',
// 失败时自动截图保存
screenshot: 'only-on-failure',
// 默认视口大小
viewport: { width: 1280, height: 720 },
},
// 跨浏览器测试矩阵配置
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
// 自动拉起 UmiJS 本地开发服务器
webServer: {
// 本地启动命令;如果是生产回归测试,也可使用 npm run build && npx serve dist
command: 'npm run start',
url: 'http://localhost:8000',
// 端口已存在时复用(极大提升本地二次运行效率)
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000, // Umi 首次冷启动编译耗时较大,给予 120 秒宽限期
},
});
3. 编写核心业务 E2E 测试用例
在 tests/e2e/auth-flow.spec.ts 中编写端到端测试。示例涵盖了登录验证、利用 page.route 拦截后端接口以获得稳定测试数据、页面跳转及仪表盘渲染断言:
import { test, expect } from '@playwright/test';
test.describe('系统权限登录与控制台流转测试', () => {
test.beforeEach(async ({ page }) => {
// 拦截登录鉴权 API,注入确定性的成功响应数据
await page.route('**/api/v1/auth/login', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
token: 'mock-jwt-token-abcdef123456',
user: { name: '自动化测试管理员', role: 'admin' },
}),
});
});
// 拦截仪表盘概览统计 API
await page.route('**/api/v1/dashboard/stats', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
totalUsers: 12890,
activeOrders: 456,
revenue: '¥1,280,000',
}),
});
});
});
test('未登录用户输入账号密码后成功进入仪表盘并展示指标', async ({ page }) => {
// 1. 打开登录页面
await page.goto('/login');
await expect(page).toHaveTitle(/登录 - 企业管理系统/);
// 2. 填写表单
await page.fill('input[placeholder="请输入用户名"]', 'admin');
await page.fill('input[placeholder="请输入密码"]', 'Secret123456');
// 3. 点击提交按钮
await page.click('button:has-text("登 录")');
// 4. 断言页面自动完成路由跳转至 /dashboard
await expect(page).toHaveURL(/.*\/dashboard/);
// 5. 断言仪表盘核心数据卡片已被渲染
const userCountCard = page.locator('[data-testid="stat-total-users"]');
await expect(userCountCard).toBeVisible();
await expect(userCountCard).toHaveText('12,890');
// 6. 验证顶部导航栏用户名展示正确
const userBadge = page.locator('.ant-pro-global-header-avatar-name');
await expect(userBadge).toContainText('自动化测试管理员');
});
test('密码错误时页面应展示错误气泡提示且不跳转', async ({ page }) => {
// 覆盖接口响应为 401 失败状态
await page.route('**/api/v1/auth/login', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ code: 40101, message: '用户名或密码无效' }),
});
});
await page.goto('/login');
await page.fill('input[placeholder="请输入用户名"]', 'admin');
await page.fill('input[placeholder="请输入密码"]', 'WrongPassword');
await page.click('button:has-text("登 录")');
// 断言错误提示节点出现
const alertMessage = page.locator('.ant-alert-message');
await expect(alertMessage).toBeVisible();
await expect(alertMessage).toHaveText('用户名或密码无效');
// 路由依然停留在登录页
expect(page.url()).toContain('/login');
});
});
四、脚本编排与工程化质量门禁
为了让测试体系真正发挥效力,需要将指令规范化并固化到 package.json 中,同时与 CI/CD 流程结合:
{
"scripts": {
"start": "umi dev",
"build": "umi build",
"test:unit": "jest --config jest.config.ts",
"test:unit:watch": "jest --config jest.config.ts --watch",
"test:unit:coverage": "jest --config jest.config.ts --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report",
"test:all": "npm run test:unit && npm run test:e2e"
}
}
五、常见问题排查与注意事项
原因:部分 npm 依赖(如 lodash-es 或 Umi 内部部分 esm 包)发布为纯 ES 模块,默认被 Jest 的 transformIgnorePatterns 忽略。
解法:在 jest.config.ts 中明确放行特定 ESM 模块转译:
transformIgnorePatterns: [
'node_modules/(?!(@umijs|lodash-es)/)',
],
原因:大型 Umi 项目首次执行 npm run start 时需进行依赖预编译与代码生成,耗时可能超出默认阈值。
解法:在本地测试前先行执行一次 npm run start 或在 CI 中采用预构建模式:先运行 npm run build,然后利用 npx serve -s dist -l 8000 进行静态产物回放测试,不仅秒级就绪,而且与线上生产环境高度一致。
通过合理结合 Jest 与 Playwright,UmiJS 项目能够在保障极速开发反馈体验的同时,构筑起稳固的代码交付防线。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。