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: {
|
||||
global: {
|
||||
branches: 40,
|
||||
functions: 45,
|
||||
lines: 50,
|
||||
statements: 50,
|
||||
branches: 41,
|
||||
functions: 47,
|
||||
lines: 52,
|
||||
statements: 51,
|
||||
},
|
||||
},
|
||||
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 {
|
||||
pageview,
|
||||
event,
|
||||
trackContactForm,
|
||||
trackButtonClick,
|
||||
trackPageView,
|
||||
GA_MEASUREMENT_ID,
|
||||
} from './analytics';
|
||||
|
||||
describe('analytics', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
describe('exports', () => {
|
||||
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', () => {
|
||||
it('should be defined', () => {
|
||||
expect(pageview).toBeDefined();
|
||||
expect(typeof pageview).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable', () => {
|
||||
pageview('/test-page');
|
||||
expect(pageview).toHaveBeenCalledWith('/test-page');
|
||||
it('应该接受URL参数', () => {
|
||||
expect(() => pageview('/test-page')).not.toThrow();
|
||||
expect(() => pageview('/another-page?param=value')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('event', () => {
|
||||
it('should be defined', () => {
|
||||
expect(event).toBeDefined();
|
||||
expect(typeof event).toBe('function');
|
||||
it('应该接受必需参数', () => {
|
||||
expect(() => event('click', 'button')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should be callable with all parameters', () => {
|
||||
event('click', 'button', 'submit', 1);
|
||||
expect(event).toHaveBeenCalledWith('click', 'button', 'submit', 1);
|
||||
});
|
||||
|
||||
it('should be callable with minimal parameters', () => {
|
||||
event('click', 'button');
|
||||
expect(event).toHaveBeenCalledWith('click', 'button');
|
||||
it('应该接受可选参数', () => {
|
||||
expect(() => event('click', 'button', 'label', 1)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackContactForm', () => {
|
||||
it('should be defined', () => {
|
||||
expect(trackContactForm).toBeDefined();
|
||||
expect(typeof trackContactForm).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable', () => {
|
||||
it('应该接受表单数据', () => {
|
||||
const formData = {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
message: 'Test message',
|
||||
};
|
||||
trackContactForm(formData);
|
||||
expect(trackContactForm).toHaveBeenCalledWith(formData);
|
||||
expect(() => trackContactForm(formData)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackButtonClick', () => {
|
||||
it('should be defined', () => {
|
||||
expect(trackButtonClick).toBeDefined();
|
||||
expect(typeof trackButtonClick).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable', () => {
|
||||
trackButtonClick('submit', 'header');
|
||||
expect(trackButtonClick).toHaveBeenCalledWith('submit', 'header');
|
||||
it('应该接受按钮名称和位置', () => {
|
||||
expect(() => trackButtonClick('submit', 'header')).not.toThrow();
|
||||
expect(() => trackButtonClick('cancel', 'footer')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackPageView', () => {
|
||||
it('should be defined', () => {
|
||||
expect(trackPageView).toBeDefined();
|
||||
expect(typeof trackPageView).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable', () => {
|
||||
trackPageView('Home Page', '/home');
|
||||
expect(trackPageView).toHaveBeenCalledWith('Home Page', '/home');
|
||||
it('应该接受页面标题和路径', () => {
|
||||
expect(() => trackPageView('Home Page', '/home')).not.toThrow();
|
||||
expect(() => trackPageView('About Page', '/about')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user