feat: 提升测试覆盖率并优化测试用例
新增测试: - use-page-views.test.ts: 测试页面浏览跟踪功能 - api-response.test.ts: 测试API响应辅助函数 - analytics.test.ts: 优化分析函数测试 覆盖率提升: - branches: 40% -> 41.62% - functions: 45% -> 47.3% - lines: 50% -> 52.82% - statements: 50% -> 51.82% 更新覆盖率阈值到当前水平
This commit is contained in:
@@ -11,10 +11,10 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
coverageThreshold: {
|
coverageThreshold: {
|
||||||
global: {
|
global: {
|
||||||
branches: 40,
|
branches: 41,
|
||||||
functions: 45,
|
functions: 47,
|
||||||
lines: 50,
|
lines: 52,
|
||||||
statements: 50,
|
statements: 51,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
coverageReporters: ['text', 'lcov', 'html', 'json'],
|
coverageReporters: ['text', 'lcov', 'html', 'json'],
|
||||||
|
|||||||
@@ -0,0 +1,904 @@
|
|||||||
|
# 测试框架与CI/CD持续优化实施计划
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** 在1个月内完成CI/CD流程并行化、测试覆盖率提升和测试数据管理优化,实现CI执行时间减少60%、测试覆盖率达到60%、测试数据管理标准化。
|
||||||
|
|
||||||
|
**Architecture:** 采用渐进式优化策略,优先实施高收益低风险的改进。并行化CI步骤通过Woodpecker CI的depends_on机制实现;测试覆盖率提升通过补充关键模块测试实现;测试数据管理通过创建统一的测试数据工厂实现。
|
||||||
|
|
||||||
|
**Tech Stack:** Woodpecker CI, Jest, Playwright, TypeScript, Node.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段1: CI/CD流程并行化(预计3天)
|
||||||
|
|
||||||
|
### Task 1.1: 分析当前CI步骤依赖关系
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Analyze: `.woodpecker.yml`
|
||||||
|
|
||||||
|
**Step 1: 绘制当前CI流程图**
|
||||||
|
|
||||||
|
分析当前CI配置,识别哪些步骤可以并行执行:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 当前流程(串行)
|
||||||
|
Clone -> Lint -> Type Check -> Security Scan -> Unit Tests -> E2E Tests -> Build -> Deploy
|
||||||
|
|
||||||
|
# 优化后流程(并行)
|
||||||
|
Clone -> [Lint || Type Check || Security Scan] -> Unit Tests -> E2E Tests -> Build -> Deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 识别可并行的步骤**
|
||||||
|
|
||||||
|
可并行的步骤:
|
||||||
|
- Lint(代码检查)
|
||||||
|
- Type Check(类型检查)
|
||||||
|
- Security Scan(安全扫描)
|
||||||
|
|
||||||
|
不可并行的步骤:
|
||||||
|
- Unit Tests(依赖前面的代码质量检查)
|
||||||
|
- E2E Tests(依赖Unit Tests)
|
||||||
|
- Build(依赖所有测试通过)
|
||||||
|
- Deploy(依赖Build成功)
|
||||||
|
|
||||||
|
**Step 3: 记录优化预期**
|
||||||
|
|
||||||
|
预期效果:
|
||||||
|
- 并行化前:Lint(30s) + TypeCheck(40s) + Security(20s) = 90s
|
||||||
|
- 并行化后:max(30s, 40s, 20s) = 40s
|
||||||
|
- 节省时间:50s
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1.2: 修改CI配置实现并行化
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.woodpecker.yml:60-120`
|
||||||
|
|
||||||
|
**Step 1: 添加并行化配置**
|
||||||
|
|
||||||
|
修改`.woodpecker.yml`,在lint、type-check、security-scan步骤前添加:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ============================================
|
||||||
|
# 阶段1: 并行代码质量检查
|
||||||
|
# ============================================
|
||||||
|
steps:
|
||||||
|
lint:
|
||||||
|
image: *node_image
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
commands:
|
||||||
|
- npm ci --cache /tmp/npm-cache
|
||||||
|
- npm run lint
|
||||||
|
volumes:
|
||||||
|
- /tmp/npm-cache:/root/.npm
|
||||||
|
- /tmp/node-modules-cache:/woodpecker/src/node_modules
|
||||||
|
when:
|
||||||
|
event: [push, pull_request]
|
||||||
|
branch: [feature/**, dev, release, release/**]
|
||||||
|
|
||||||
|
type-check:
|
||||||
|
image: *node_image
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
commands:
|
||||||
|
- npm ci --cache /tmp/npm-cache
|
||||||
|
- npm run type-check
|
||||||
|
volumes:
|
||||||
|
- /tmp/npm-cache:/root/.npm
|
||||||
|
- /tmp/node-modules-cache:/woodpecker/src/node_modules
|
||||||
|
when:
|
||||||
|
event: [push, pull_request]
|
||||||
|
branch: [feature/**, dev, release, release/**]
|
||||||
|
|
||||||
|
security-scan:
|
||||||
|
image: *node_image
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
HUSKY: 0
|
||||||
|
commands:
|
||||||
|
- npm ci --omit=dev --ignore-scripts --cache /tmp/npm-cache
|
||||||
|
- npm audit --audit-level=high --omit=dev
|
||||||
|
volumes:
|
||||||
|
- /tmp/npm-cache:/root/.npm
|
||||||
|
when:
|
||||||
|
event: [push, pull_request]
|
||||||
|
branch: [feature/**, dev, release, release/**]
|
||||||
|
failure: ignore
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 添加单元测试依赖配置**
|
||||||
|
|
||||||
|
修改unit-tests步骤,添加depends_on:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
unit-tests:
|
||||||
|
image: *node_image
|
||||||
|
environment:
|
||||||
|
NODE_ENV: test
|
||||||
|
CI: true
|
||||||
|
depends_on: [lint, type-check, security-scan]
|
||||||
|
commands:
|
||||||
|
- npm install --cache /tmp/npm-cache
|
||||||
|
- npm run test:coverage:check
|
||||||
|
volumes:
|
||||||
|
- /tmp/npm-cache:/root/.npm
|
||||||
|
- /tmp/node-modules-cache:/woodpecker/src/node_modules
|
||||||
|
when:
|
||||||
|
event: [push, pull_request]
|
||||||
|
branch: [dev, release, release/**]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 验证配置语法**
|
||||||
|
|
||||||
|
运行配置验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 验证YAML语法
|
||||||
|
python -c "import yaml; yaml.safe_load(open('.woodpecker.yml'))"
|
||||||
|
|
||||||
|
# 或使用在线YAML验证器
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: 提交更改**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .woodpecker.yml
|
||||||
|
git commit -m "feat: 并行化CI代码质量检查步骤
|
||||||
|
|
||||||
|
- Lint、Type Check、Security Scan并行执行
|
||||||
|
- Unit Tests依赖所有检查步骤完成
|
||||||
|
- 预计减少CI时间50秒"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1.3: 验证并行化效果
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Monitor: https://ci.f.novalon.cn/repos/1/pipeline/
|
||||||
|
|
||||||
|
**Step 1: 推送更改触发CI**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin release/v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 监控CI执行**
|
||||||
|
|
||||||
|
访问Pipeline页面,观察:
|
||||||
|
- Lint、Type Check、Security Scan是否同时开始执行
|
||||||
|
- 记录实际执行时间
|
||||||
|
- 对比优化前后的时间差异
|
||||||
|
|
||||||
|
**Step 3: 记录优化结果**
|
||||||
|
|
||||||
|
创建监控记录文件:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# CI并行化优化记录
|
||||||
|
|
||||||
|
## 优化前
|
||||||
|
- Lint: 30s
|
||||||
|
- Type Check: 40s
|
||||||
|
- Security Scan: 20s
|
||||||
|
- 总计: 90s(串行)
|
||||||
|
|
||||||
|
## 优化后
|
||||||
|
- 并行执行时间: 40s
|
||||||
|
- 节省时间: 50s
|
||||||
|
- 改善比例: 55.6%
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段2: 测试覆盖率提升(预计7天)
|
||||||
|
|
||||||
|
### Task 2.1: 分析当前测试覆盖率
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Analyze: `coverage/lcov-report/index.html`
|
||||||
|
- Modify: `jest.config.js`
|
||||||
|
|
||||||
|
**Step 1: 运行覆盖率测试**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 分析覆盖率报告**
|
||||||
|
|
||||||
|
打开覆盖率报告:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open coverage/lcov-report/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
识别覆盖率较低的模块:
|
||||||
|
- 工具函数(utils)
|
||||||
|
- Hooks
|
||||||
|
- API路由
|
||||||
|
|
||||||
|
**Step 3: 记录当前覆盖率**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 当前测试覆盖率
|
||||||
|
|
||||||
|
| 类型 | 当前覆盖率 | 目标覆盖率 | 差距 |
|
||||||
|
|------|-----------|-----------|------|
|
||||||
|
| Branches | 40% | 60% | +20% |
|
||||||
|
| Functions | 45% | 60% | +15% |
|
||||||
|
| Lines | 50% | 60% | +10% |
|
||||||
|
| Statements | 50% | 60% | +10% |
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.2: 补充工具函数测试
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/utils.test.ts`
|
||||||
|
- Modify: `src/lib/utils.ts`(如需)
|
||||||
|
|
||||||
|
**Step 1: 识别未测试的工具函数**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查找所有工具函数
|
||||||
|
find src/lib -name "*.ts" ! -name "*.test.ts" -type f
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 编写工具函数测试**
|
||||||
|
|
||||||
|
创建`src/lib/utils.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from '@jest/globals';
|
||||||
|
import { cn, formatDate, validateEmail } from './utils';
|
||||||
|
|
||||||
|
describe('工具函数测试', () => {
|
||||||
|
describe('cn (className合并)', () => {
|
||||||
|
it('应该正确合并多个className', () => {
|
||||||
|
expect(cn('foo', 'bar')).toBe('foo bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理条件className', () => {
|
||||||
|
expect(cn('foo', false && 'bar', 'baz')).toBe('foo baz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理undefined和null', () => {
|
||||||
|
expect(cn('foo', undefined, null, 'bar')).toBe('foo bar');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('应该正确格式化日期', () => {
|
||||||
|
const date = new Date('2024-01-01');
|
||||||
|
expect(formatDate(date)).toBe('2024-01-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理无效日期', () => {
|
||||||
|
expect(formatDate(null)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateEmail', () => {
|
||||||
|
it('应该验证有效的邮箱地址', () => {
|
||||||
|
expect(validateEmail('test@example.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝无效的邮箱地址', () => {
|
||||||
|
expect(validateEmail('invalid-email')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 运行测试验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:unit -- src/lib/utils.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: 提交更改**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/utils.test.ts
|
||||||
|
git commit -m "test: 添加工具函数测试用例
|
||||||
|
|
||||||
|
- 测试className合并功能
|
||||||
|
- 测试日期格式化功能
|
||||||
|
- 测试邮箱验证功能
|
||||||
|
- 提升覆盖率约5%"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.3: 补充Hooks测试
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/hooks/use-debounce.test.ts`
|
||||||
|
- Create: `src/hooks/use-local-storage.test.ts`
|
||||||
|
|
||||||
|
**Step 1: 识别未测试的Hooks**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find src/hooks -name "*.ts" ! -name "*.test.ts" -type f
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 编写use-debounce Hook测试**
|
||||||
|
|
||||||
|
创建`src/hooks/use-debounce.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useDebounce } from './use-debounce';
|
||||||
|
|
||||||
|
describe('useDebounce Hook', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该延迟更新值', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ value, delay }) => useDebounce(value, delay),
|
||||||
|
{ initialProps: { value: 'initial', delay: 500 } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current).toBe('initial');
|
||||||
|
|
||||||
|
rerender({ value: 'updated', delay: 500 });
|
||||||
|
expect(result.current).toBe('initial');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe('updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该取消之前的定时器', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ value, delay }) => useDebounce(value, delay),
|
||||||
|
{ initialProps: { value: 'initial', delay: 500 } }
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender({ value: 'updated1', delay: 500 });
|
||||||
|
rerender({ value: 'updated2', delay: 500 });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe('updated2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 编写use-local-storage Hook测试**
|
||||||
|
|
||||||
|
创建`src/hooks/use-local-storage.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useLocalStorage } from './use-local-storage';
|
||||||
|
|
||||||
|
describe('useLocalStorage Hook', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该从localStorage读取初始值', () => {
|
||||||
|
localStorage.setItem('test-key', JSON.stringify('stored-value'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useLocalStorage('test-key', 'default-value')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('stored-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用默认值当localStorage为空', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useLocalStorage('test-key', 'default-value')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('default-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该更新localStorage值', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useLocalStorage('test-key', 'initial')
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current[1]('updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe('updated');
|
||||||
|
expect(localStorage.getItem('test-key')).toBe(JSON.stringify('updated'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: 运行测试验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:unit -- src/hooks/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: 提交更改**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/hooks/*.test.ts
|
||||||
|
git commit -m "test: 添加Hooks测试用例
|
||||||
|
|
||||||
|
- 测试useDebounce延迟更新功能
|
||||||
|
- 测试useLocalStorage持久化功能
|
||||||
|
- 提升覆盖率约5%"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.4: 更新覆盖率阈值
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `jest.config.js:18-24`
|
||||||
|
|
||||||
|
**Step 1: 更新覆盖率阈值配置**
|
||||||
|
|
||||||
|
修改`jest.config.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
// 阶段1(当前):50%
|
||||||
|
// 阶段2(现在):60%
|
||||||
|
branches: 60,
|
||||||
|
functions: 60,
|
||||||
|
lines: 60,
|
||||||
|
statements: 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 运行测试验证新阈值**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:coverage:check
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 提交更改**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add jest.config.js
|
||||||
|
git commit -m "chore: 提升测试覆盖率阈值到60%
|
||||||
|
|
||||||
|
- branches: 40% -> 60%
|
||||||
|
- functions: 45% -> 60%
|
||||||
|
- lines: 50% -> 60%
|
||||||
|
- statements: 50% -> 60%"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段3: 测试数据管理优化(预计5天)
|
||||||
|
|
||||||
|
### Task 3.1: 创建测试数据工厂
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/test-utils/test-data-factory.ts`
|
||||||
|
- Create: `src/test-utils/test-data-factory.test.ts`
|
||||||
|
|
||||||
|
**Step 1: 设计测试数据工厂接口**
|
||||||
|
|
||||||
|
创建`src/test-utils/test-data-factory.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: 'admin' | 'user';
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface News {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
author: string;
|
||||||
|
publishedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestDataFactory {
|
||||||
|
static createUser(overrides?: Partial<User>): User {
|
||||||
|
return {
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
name: faker.person.fullName(),
|
||||||
|
email: faker.internet.email(),
|
||||||
|
role: faker.helpers.arrayElement(['admin', 'user']),
|
||||||
|
createdAt: faker.date.past(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static createProduct(overrides?: Partial<Product>): Product {
|
||||||
|
return {
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
name: faker.commerce.productName(),
|
||||||
|
description: faker.commerce.productDescription(),
|
||||||
|
price: parseFloat(faker.commerce.price()),
|
||||||
|
category: faker.commerce.department(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static createNews(overrides?: Partial<News>): News {
|
||||||
|
return {
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
title: faker.lorem.sentence(),
|
||||||
|
content: faker.lorem.paragraphs(3),
|
||||||
|
author: faker.person.fullName(),
|
||||||
|
publishedAt: faker.date.recent(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static createMany<T>(
|
||||||
|
factory: () => T,
|
||||||
|
count: number = 3
|
||||||
|
): T[] {
|
||||||
|
return Array.from({ length: count }, factory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 安装faker依赖**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --save-dev @faker-js/faker
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 编写测试数据工厂测试**
|
||||||
|
|
||||||
|
创建`src/test-utils/test-data-factory.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from '@jest/globals';
|
||||||
|
import { TestDataFactory } from './test-data-factory';
|
||||||
|
|
||||||
|
describe('TestDataFactory', () => {
|
||||||
|
describe('createUser', () => {
|
||||||
|
it('应该创建用户对象', () => {
|
||||||
|
const user = TestDataFactory.createUser();
|
||||||
|
|
||||||
|
expect(user).toHaveProperty('id');
|
||||||
|
expect(user).toHaveProperty('name');
|
||||||
|
expect(user).toHaveProperty('email');
|
||||||
|
expect(user).toHaveProperty('role');
|
||||||
|
expect(user).toHaveProperty('createdAt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该支持覆盖属性', () => {
|
||||||
|
const user = TestDataFactory.createUser({
|
||||||
|
name: '测试用户',
|
||||||
|
role: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(user.name).toBe('测试用户');
|
||||||
|
expect(user.role).toBe('admin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createProduct', () => {
|
||||||
|
it('应该创建产品对象', () => {
|
||||||
|
const product = TestDataFactory.createProduct();
|
||||||
|
|
||||||
|
expect(product).toHaveProperty('id');
|
||||||
|
expect(product).toHaveProperty('name');
|
||||||
|
expect(product).toHaveProperty('price');
|
||||||
|
expect(typeof product.price).toBe('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createMany', () => {
|
||||||
|
it('应该创建多个对象', () => {
|
||||||
|
const users = TestDataFactory.createMany(
|
||||||
|
TestDataFactory.createUser,
|
||||||
|
5
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(users).toHaveLength(5);
|
||||||
|
expect(users[0].id).not.toBe(users[1].id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: 运行测试验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:unit -- src/test-utils/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: 提交更改**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/test-utils/
|
||||||
|
git commit -m "feat: 创建测试数据工厂
|
||||||
|
|
||||||
|
- 支持创建用户、产品、新闻等测试数据
|
||||||
|
- 支持覆盖默认属性
|
||||||
|
- 支持批量创建测试数据
|
||||||
|
- 使用faker生成随机数据"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3.2: 重构现有测试使用数据工厂
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/api/contact/route.test.ts`
|
||||||
|
- Modify: `src/components/sections/contact-section.test.tsx`
|
||||||
|
|
||||||
|
**Step 1: 识别使用硬编码数据的测试**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 搜索测试中的硬编码数据
|
||||||
|
grep -r "test@example.com" src/**/*.test.*
|
||||||
|
grep -r "测试用户" src/**/*.test.*
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 重构contact路由测试**
|
||||||
|
|
||||||
|
修改`src/app/api/contact/route.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from '@jest/globals';
|
||||||
|
import { TestDataFactory } from '@/test-utils/test-data-factory';
|
||||||
|
|
||||||
|
describe('Contact API Route', () => {
|
||||||
|
it('应该处理联系表单提交', async () => {
|
||||||
|
const contactData = {
|
||||||
|
name: TestDataFactory.createUser().name,
|
||||||
|
email: TestDataFactory.createUser().email,
|
||||||
|
message: '测试消息',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(contactData),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 重构contact-section组件测试**
|
||||||
|
|
||||||
|
修改`src/components/sections/contact-section.test.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TestDataFactory } from '@/test-utils/test-data-factory';
|
||||||
|
|
||||||
|
describe('ContactSection', () => {
|
||||||
|
it('应该显示联系表单', () => {
|
||||||
|
const testUser = TestDataFactory.createUser();
|
||||||
|
|
||||||
|
render(<ContactSection />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/姓名/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/邮箱/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: 运行测试验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:unit
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: 提交更改**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app/api/contact/route.test.ts src/components/sections/contact-section.test.tsx
|
||||||
|
git commit -m "refactor: 使用测试数据工厂重构测试
|
||||||
|
|
||||||
|
- 移除硬编码测试数据
|
||||||
|
- 使用TestDataFactory生成随机数据
|
||||||
|
- 提高测试可维护性"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3.3: 创建测试数据清理工具
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/test-utils/test-data-cleaner.ts`
|
||||||
|
- Create: `src/test-utils/test-data-cleaner.test.ts`
|
||||||
|
|
||||||
|
**Step 1: 创建测试数据清理工具**
|
||||||
|
|
||||||
|
创建`src/test-utils/test-data-cleaner.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
|
||||||
|
export class TestDataCleaner {
|
||||||
|
private static mocks: jest.Mock[] = [];
|
||||||
|
|
||||||
|
static registerMock(mock: jest.Mock): void {
|
||||||
|
this.mocks.push(mock);
|
||||||
|
}
|
||||||
|
|
||||||
|
static clearAllMocks(): void {
|
||||||
|
this.mocks.forEach(mock => mock.mockClear());
|
||||||
|
this.mocks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static resetAllMocks(): void {
|
||||||
|
this.mocks.forEach(mock => mock.mockReset());
|
||||||
|
this.mocks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static cleanup(): void {
|
||||||
|
this.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoCleanup() {
|
||||||
|
afterEach(() => {
|
||||||
|
TestDataCleaner.cleanup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 编写清理工具测试**
|
||||||
|
|
||||||
|
创建`src/test-utils/test-data-cleaner.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||||
|
import { TestDataCleaner, autoCleanup } from './test-data-cleaner';
|
||||||
|
|
||||||
|
describe('TestDataCleaner', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestDataCleaner.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该注册和清理mock', () => {
|
||||||
|
const mock = jest.fn();
|
||||||
|
TestDataCleaner.registerMock(mock);
|
||||||
|
|
||||||
|
mock();
|
||||||
|
expect(mock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
TestDataCleaner.clearAllMocks();
|
||||||
|
expect(mock).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该清理localStorage', () => {
|
||||||
|
localStorage.setItem('test', 'value');
|
||||||
|
TestDataCleaner.cleanup();
|
||||||
|
expect(localStorage.getItem('test')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 运行测试验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:unit -- src/test-utils/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: 提交更改**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/test-utils/
|
||||||
|
git commit -m "feat: 创建测试数据清理工具
|
||||||
|
|
||||||
|
- 自动清理mock函数
|
||||||
|
- 清理localStorage和sessionStorage
|
||||||
|
- 提供autoCleanup装饰器"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证与总结
|
||||||
|
|
||||||
|
### Task 4.1: 验证优化效果
|
||||||
|
|
||||||
|
**Step 1: 运行完整测试套件**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:coverage:check
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 检查覆盖率报告**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open coverage/lcov-report/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
验证覆盖率是否达到60%目标。
|
||||||
|
|
||||||
|
**Step 3: 监控CI执行时间**
|
||||||
|
|
||||||
|
访问 https://ci.f.novalon.cn/repos/1/pipeline/
|
||||||
|
|
||||||
|
记录最新的CI执行时间,对比优化前后的改善。
|
||||||
|
|
||||||
|
**Step 4: 创建优化总结报告**
|
||||||
|
|
||||||
|
创建`docs/testing/optimization-report-2026-03.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 测试框架优化总结报告
|
||||||
|
|
||||||
|
## 优化成果
|
||||||
|
|
||||||
|
### CI/CD执行时间
|
||||||
|
- 优化前: ~1180s
|
||||||
|
- 优化后: ~XXXs
|
||||||
|
- 改善: XX%
|
||||||
|
|
||||||
|
### 测试覆盖率
|
||||||
|
- 优化前: 50%
|
||||||
|
- 优化后: 60%
|
||||||
|
- 改善: +10%
|
||||||
|
|
||||||
|
### 测试数据管理
|
||||||
|
- 创建统一的测试数据工厂
|
||||||
|
- 实现自动数据清理
|
||||||
|
- 提高测试可维护性
|
||||||
|
|
||||||
|
## 后续计划
|
||||||
|
|
||||||
|
### 长期优化(3个月内)
|
||||||
|
1. 引入视觉回归测试
|
||||||
|
2. 集成持续性能监控
|
||||||
|
3. 完善测试文档
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: 提交总结报告**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/testing/optimization-report-2026-03.md
|
||||||
|
git commit -m "docs: 添加测试框架优化总结报告"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行选项
|
||||||
|
|
||||||
|
**Plan complete and saved to `docs/plans/2026-03-29-testing-cicd-optimization.md`.**
|
||||||
|
|
||||||
|
**Two execution options:**
|
||||||
|
|
||||||
|
**1. Subagent-Driven (this session)** - 我将在当前会话中逐任务执行,每个任务完成后进行代码审查,快速迭代。
|
||||||
|
|
||||||
|
**2. Parallel Session (separate)** - 在新的会话中使用executing-plans skill批量执行,设置检查点。
|
||||||
|
|
||||||
|
**Which approach?**
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { usePageViews, PageViewsTracker } from './use-page-views';
|
||||||
|
import * as analytics from '@/lib/analytics';
|
||||||
|
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
usePathname: jest.fn(),
|
||||||
|
useSearchParams: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/lib/analytics', () => ({
|
||||||
|
pageview: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('usePageViews', () => {
|
||||||
|
const mockPageview = analytics.pageview as jest.Mock;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const mockUsePathname = require('next/navigation').usePathname as jest.Mock;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const mockUseSearchParams = require('next/navigation').useSearchParams as jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockUsePathname.mockReturnValue('/test-path');
|
||||||
|
mockUseSearchParams.mockReturnValue({
|
||||||
|
toString: () => 'param=value',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在pathname变化时调用pageview', () => {
|
||||||
|
renderHook(() => usePageViews());
|
||||||
|
|
||||||
|
expect(mockPageview).toHaveBeenCalledWith('/test-path?param=value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在没有searchParams时只使用pathname', () => {
|
||||||
|
mockUseSearchParams.mockReturnValue({
|
||||||
|
toString: () => '',
|
||||||
|
});
|
||||||
|
|
||||||
|
renderHook(() => usePageViews());
|
||||||
|
|
||||||
|
expect(mockPageview).toHaveBeenCalledWith('/test-path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在pathname为空时不调用pageview', () => {
|
||||||
|
mockUsePathname.mockReturnValue(null);
|
||||||
|
|
||||||
|
renderHook(() => usePageViews());
|
||||||
|
|
||||||
|
expect(mockPageview).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PageViewsTracker应该渲染null', () => {
|
||||||
|
const { result } = renderHook(() => PageViewsTracker());
|
||||||
|
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
-57
@@ -1,95 +1,71 @@
|
|||||||
jest.mock('./analytics', () => {
|
|
||||||
const actual = jest.requireActual('./analytics');
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
pageview: jest.fn(),
|
|
||||||
event: jest.fn(),
|
|
||||||
trackContactForm: jest.fn(),
|
|
||||||
trackButtonClick: jest.fn(),
|
|
||||||
trackPageView: jest.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
pageview,
|
pageview,
|
||||||
event,
|
event,
|
||||||
trackContactForm,
|
trackContactForm,
|
||||||
trackButtonClick,
|
trackButtonClick,
|
||||||
trackPageView,
|
trackPageView,
|
||||||
|
GA_MEASUREMENT_ID,
|
||||||
} from './analytics';
|
} from './analytics';
|
||||||
|
|
||||||
describe('analytics', () => {
|
describe('analytics', () => {
|
||||||
beforeEach(() => {
|
describe('exports', () => {
|
||||||
jest.clearAllMocks();
|
it('应该导出所有必要的函数', () => {
|
||||||
|
expect(pageview).toBeDefined();
|
||||||
|
expect(event).toBeDefined();
|
||||||
|
expect(trackContactForm).toBeDefined();
|
||||||
|
expect(trackButtonClick).toBeDefined();
|
||||||
|
expect(trackPageView).toBeDefined();
|
||||||
|
expect(GA_MEASUREMENT_ID).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('所有函数应该是可调用的', () => {
|
||||||
|
expect(() => pageview('/test')).not.toThrow();
|
||||||
|
expect(() => event('click', 'button')).not.toThrow();
|
||||||
|
expect(() => trackContactForm({ name: 'test' })).not.toThrow();
|
||||||
|
expect(() => trackButtonClick('submit', 'header')).not.toThrow();
|
||||||
|
expect(() => trackPageView('Home', '/home')).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pageview', () => {
|
describe('pageview', () => {
|
||||||
it('should be defined', () => {
|
it('应该接受URL参数', () => {
|
||||||
expect(pageview).toBeDefined();
|
expect(() => pageview('/test-page')).not.toThrow();
|
||||||
expect(typeof pageview).toBe('function');
|
expect(() => pageview('/another-page?param=value')).not.toThrow();
|
||||||
});
|
|
||||||
|
|
||||||
it('should be callable', () => {
|
|
||||||
pageview('/test-page');
|
|
||||||
expect(pageview).toHaveBeenCalledWith('/test-page');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('event', () => {
|
describe('event', () => {
|
||||||
it('should be defined', () => {
|
it('应该接受必需参数', () => {
|
||||||
expect(event).toBeDefined();
|
expect(() => event('click', 'button')).not.toThrow();
|
||||||
expect(typeof event).toBe('function');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be callable with all parameters', () => {
|
it('应该接受可选参数', () => {
|
||||||
event('click', 'button', 'submit', 1);
|
expect(() => event('click', 'button', 'label', 1)).not.toThrow();
|
||||||
expect(event).toHaveBeenCalledWith('click', 'button', 'submit', 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be callable with minimal parameters', () => {
|
|
||||||
event('click', 'button');
|
|
||||||
expect(event).toHaveBeenCalledWith('click', 'button');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('trackContactForm', () => {
|
describe('trackContactForm', () => {
|
||||||
it('should be defined', () => {
|
it('应该接受表单数据', () => {
|
||||||
expect(trackContactForm).toBeDefined();
|
|
||||||
expect(typeof trackContactForm).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be callable', () => {
|
|
||||||
const formData = {
|
const formData = {
|
||||||
name: 'John Doe',
|
name: 'John Doe',
|
||||||
email: 'john@example.com',
|
email: 'john@example.com',
|
||||||
message: 'Test message',
|
message: 'Test message',
|
||||||
};
|
};
|
||||||
trackContactForm(formData);
|
expect(() => trackContactForm(formData)).not.toThrow();
|
||||||
expect(trackContactForm).toHaveBeenCalledWith(formData);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('trackButtonClick', () => {
|
describe('trackButtonClick', () => {
|
||||||
it('should be defined', () => {
|
it('应该接受按钮名称和位置', () => {
|
||||||
expect(trackButtonClick).toBeDefined();
|
expect(() => trackButtonClick('submit', 'header')).not.toThrow();
|
||||||
expect(typeof trackButtonClick).toBe('function');
|
expect(() => trackButtonClick('cancel', 'footer')).not.toThrow();
|
||||||
});
|
|
||||||
|
|
||||||
it('should be callable', () => {
|
|
||||||
trackButtonClick('submit', 'header');
|
|
||||||
expect(trackButtonClick).toHaveBeenCalledWith('submit', 'header');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('trackPageView', () => {
|
describe('trackPageView', () => {
|
||||||
it('should be defined', () => {
|
it('应该接受页面标题和路径', () => {
|
||||||
expect(trackPageView).toBeDefined();
|
expect(() => trackPageView('Home Page', '/home')).not.toThrow();
|
||||||
expect(typeof trackPageView).toBe('function');
|
expect(() => trackPageView('About Page', '/about')).not.toThrow();
|
||||||
});
|
|
||||||
|
|
||||||
it('should be callable', () => {
|
|
||||||
trackPageView('Home Page', '/home');
|
|
||||||
expect(trackPageView).toHaveBeenCalledWith('Home Page', '/home');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user