|
@@ -3,6 +3,7 @@
|
|
|
## 版本信息
|
|
## 版本信息
|
|
|
| 版本 | 日期 | 描述 | 作者 |
|
|
| 版本 | 日期 | 描述 | 作者 |
|
|
|
|------|------|------|------|
|
|
|------|------|------|------|
|
|
|
|
|
+| 2.9 | 2025-12-15 | 添加API模拟规范和前端组件测试策略 | James |
|
|
|
| 2.8 | 2025-11-11 | 更新包测试结构,添加模块化包测试策略 | Winston |
|
|
| 2.8 | 2025-11-11 | 更新包测试结构,添加模块化包测试策略 | Winston |
|
|
|
| 2.7 | 2025-11-09 | 更新为monorepo测试架构,清理重复测试文件 | James |
|
|
| 2.7 | 2025-11-09 | 更新为monorepo测试架构,清理重复测试文件 | James |
|
|
|
| 2.6 | 2025-10-15 | 完成遗留测试文件迁移到统一的tests目录结构 | Winston |
|
|
| 2.6 | 2025-10-15 | 完成遗留测试文件迁移到统一的tests目录结构 | Winston |
|
|
@@ -206,6 +207,348 @@ const inactiveUser = createTestUser({ active: false });
|
|
|
2. **数据库清理** (每个测试后)
|
|
2. **数据库清理** (每个测试后)
|
|
|
3. **测试数据隔离** (使用唯一标识符)
|
|
3. **测试数据隔离** (使用唯一标识符)
|
|
|
|
|
|
|
|
|
|
+## API模拟规范
|
|
|
|
|
+
|
|
|
|
|
+### 概述
|
|
|
|
|
+API模拟规范为管理后台UI包提供测试中的API模拟策略。虽然当前项目实践中每个UI包都有自己的客户端管理器,但为了简化测试复杂度、特别是跨UI包集成测试场景,规范要求统一模拟共享UI组件包中的`rpcClient`函数。
|
|
|
|
|
+
|
|
|
|
|
+### 问题背景
|
|
|
|
|
+当前实现中,每个UI包测试文件模拟自己的客户端管理器(如`AdvertisementClientManager`、`UserClientManager`)。这种模式在单一UI包测试时可行,但在**跨UI包集成测试**时存在严重问题:
|
|
|
|
|
+
|
|
|
|
|
+**示例场景**:收货地址UI包中使用区域管理UI包的区域选择组件,两个组件分别使用各自的客户端管理器。
|
|
|
|
|
+- 收货地址组件 → 使用`DeliveryAddressClientManager`
|
|
|
|
|
+- 区域选择组件 → 使用`AreaClientManager`
|
|
|
|
|
+- 测试时需要同时模拟两个客户端管理器,配置复杂且容易冲突
|
|
|
|
|
+
|
|
|
|
|
+**统一模拟优势**:通过模拟`@d8d/shared-ui-components`包中的`rpcClient`函数,可以:
|
|
|
|
|
+1. **统一控制**:所有API调用都经过同一个模拟点
|
|
|
|
|
+2. **简化配置**:无需关心具体客户端管理器,只需配置API响应
|
|
|
|
|
+3. **跨包支持**:天然支持多个UI包组件的集成测试
|
|
|
|
|
+4. **维护性**:API响应配置集中管理,易于更新
|
|
|
|
|
+
|
|
|
|
|
+### 现有模式分析(仅供参考)
|
|
|
|
|
+项目中的管理后台UI包当前遵循以下架构模式:
|
|
|
|
|
+1. **客户端管理器模式**:每个UI包都有一个客户端管理器类(如`AdvertisementClientManager`、`UserClientManager`)
|
|
|
|
|
+2. **rpcClient使用**:客户端管理器使用`@d8d/shared-ui-components`包中的`rpcClient`函数创建Hono RPC客户端
|
|
|
|
|
+3. **API结构**:生成的客户端使用Hono风格的方法调用(如`index.$get`、`index.$post`、`:id.$put`等)
|
|
|
|
|
+
|
|
|
|
|
+**注意**:新的测试规范要求直接模拟`rpcClient`函数,而不是模拟各个客户端管理器。
|
|
|
|
|
+
|
|
|
|
|
+### rpcClient函数分析
|
|
|
|
|
+`rpcClient`函数位于`@d8d/shared-ui-components`包的`src/utils/hc.ts`文件中,其核心功能是创建Hono RPC客户端:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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客户端实例。
|
|
|
|
|
+
|
|
|
|
|
+### 模拟策略
|
|
|
|
|
+
|
|
|
|
|
+#### 1. 统一模拟rpcClient函数
|
|
|
|
|
+在测试中,使用Vitest的`vi.mock`直接模拟`@d8d/shared-ui-components`包中的`rpcClient`函数,统一拦截所有API调用:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 测试文件顶部 - 统一模拟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
|
|
|
|
|
+}))
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 2. 创建模拟响应辅助函数
|
|
|
|
|
+创建通用的模拟响应辅助函数,用于生成一致的API响应格式:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 在测试文件中定义或从共享工具导入
|
|
|
|
|
+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()
|
|
|
|
|
+})
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 3. 在测试用例中配置模拟响应
|
|
|
|
|
+在测试用例的`beforeEach`或具体测试中配置模拟响应,支持跨UI包集成:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 跨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('网络错误'));
|
|
|
|
|
+
|
|
|
|
|
+ // 测试错误处理
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 管理后台UI包测试策略
|
|
|
|
|
+
|
|
|
|
|
+#### 1. 模拟范围
|
|
|
|
|
+- **统一模拟点**: 集中模拟`@d8d/shared-ui-components/utils/hc`中的`rpcClient`函数
|
|
|
|
|
+- **HTTP方法**: 支持Hono风格的`$get`、`$post`、`$put`、`$delete`方法
|
|
|
|
|
+- **API端点**: 支持标准端点(`index`)、参数化端点(`:id`)和嵌套路径(`$path()`)
|
|
|
|
|
+- **响应格式**: 模拟完整的Response对象,包含`status`、`ok`、`json()`等方法
|
|
|
|
|
+- **跨包支持**: 天然支持多个UI包组件的API模拟,无需分别模拟客户端管理器
|
|
|
|
|
+
|
|
|
|
|
+#### 2. 测试设置
|
|
|
|
|
+1. **统一模拟**: 在每个测试文件顶部使用`vi.mock`统一模拟`rpcClient`函数
|
|
|
|
|
+2. **测试隔离**: 每个测试用例使用独立的模拟实例,在`beforeEach`中重置
|
|
|
|
|
+3. **响应配置**: 根据测试场景配置不同的模拟响应(成功、失败、错误等)
|
|
|
|
|
+4. **错误测试**: 模拟各种错误场景(网络错误、验证错误、权限错误、服务器错误等)
|
|
|
|
|
+5. **跨包集成**: 支持配置多个UI包的API响应,适用于组件集成测试
|
|
|
|
|
+
|
|
|
|
|
+#### 3. 最佳实践
|
|
|
|
|
+- **统一模拟**: 所有API调用都通过模拟`rpcClient`函数统一拦截
|
|
|
|
|
+- **类型安全**: 使用TypeScript确保模拟响应与API类型兼容
|
|
|
|
|
+- **可维护性**: 保持模拟响应与实际API响应结构一致,便于后续更新
|
|
|
|
|
+- **文档化**: 在测试注释中说明模拟的API行为和预期结果
|
|
|
|
|
+- **响应工厂**: 创建可重用的模拟响应工厂函数,确保响应格式一致性
|
|
|
|
|
+- **跨包考虑**: 为集成的UI包组件配置相应的API响应
|
|
|
|
|
+
|
|
|
|
|
+### 验证和调试
|
|
|
|
|
+
|
|
|
|
|
+#### 1. 模拟验证
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 验证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);
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 2. 调试技巧
|
|
|
|
|
+- **console.debug**: 在测试中使用`console.debug`输出模拟调用信息,便于调试
|
|
|
|
|
+ ```typescript
|
|
|
|
|
+ // 在测试中输出调试信息
|
|
|
|
|
+ console.debug('Mock client calls:', {
|
|
|
|
|
+ getCalls: mockClient.index.$get.mock.calls,
|
|
|
|
|
+ postCalls: mockClient.index.$post.mock.calls
|
|
|
|
|
+ });
|
|
|
|
|
+ ```
|
|
|
|
|
+
|
|
|
|
|
+- **调用检查**: 使用`vi.mocked()`检查模拟函数的调用参数和次数
|
|
|
|
|
+ ```typescript
|
|
|
|
|
+ // 检查mockRpcClient的调用
|
|
|
|
|
+ const mockCalls = vi.mocked(mockRpcClient).mock.calls;
|
|
|
|
|
+ console.debug('rpcClient调用参数:', mockCalls);
|
|
|
|
|
+
|
|
|
|
|
+ // 检查具体端点调用
|
|
|
|
|
+ const getCalls = vi.mocked(mockClient.index.$get).mock.calls;
|
|
|
|
|
+ ```
|
|
|
|
|
+
|
|
|
|
|
+- **响应验证**: 确保模拟响应的格式与实际API响应一致
|
|
|
|
|
+ ```typescript
|
|
|
|
|
+ // 验证响应格式
|
|
|
|
|
+ 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能正确处理
|
|
|
|
|
+ ```typescript
|
|
|
|
|
+ // 模拟不同类型的错误
|
|
|
|
|
+ mockClient.index.$get.mockRejectedValue(new Error('网络错误')); // 网络错误
|
|
|
|
|
+ mockClient.index.$get.mockResolvedValue(createMockResponse(500)); // 服务器错误
|
|
|
|
|
+ mockClient.index.$get.mockResolvedValue(createMockResponse(401)); // 认证错误
|
|
|
|
|
+ ```
|
|
|
|
|
+
|
|
|
|
|
+- **快照测试**: 使用Vitest的快照测试验证UI在不同API响应下的渲染结果
|
|
|
|
|
+- **跨包调试**: 在跨UI包集成测试中,验证所有相关API都正确配置了模拟响应
|
|
|
|
|
+
|
|
|
## 测试执行流程
|
|
## 测试执行流程
|
|
|
|
|
|
|
|
### 本地开发测试
|
|
### 本地开发测试
|