Bläddra i källkod

✨ feat(shared-test-util): 创建共享测试工具包

- 初始化 shared-test-util 包结构,添加 package.json 配置
- 实现集成测试数据库工具类 IntegrationTestDatabase
- 创建集成测试断言工具类 IntegrationTestAssertions 和 ApiResponseAssertions
- 添加 TypeScript 配置文件 tsconfig.json
- 配置 Vitest 测试环境 vitest.config.ts
- 更新基础设施拆分文档,明确从 user-module 迁移并通用化测试工具的计划

🔧 chore(docs): 更新基础设施拆分文档任务描述

- 修改 shared-test-util 包任务描述,明确从 user-module 迁移并通用化测试工具
- 调整测试工具创建任务为迁移并通用化现有工具(基于 user-module/tests/utils)
yourname 4 veckor sedan
förälder
incheckning
a4f8012a10

+ 3 - 4
docs/stories/005.001.infrastructure-packages-split.md

@@ -41,10 +41,9 @@ Draft
 
 - [ ] 创建 shared-test-util package (测试基础设施依赖)
   - [ ] 创建 package.json 配置
-  - [ ] 创建集成测试数据库工具(integration-test-db.ts)
-  - [ ] 创建通用集成测试断言工具(integration-test-utils.ts)
-  - [ ] 创建测试生命周期钩子(setup-hooks.ts)
-  - [ ] 创建通用单元测试mock工具(mock-utils.ts)
+  - [ ] 迁移并通用化集成测试数据库工具(基于 user-module/tests/utils/integration-test-db.ts)
+  - [ ] 迁移并通用化集成测试断言工具(基于 user-module/tests/utils/integration-test-utils.ts)
+  - [ ] 迁移并通用化测试生命周期钩子(基于 user-module/tests/utils/integration-test-db.ts 中的 setupIntegrationDatabaseHooks)
   - [ ] 配置 TypeScript 编译选项(包含 `"composite": true`)
   - [ ] 编写基础测试
 

+ 68 - 0
packages/shared-test-util/package.json

@@ -0,0 +1,68 @@
+{
+  "name": "@d8d/shared-test-util",
+  "version": "1.0.0",
+  "description": "Shared testing utilities for D8D applications",
+  "type": "module",
+  "main": "src/index.ts",
+  "types": "src/index.ts",
+  "exports": {
+    ".": {
+      "import": "./src/index.ts",
+      "require": "./src/index.ts",
+      "types": "./src/index.ts"
+    },
+    "./integration-test-db": {
+      "import": "./src/integration-test-db.ts",
+      "require": "./src/integration-test-db.ts",
+      "types": "./src/integration-test-db.ts"
+    },
+    "./integration-test-utils": {
+      "import": "./src/integration-test-utils.ts",
+      "require": "./src/integration-test-utils.ts",
+      "types": "./src/integration-test-utils.ts"
+    },
+    "./setup-hooks": {
+      "import": "./src/setup-hooks.ts",
+      "require": "./src/setup-hooks.ts",
+      "types": "./src/setup-hooks.ts"
+    },
+    "./mock-utils": {
+      "import": "./src/mock-utils.ts",
+      "require": "./src/mock-utils.ts",
+      "types": "./src/mock-utils.ts"
+    }
+  },
+  "files": [
+    "src"
+  ],
+  "scripts": {
+    "build": "tsc",
+    "dev": "tsc --watch",
+    "test": "vitest",
+    "test:unit": "vitest run src/**/*.test.ts",
+    "test:coverage": "vitest --coverage",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@d8d/shared-utils": "workspace:*",
+    "typeorm": "^0.3.20",
+    "vitest": "^3.2.4"
+  },
+  "peerDependencies": {
+    "hono": "^4.8.5"
+  },
+  "devDependencies": {
+    "typescript": "^5.8.3",
+    "@vitest/coverage-v8": "^3.2.4"
+  },
+  "keywords": [
+    "testing",
+    "utilities",
+    "integration",
+    "unit",
+    "database",
+    "mocking"
+  ],
+  "author": "D8D Team",
+  "license": "MIT"
+}

+ 8 - 0
packages/shared-test-util/src/index.ts

@@ -0,0 +1,8 @@
+// 导出集成测试数据库工具
+export * from './integration-test-db';
+
+// 导出集成测试断言工具
+export * from './integration-test-utils';
+
+// 导出类型定义
+export type { EntityTarget, ObjectLiteral } from 'typeorm';

+ 120 - 0
packages/shared-test-util/src/integration-test-db.ts

@@ -0,0 +1,120 @@
+import { DataSource, EntityTarget, ObjectLiteral } from 'typeorm';
+import { beforeEach, afterEach } from 'vitest';
+import { AppDataSource, initializeDataSource } from '@d8d/shared-utils';
+
+/**
+ * 集成测试数据库工具类 - 使用真实PostgreSQL数据库
+ * 通用版本,不依赖特定实体
+ */
+export class IntegrationTestDatabase {
+  /**
+   * 清理集成测试数据库
+   */
+  static async cleanup(): Promise<void> {
+    if (AppDataSource.isInitialized) {
+      await AppDataSource.destroy();
+    }
+  }
+
+  /**
+   * 获取当前数据源
+   */
+  static async getDataSource(): Promise<DataSource> {
+    if (!AppDataSource.isInitialized) {
+      await AppDataSource.initialize();
+    }
+    return AppDataSource;
+  }
+
+  /**
+   * 使用特定实体初始化数据源
+   */
+  static async initializeWithEntities(entities: EntityTarget<ObjectLiteral>[]): Promise<DataSource> {
+    initializeDataSource(entities);
+    return await this.getDataSource();
+  }
+
+  /**
+   * 清理特定表的数据
+   */
+  static async cleanupTable<T extends ObjectLiteral>(
+    entity: EntityTarget<T>,
+    condition?: Partial<T>
+  ): Promise<void> {
+    const dataSource = await this.getDataSource();
+    const repository = dataSource.getRepository(entity);
+
+    if (condition) {
+      await repository.delete(condition);
+    } else {
+      await repository.clear();
+    }
+  }
+
+  /**
+   * 创建测试数据
+   */
+  static async createTestData<T extends ObjectLiteral>(
+    entity: EntityTarget<T>,
+    data: Partial<T>
+  ): Promise<T> {
+    const dataSource = await this.getDataSource();
+    const repository = dataSource.getRepository(entity);
+
+    const entityInstance = repository.create(data);
+    return await repository.save(entityInstance);
+  }
+
+  /**
+   * 查找测试数据
+   */
+  static async findTestData<T extends ObjectLiteral>(
+    entity: EntityTarget<T>,
+    condition: Partial<T>
+  ): Promise<T | null> {
+    const dataSource = await this.getDataSource();
+    const repository = dataSource.getRepository(entity);
+
+    return await repository.findOne({ where: condition });
+  }
+
+  /**
+   * 删除测试数据
+   */
+  static async deleteTestData<T extends ObjectLiteral>(
+    entity: EntityTarget<T>,
+    condition: Partial<T>
+  ): Promise<void> {
+    const dataSource = await this.getDataSource();
+    const repository = dataSource.getRepository(entity);
+
+    await repository.delete(condition);
+  }
+}
+
+
+/**
+ * 集成测试数据库生命周期钩子
+ */
+export function setupIntegrationDatabaseHooks() {
+  beforeEach(async () => {
+    await IntegrationTestDatabase.getDataSource();
+  });
+
+  afterEach(async () => {
+    await IntegrationTestDatabase.cleanup();
+  });
+}
+
+/**
+ * 集成测试数据库生命周期钩子(带实体初始化)
+ */
+export function setupIntegrationDatabaseHooksWithEntities(entities: EntityTarget<ObjectLiteral>[]) {
+  beforeEach(async () => {
+    await IntegrationTestDatabase.initializeWithEntities(entities);
+  });
+
+  afterEach(async () => {
+    await IntegrationTestDatabase.cleanup();
+  });
+}

+ 183 - 0
packages/shared-test-util/src/integration-test-utils.ts

@@ -0,0 +1,183 @@
+import { IntegrationTestDatabase } from './integration-test-db';
+import { EntityTarget, ObjectLiteral } from 'typeorm';
+
+/**
+ * 集成测试断言工具
+ * 通用版本,不依赖特定实体
+ */
+export class IntegrationTestAssertions {
+  /**
+   * 断言响应状态码
+   */
+  static expectStatus(response: { status: number }, expectedStatus: number): void {
+    if (response.status !== expectedStatus) {
+      throw new Error(`Expected status ${expectedStatus}, but got ${response.status}`);
+    }
+  }
+
+  /**
+   * 断言响应包含特定字段
+   */
+  static expectResponseToHave(response: { data: any }, expectedFields: Record<string, any>): void {
+    for (const [key, value] of Object.entries(expectedFields)) {
+      if (response.data[key] !== value) {
+        throw new Error(`Expected field ${key} to be ${value}, but got ${response.data[key]}`);
+      }
+    }
+  }
+
+  /**
+   * 断言响应包含特定结构
+   */
+  static expectResponseStructure(response: { data: any }, structure: Record<string, any>): void {
+    for (const key of Object.keys(structure)) {
+      if (!(key in response.data)) {
+        throw new Error(`Expected response to have key: ${key}`);
+      }
+    }
+  }
+
+  /**
+   * 断言实体存在于数据库中
+   */
+  static async expectEntityToExist<T extends ObjectLiteral>(
+    entity: EntityTarget<T>,
+    condition: Partial<T>
+  ): Promise<void> {
+    const dataSource = await IntegrationTestDatabase.getDataSource();
+    if (!dataSource) {
+      throw new Error('Database not initialized');
+    }
+
+    const repository = dataSource.getRepository(entity);
+    const entityInstance = await repository.findOne({ where: condition });
+
+    if (!entityInstance) {
+      throw new Error(`Expected entity to exist in database with condition: ${JSON.stringify(condition)}`);
+    }
+  }
+
+  /**
+   * 断言实体不存在于数据库中
+   */
+  static async expectEntityNotToExist<T extends ObjectLiteral>(
+    entity: EntityTarget<T>,
+    condition: Partial<T>
+  ): Promise<void> {
+    const dataSource = await IntegrationTestDatabase.getDataSource();
+    if (!dataSource) {
+      throw new Error('Database not initialized');
+    }
+
+    const repository = dataSource.getRepository(entity);
+    const entityInstance = await repository.findOne({ where: condition });
+
+    if (entityInstance) {
+      throw new Error(`Expected entity not to exist in database with condition: ${JSON.stringify(condition)}`);
+    }
+  }
+
+  /**
+   * 断言响应包含错误信息
+   */
+  static expectErrorResponse(response: { data: any }, expectedError: string): void {
+    if (!response.data || !response.data.error || !response.data.error.includes(expectedError)) {
+      throw new Error(`Expected error response to contain "${expectedError}", but got: ${JSON.stringify(response.data)}`);
+    }
+  }
+
+  /**
+   * 断言响应包含成功信息
+   */
+  static expectSuccessResponse(response: { data: any }): void {
+    if (!response.data || response.data.error) {
+      throw new Error(`Expected success response, but got: ${JSON.stringify(response.data)}`);
+    }
+  }
+
+  /**
+   * 断言列表响应包含特定数量的项目
+   */
+  static expectListResponseCount(response: { data: any }, expectedCount: number): void {
+    if (!Array.isArray(response.data)) {
+      throw new Error(`Expected array response, but got: ${typeof response.data}`);
+    }
+
+    if (response.data.length !== expectedCount) {
+      throw new Error(`Expected ${expectedCount} items, but got ${response.data.length}`);
+    }
+  }
+
+  /**
+   * 断言响应包含分页信息
+   */
+  static expectPaginationResponse(response: { data: any }): void {
+    if (!response.data || typeof response.data !== 'object') {
+      throw new Error(`Expected object response, but got: ${typeof response.data}`);
+    }
+
+    const requiredFields = ['data', 'total', 'page', 'pageSize'];
+    for (const field of requiredFields) {
+      if (!(field in response.data)) {
+        throw new Error(`Expected pagination response to have field: ${field}`);
+      }
+    }
+
+    if (!Array.isArray(response.data.data)) {
+      throw new Error(`Expected pagination data to be an array, but got: ${typeof response.data.data}`);
+    }
+  }
+}
+
+/**
+ * API 响应断言工具
+ */
+export class ApiResponseAssertions {
+  /**
+   * 断言成功的 API 响应
+   */
+  static expectSuccess(response: { status: number; data: any }): void {
+    IntegrationTestAssertions.expectStatus(response, 200);
+    IntegrationTestAssertions.expectSuccessResponse(response);
+  }
+
+  /**
+   * 断言创建的 API 响应
+   */
+  static expectCreated(response: { status: number; data: any }): void {
+    IntegrationTestAssertions.expectStatus(response, 201);
+    IntegrationTestAssertions.expectSuccessResponse(response);
+  }
+
+  /**
+   * 断言未授权的 API 响应
+   */
+  static expectUnauthorized(response: { status: number; data: any }): void {
+    IntegrationTestAssertions.expectStatus(response, 401);
+    IntegrationTestAssertions.expectErrorResponse(response, 'Unauthorized');
+  }
+
+  /**
+   * 断言禁止访问的 API 响应
+   */
+  static expectForbidden(response: { status: number; data: any }): void {
+    IntegrationTestAssertions.expectStatus(response, 403);
+    IntegrationTestAssertions.expectErrorResponse(response, 'Forbidden');
+  }
+
+  /**
+   * 断言未找到的 API 响应
+   */
+  static expectNotFound(response: { status: number; data: any }): void {
+    IntegrationTestAssertions.expectStatus(response, 404);
+    IntegrationTestAssertions.expectErrorResponse(response, 'Not found');
+  }
+
+  /**
+   * 断言验证错误的 API 响应
+   */
+  static expectValidationError(response: { status: number; data: any }): void {
+    IntegrationTestAssertions.expectStatus(response, 400);
+    IntegrationTestAssertions.expectErrorResponse(response, 'Validation');
+  }
+}

+ 28 - 0
packages/shared-test-util/tsconfig.json

@@ -0,0 +1,28 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "lib": ["ES2022"],
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "allowJs": true,
+    "strict": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "forceConsistentCasingInFileNames": true,
+    "skipLibCheck": true,
+    "declaration": true,
+    "outDir": "./dist",
+    "rootDir": "./src",
+    "composite": true
+  },
+  "include": [
+    "src/**/*"
+  ],
+  "exclude": [
+    "node_modules",
+    "dist"
+  ]
+}

+ 14 - 0
packages/shared-test-util/vitest.config.ts

@@ -0,0 +1,14 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    globals: true,
+    environment: 'node',
+    include: ['src/**/*.test.ts'],
+    coverage: {
+      provider: 'v8',
+      reporter: ['text', 'json', 'html'],
+      exclude: ['node_modules/', 'dist/']
+    }
+  }
+});