单元测试 & E2E测试:Jest + Cypress 组合拳
单元测试和E2E测试是前端质量保障的两大核心手段。Jest作为JavaScript生态中流行的单元测试框架,与Cypress这一现代E2E测试工具的结合,能覆盖从函数级验证到用户交互的全流程测试场景。
单元测试:Jest的核心能力
Jest以其零配置、快照测试和强大的Mock功能成为React等前端项目的首选测试框架。它的核心优势体现在:
- 快速反馈:通过
--watch
模式实现热更新测试 - 隔离测试:自动为每个测试文件创建独立沙箱环境
- 快照比对:对UI组件输出进行版本控制
典型测试用例结构如下:
// utils.test.js
import { formatDate } from './dateUtils';
describe('日期格式化工具', () => {
test('应该正确处理ISO格式日期', () => {
const input = '2023-05-15T08:30:00Z';
expect(formatDate(input)).toBe('2023年5月15日');
});
test('空值应该返回默认占位符', () => {
expect(formatDate(null)).toBe('--');
});
});
Mock功能的实际应用示例:
// api.test.js
jest.mock('./apiClient');
describe('用户数据获取', () => {
it('应该处理API错误', async () => {
require('./apiClient').getUser.mockRejectedValue(new Error('Timeout'));
const { fetchUser } = require('./userService');
await expect(fetchUser(123)).rejects.toThrow('请求超时');
});
});
E2E测试:Cypress实战
Cypress解决了传统E2E测试工具的三大痛点:
- 实时重载测试运行
- 自动等待机制
- 时间旅行调试
基础测试场景示例:
// login.spec.js
describe('登录流程', () => {
beforeEach(() => {
cy.visit('/login');
});
it('应该阻止无效凭证登录', () => {
cy.get('#username').type('test@demo.com');
cy.get('#password').type('wrong123');
cy.get('form').submit();
cy.contains('.error', '凭证无效').should('be.visible');
});
it('成功登录后应跳转仪表盘', () => {
cy.intercept('POST', '/api/login', { fixture: 'login.success.json' });
cy.get('#username').type('admin@company.com');
cy.get('#password').type('correct-password');
cy.get('form').submit();
cy.url().should('include', '/dashboard');
});
});
高级功能实践:
// network.spec.js
it('应该显示加载状态', () => {
cy.intercept('GET', '/api/products', {
delay: 2000,
fixture: 'products.json'
}).as('getProducts');
cy.visit('/products');
cy.get('.loading-indicator').should('exist');
cy.wait('@getProducts');
cy.get('.product-list').should('have.length', 5);
});
组合策略:分层测试体系
-
金字塔模型:
- 底层:70% Jest单元测试
- 中层:20% 集成测试
- 顶层:10% Cypress E2E测试
-
CI/CD集成:
# .github/workflows/test.yml
jobs:
test:
steps:
- run: npm test # Jest单元测试
- run: npm run cy:run -- --headless # Cypress无头模式
- 共享逻辑复用:
// 在Jest和Cypress间共享的测试数据
// test-utils/constants.js
export const TEST_USER = {
email: 'test@cypress.com',
password: 'Test1234!'
};
// 在Jest测试中
import { TEST_USER } from '../test-utils/constants';
// 在Cypress测试中
import { TEST_USER } from '../../test-utils/constants';
调试技巧与性能优化
Jest调试方案:
// 在VSCode中配置launch.json
{
"type": "node",
"request": "launch",
"name": "Debug Jest Tests",
"program": "${workspaceFolder}/node_modules/jest/bin/jest",
"args": ["--runInBand", "${fileBasename}"]
}
Cypress性能优化:
// cypress.config.js
module.exports = {
e2e: {
experimentalMemoryManagement: true,
numTestsKeptInMemory: 5 // 减少内存占用
}
}
并行测试配置示例:
# 分割测试任务
npm run jest -- --shard=1/3
npm run jest -- --shard=2/3
npm run jest -- --shard=3/3
常见问题解决方案
Jest与CSS模块冲突:
// jest.config.js
module.exports = {
moduleNameMapper: {
'\\.(css|less)$': 'identity-obj-proxy'
}
}
Cypress元素定位策略:
// 避免脆弱的定位方式
cy.get('[data-testid="submit-button"]').click(); // 优于 cy.get('.btn-primary').click();
// 自定义命令
Cypress.Commands.add('login', (email, password) => {
cy.get('[data-testid=email]').type(email);
cy.get('[data-testid=password]').type(password);
cy.get('[data-testid=submit]').click();
});
测试数据管理:
// fixtures/products.json
{
"list": [
{
"id": 1,
"name": "测试商品",
"price": 99.9
}
]
}
// 测试中使用
cy.intercept('GET', '/api/products', { fixture: 'products.json' });
测试覆盖率与报告
Jest覆盖率配置:
// package.json
{
"scripts": {
"test:coverage": "jest --coverage"
},
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx}",
"!**/node_modules/**",
"!**/vendor/**"
]
}
}
Cypress覆盖率集成:
// cypress.config.js
module.exports = {
env: {
codeCoverage: {
url: '/__coverage__'
}
}
}
// 插件配置
module.exports = (on, config) => {
require('@cypress/code-coverage/task')(on, config);
return config;
};
合并报告示例:
# 合并Jest和Cypress的覆盖率数据
nyc merge .nyc_output && nyc report --reporter=lcov
现代前端框架的测试适配
React组件测试方案:
// Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
test('按钮点击触发回调', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>提交</Button>);
fireEvent.click(screen.getByText('提交'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
Vue组合式API测试:
// useCounter.test.js
import { ref } from 'vue';
import { useCounter } from './useCounter';
test('计数器应该递增', () => {
const count = ref(0);
const { increment } = useCounter(count);
increment();
expect(count.value).toBe(1);
});
Next.js页面测试:
// home.cy.js
describe('首页测试', () => {
it('应该预取数据', () => {
cy.intercept('GET', '/api/posts', { fixture: 'posts.json' });
cy.visit('/');
cy.get('[data-testid=post-list]').should('have.length', 5);
});
});
本站部分内容来自互联网,一切版权均归源网站或源作者所有。
如果侵犯了你的权益请来信告知我们删除。邮箱:cc@cccx.cn