| 版本 | 日期 | 描述 | 作者 |
|---|---|---|---|
| 2.9 | 2025-12-15 | 添加API模拟规范和前端组件测试策略 | James |
| 2.8 | 2025-11-11 | 更新包测试结构,添加模块化包测试策略 | Winston |
| 2.7 | 2025-11-09 | 更新为monorepo测试架构,清理重复测试文件 | James |
| 2.6 | 2025-10-15 | 完成遗留测试文件迁移到统一的tests目录结构 | Winston |
| 2.5 | 2025-10-14 | 更新测试文件位置到统一的tests目录结构 | Claude |
| 2.4 | 2025-09-20 | 更新测试策略与主架构文档版本一致 | Winston |
本文档定义了D8D Starter项目的完整测试策略,基于monorepo架构和现有的测试基础设施。测试策略遵循测试金字塔模型,确保代码质量、功能稳定性和系统可靠性。
项目已重构为模块化包架构,测试架构相应调整为:
项目采用分层测试架构,每个包独立测试:
packages/shared-*/tests/unit/**/*.test.tspackages/*-module/tests/unit/**/*.test.tspackages/server/tests/unit/**/*.test.tsweb/tests/unit/**/*.test.{ts,tsx}packages/*-module/tests/integration/**/*.test.tspackages/server/tests/integration/**/*.test.ts (模块集成测试)web/tests/integration/**/*.test.{ts,tsx}web/tests/e2e/**/*.test.{ts,tsx}// vitest.config.ts - 开发环境配置
export default defineConfig({
test: {
projects: [
// Node.js 环境项目 - 后端测试
{
test: {
include: [
'tests/unit/server/**/*.test.{ts,js}',
'tests/integration/server/**/*.test.{ts,js}'
],
// ... 其他配置
}
},
// Happy DOM 环境项目 - 前端组件测试
{
test: {
include: [
'tests/unit/client/**/*.test.{ts,js,tsx,jsx}',
'tests/integration/client/**/*.test.{ts,js,tsx,jsx}'
],
// ... 其他配置
}
}
]
}
});
# GitHub Actions 测试配置 (模块化包架构)
name: Test Pipeline
jobs:
# 基础设施包测试
shared-packages-tests:
runs-on: ubuntu-latest
steps:
- run: cd packages/shared-types && pnpm test
- run: cd packages/shared-utils && pnpm test
- run: cd packages/shared-crud && pnpm test
- run: cd packages/shared-test-util && pnpm test
# 业务模块包测试
business-modules-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: test_password
POSTGRES_DB: test_d8dai
steps:
- run: cd packages/user-module && pnpm test
- run: cd packages/auth-module && pnpm test
- run: cd packages/file-module && pnpm test
- run: cd packages/geo-areas && pnpm test
# 服务器集成测试
server-integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: test_password
POSTGRES_DB: test_d8dai
steps:
- run: cd packages/server && pnpm test
# Web应用测试
web-integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: test_password
POSTGRES_DB: test_d8dai
steps:
- run: cd web && pnpm test:integration
web-component-tests:
runs-on: ubuntu-latest
steps:
- run: cd web && pnpm test:components
web-e2e-tests:
runs-on: ubuntu-latest
steps:
- run: cd web && pnpm test:e2e:chromium
| 测试类型 | 最低要求 | 目标要求 | 关键模块要求 |
|---|---|---|---|
| 单元测试 | 70% | 80% | 90% |
| 集成测试 | 50% | 60% | 70% |
| E2E测试 | 关键流程100% | 主要流程80% | - |
// 测试数据工厂模式
export function createTestUser(overrides = {}): User {
return {
id: 1,
username: 'testuser',
email: 'test@example.com',
createdAt: new Date(),
...overrides
};
}
// 使用示例
const adminUser = createTestUser({ role: 'admin' });
const inactiveUser = createTestUser({ active: false });
API模拟规范为管理后台UI包提供测试中的API模拟策略。虽然当前项目实践中每个UI包都有自己的客户端管理器,但为了简化测试复杂度、特别是跨UI包集成测试场景,规范要求统一模拟共享UI组件包中的rpcClient函数。
当前实现中,每个UI包测试文件模拟自己的客户端管理器(如AdvertisementClientManager、UserClientManager)。这种模式在单一UI包测试时可行,但在跨UI包集成测试时存在严重问题:
示例场景:收货地址UI包中使用区域管理UI包的区域选择组件,两个组件分别使用各自的客户端管理器。
DeliveryAddressClientManagerAreaClientManager统一模拟优势:通过模拟@d8d/shared-ui-components包中的rpcClient函数,可以:
项目中的管理后台UI包当前遵循以下架构模式:
AdvertisementClientManager、UserClientManager)@d8d/shared-ui-components包中的rpcClient函数创建Hono RPC客户端index.$get、index.$post、:id.$put等)注意:新的测试规范要求直接模拟rpcClient函数,而不是模拟各个客户端管理器。
rpcClient函数位于@d8d/shared-ui-components包的src/utils/hc.ts文件中,其核心功能是创建Hono RPC客户端:
// packages/shared-ui-components/src/utils/hc.ts
export const rpcClient = <T extends Hono<any, any, any>>(aptBaseUrl: string): ReturnType<typeof hc<T>> => {
return hc<T>(aptBaseUrl, {
fetch: axiosFetch
})
}
该函数接收API基础URL参数,返回一个配置了axios适配器的Hono客户端实例。
在测试中,使用Vitest的vi.mock直接模拟@d8d/shared-ui-components包中的rpcClient函数,统一拦截所有API调用:
// 测试文件顶部 - 统一模拟rpcClient函数
import { vi } from 'vitest'
import type { Hono } from 'hono'
// 创建模拟的rpcClient函数
const mockRpcClient = vi.fn((aptBaseUrl: string) => {
// 创建模拟的Hono客户端结构
const mockClient = {
// 支持动态路径访问
[Symbol.toPrimitive]: () => mockClient,
// 通用API端点模拟
index: {
$get: vi.fn(),
$post: vi.fn(),
$put: vi.fn(),
$delete: vi.fn(),
},
':id': {
$get: vi.fn(),
$put: vi.fn(),
$delete: vi.fn(),
},
// 支持嵌套路径访问
$path: (path: string) => {
// 根据路径返回对应的模拟端点
const pathSegments = path.split('/').filter(Boolean)
let current = mockClient
for (const segment of pathSegments) {
if (!current[segment]) {
current[segment] = {
$get: vi.fn(),
$post: vi.fn(),
$put: vi.fn(),
$delete: vi.fn(),
}
}
current = current[segment]
}
return current
}
}
return mockClient
})
// 模拟共享UI组件包中的rpcClient函数
vi.mock('@d8d/shared-ui-components/utils/hc', () => ({
rpcClient: mockRpcClient
}))
创建通用的模拟响应辅助函数,用于生成一致的API响应格式:
// 在测试文件中定义或从共享工具导入
const createMockResponse = (status: number, data?: any) => ({
status,
ok: status >= 200 && status < 300,
body: null,
bodyUsed: false,
statusText: status === 200 ? 'OK' : status === 201 ? 'Created' : status === 204 ? 'No Content' : 'Error',
headers: new Headers(),
url: '',
redirected: false,
type: 'basic' as ResponseType,
json: async () => data || {},
text: async () => '',
blob: async () => new Blob(),
arrayBuffer: async () => new ArrayBuffer(0),
formData: async () => new FormData(),
clone: function() { return this; }
});
// 创建简化版响应工厂(针对常见业务数据结构)
const createMockApiResponse = <T>(data: T, success = true) => ({
success,
data,
timestamp: new Date().toISOString()
})
const createMockErrorResponse = (message: string, code = 'ERROR') => ({
success: false,
error: { code, message },
timestamp: new Date().toISOString()
})
在测试用例的beforeEach或具体测试中配置模拟响应,支持跨UI包集成:
// 跨UI包集成测试示例:收货地址UI包(包含区域选择组件)
describe('收货地址管理(跨UI包集成)', () => {
let mockClient: any;
beforeEach(() => {
vi.clearAllMocks();
// 获取模拟的rpcClient实例
mockClient = mockRpcClient('/');
// 配置收货地址API响应(收货地址UI包)
mockClient.index.$get.mockResolvedValue(createMockResponse(200, {
data: [
{
id: 1,
name: '测试地址',
phone: '13800138000',
provinceId: 1,
cityId: 2,
districtId: 3,
detail: '测试街道'
}
],
pagination: { total: 1, page: 1, pageSize: 10 }
}));
mockClient.index.$post.mockResolvedValue(createMockResponse(201, {
id: 2,
name: '新地址'
}));
mockClient[':id']['$put'].mockResolvedValue(createMockResponse(200));
mockClient[':id']['$delete'].mockResolvedValue(createMockResponse(204));
// 配置区域API响应(区域管理UI包 - 跨包支持)
mockClient.$path('api/areas').$get.mockResolvedValue(createMockResponse(200, {
data: [
{ id: 1, name: '北京市', code: '110000', level: 1 },
{ id: 2, name: '朝阳区', code: '110105', level: 2, parentId: 1 },
{ id: 3, name: '海淀区', code: '110108', level: 2, parentId: 1 }
]
}));
mockClient.$path('api/areas/provinces').$get.mockResolvedValue(createMockResponse(200, {
data: [
{ id: 1, name: '北京市', code: '110000' },
{ id: 2, name: '上海市', code: '310000' }
]
}));
mockClient.$path('api/areas/:id/cities').$get.mockResolvedValue(createMockResponse(200, {
data: [
{ id: 2, name: '朝阳区', code: '110105', parentId: 1 },
{ id: 3, name: '海淀区', code: '110108', parentId: 1 }
]
}));
});
it('应该显示收货地址列表并支持区域选择', async () => {
// 测试代码:验证收货地址UI和区域选择组件都能正常工作
// 所有API调用都通过统一的mockRpcClient模拟
});
it('应该处理API错误场景', async () => {
// 模拟API错误
mockClient.index.$get.mockRejectedValue(new Error('网络错误'));
// 测试错误处理
});
});
@d8d/shared-ui-components/utils/hc中的rpcClient函数$get、$post、$put、$delete方法index)、参数化端点(:id)和嵌套路径($path())status、ok、json()等方法vi.mock统一模拟rpcClient函数beforeEach中重置rpcClient函数统一拦截// 验证API调用次数和参数 - 使用统一模拟的rpcClient
describe('API调用验证(统一模拟)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('应该验证API调用次数和参数', async () => {
// 获取模拟的客户端实例
const mockClient = mockRpcClient('/');
// 配置模拟响应
mockClient.index.$get.mockResolvedValue(createMockResponse(200, { data: [] }));
mockClient.index.$post.mockResolvedValue(createMockResponse(201, { id: 1 }));
mockClient[':id']['$put'].mockResolvedValue(createMockResponse(200));
mockClient.$path('api/areas').$get.mockResolvedValue(createMockResponse(200, { data: [] }));
// 执行测试代码(触发API调用)...
// 验证API调用次数
expect(mockClient.index.$get).toHaveBeenCalledTimes(1);
expect(mockClient.$path('api/areas').$get).toHaveBeenCalledTimes(1);
// 验证API调用参数
expect(mockClient.index.$post).toHaveBeenCalledWith({
json: {
title: '新广告',
code: 'new-ad',
typeId: 1
}
});
// 验证带参数的API调用
expect(mockClient[':id']['$put']).toHaveBeenCalledWith({
param: { id: 1 },
json: {
title: '更新后的广告'
}
});
// 验证嵌套路径API调用
expect(mockClient.$path('api/areas').$get).toHaveBeenCalledWith({
query: { level: 1 }
});
});
it('应该验证错误场景', async () => {
const mockClient = mockRpcClient('/');
// 配置错误响应
mockClient.index.$get.mockRejectedValue(new Error('网络错误'));
// 执行测试代码...
// 验证错误调用
expect(mockClient.index.$get).toHaveBeenCalledTimes(1);
});
});
console.debug: 在测试中使用console.debug输出模拟调用信息,便于调试
// 在测试中输出调试信息
console.debug('Mock client calls:', {
getCalls: mockClient.index.$get.mock.calls,
postCalls: mockClient.index.$post.mock.calls
});
调用检查: 使用vi.mocked()检查模拟函数的调用参数和次数
// 检查mockRpcClient的调用
const mockCalls = vi.mocked(mockRpcClient).mock.calls;
console.debug('rpcClient调用参数:', mockCalls);
// 检查具体端点调用
const getCalls = vi.mocked(mockClient.index.$get).mock.calls;
响应验证: 确保模拟响应的格式与实际API响应一致
// 验证响应格式
const response = await mockClient.index.$get();
expect(response.status).toBe(200);
expect(response.ok).toBe(true);
const data = await response.json();
expect(data).toHaveProperty('data');
expect(data).toHaveProperty('pagination');
错误模拟: 测试各种错误场景,确保UI能正确处理
// 模拟不同类型的错误
mockClient.index.$get.mockRejectedValue(new Error('网络错误')); // 网络错误
mockClient.index.$get.mockResolvedValue(createMockResponse(500)); // 服务器错误
mockClient.index.$get.mockResolvedValue(createMockResponse(401)); // 认证错误
快照测试: 使用Vitest的快照测试验证UI在不同API响应下的渲染结果
跨包调试: 在跨UI包集成测试中,验证所有相关API都正确配置了模拟响应
# 运行所有基础设施包测试
cd packages/shared-types && pnpm test
cd packages/shared-utils && pnpm test
cd packages/shared-crud && pnpm test
cd packages/shared-test-util && pnpm test
# 生成覆盖率报告
cd packages/shared-utils && pnpm test:coverage
# 运行所有业务模块包测试
cd packages/user-module && pnpm test
cd packages/auth-module && pnpm test
cd packages/file-module && pnpm test
cd packages/geo-areas && pnpm test
# 运行单元测试
cd packages/user-module && pnpm test:unit
# 运行集成测试
cd packages/auth-module && pnpm test:integration
# 生成覆盖率报告
cd packages/user-module && pnpm test:coverage
# 运行所有测试
cd packages/server && pnpm test
# 运行集成测试
cd packages/server && pnpm test:integration
# 生成覆盖率报告
cd packages/server && pnpm test:coverage
# 运行所有测试
cd web && pnpm test
# 运行单元测试
cd web && pnpm test:unit
# 运行集成测试
cd web && pnpm test:integration
# 运行组件测试
cd web && pnpm test:components
# 运行E2E测试
cd web && pnpm test:e2e:chromium
# 生成覆盖率报告
cd web && pnpm test:coverage
// 良好的测试示例
describe('UserService', () => {
describe('createUser()', () => {
it('应该创建新用户并返回用户对象', async () => {
// Arrange
const userData = { username: 'testuser', email: 'test@example.com' };
// Act
const result = await userService.createUser(userData);
// Assert
expect(result).toHaveProperty('id');
expect(result.username).toBe('testuser');
expect(result.email).toBe('test@example.com');
});
it('应该拒绝重复的用户名', async () => {
// Arrange
const existingUser = await createTestUser({ username: 'existing' });
// Act & Assert
await expect(
userService.createUser({ username: 'existing', email: 'new@example.com' })
).rejects.toThrow('用户名已存在');
});
});
});
[module].test.ts 或 [module].integration.test.ts| 日期 | 版本 | 描述 |
|---|---|---|
| 2025-11-11 | 2.8 | 更新包测试结构,添加模块化包测试策略 |
| 2025-11-09 | 2.7 | 更新为monorepo测试架构,清理重复测试文件 |
| 2025-10-15 | 2.6 | 完成遗留测试文件迁移到统一的tests目录结构 |
| 2025-10-14 | 2.5 | 重构测试文件结构,统一到tests目录 |
| 2025-09-20 | 2.4 | 更新版本与主架构文档一致 |
| 2025-09-19 | 1.0 | 初始版本,基于现有测试基础设施 |
文档状态: 正式版 下次评审: 2025-12-19