backend-module-testing-standards.md 20 KB

后端模块包测试规范

版本信息

版本 日期 描述 作者
1.0 2025-12-26 从测试策略文档拆分,专注后端模块包测试 James (Claude Code)

概述

本文档定义了后端模块包的测试标准和最佳实践。

  • 目标: packages/*-module - 业务模块包(user-module、auth-module、file-module等)
  • 测试类型: 单元测试 + 集成测试

测试框架栈

  • Vitest: 测试运行器
  • hono/testing: Hono路由测试
  • TypeORM: 数据库测试
  • PostgreSQL: 测试数据库
  • shared-test-util: 共享测试基础设施

测试分层策略

1. 单元测试(Unit Tests)

  • 范围: 单个服务、工具函数、Schema验证
  • 目标: 验证独立单元的正确性
  • 位置: packages/*-module/tests/unit/**/*.test.ts
  • 框架: Vitest
  • 覆盖率目标: ≥ 80%

2. 集成测试(Integration Tests)

  • 范围: 路由、服务、数据库集成
  • 目标: 验证模块内部组件协作
  • 位置: packages/*-module/tests/integration/**/*.test.ts
  • 框架: Vitest + hono/testing + TypeORM
  • 覆盖率目标: ≥ 60%

测试文件结构

packages/user-module/
├── src/
│   ├── entities/
│   │   ├── user.entity.ts
│   │   └── role.entity.ts
│   ├── services/
│   │   ├── user.service.ts
│   │   └── role.service.ts
│   ├── schemas/
│   │   ├── user.schema.ts
│   │   └── role.schema.ts
│   └── routes/
│       ├── user.routes.ts
│       └── role.routes.ts
└── tests/
    ├── unit/
    │   ├── services/
    │   │   ├── user.service.test.ts
    │   │   └── role.service.test.ts
    │   └── schemas/
    │       ├── user.schema.test.ts
    │       └── role.schema.test.ts
    ├── integration/
    │   ├── routes/
    │   │   ├── user.routes.integration.test.ts
    │   │   └── role.routes.integration.test.ts
    │   └── services/
    │       └── user.service.integration.test.ts
    └── fixtures/
        ├── test-db.ts        # 测试数据库配置
        └── factories.ts      # 测试数据工厂

单元测试最佳实践

1. 测试Service层

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService } from '../src/services/user.service';
import { User } from '../src/entities/user.entity';
import { Repository } from 'typeorm';

describe('UserService', () => {
  let userService: UserService;
  let mockUserRepo: Partial<Repository<User>>;

  beforeEach(() => {
    // 模拟Repository
    mockUserRepo = {
      findOne: vi.fn(),
      find: vi.fn(),
      create: vi.fn(),
      save: vi.fn(),
      update: vi.fn(),
      delete: vi.fn()
    };

    userService = new UserService(mockUserRepo as Repository<User>);
  });

  describe('findById()', () => {
    it('应该返回用户数据', async () => {
      // Arrange
      const mockUser = { id: 1, username: 'testuser', email: 'test@example.com' };
      mockUserRepo.findOne?.mockResolvedValue(mockUser);

      // Act
      const result = await userService.findById(1);

      // Assert
      expect(result).toEqual(mockUser);
      expect(mockUserRepo.findOne).toHaveBeenCalledWith({
        where: { id: 1 }
      });
    });

    it('应该返回null当用户不存在', async () => {
      mockUserRepo.findOne?.mockResolvedValue(null);

      const result = await userService.findById(999);

      expect(result).toBeNull();
    });
  });

  describe('createUser()', () => {
    it('应该创建新用户', async () => {
      const userData = {
        username: 'newuser',
        email: 'new@example.com',
        password: 'hashedpassword'
      };

      const createdUser = { id: 1, ...userData };
      mockUserRepo.create?.mockReturnValue(createdUser);
      mockUserRepo.save?.mockResolvedValue(createdUser);

      const result = await userService.createUser(userData);

      expect(result).toEqual(createdUser);
      expect(mockUserRepo.create).toHaveBeenCalledWith(userData);
      expect(mockUserRepo.save).toHaveBeenCalledWith(createdUser);
    });

    it('应该抛出错误当用户名已存在', async () => {
      mockUserRepo.findOne?.mockResolvedValue({ id: 1, username: 'existing' });

      await expect(
        userService.createUser({ username: 'existing', email: 'test@example.com', password: 'hash' })
      ).rejects.toThrow('用户名已存在');
    });
  });
});

2. 测试Schema验证

import { describe, it, expect } from 'vitest';
import { userSchema } from '../src/schemas/user.schema';

describe('userSchema', () => {
  it('应该验证有效的用户数据', async () => {
    const validData = {
      username: 'testuser',
      email: 'test@example.com',
      password: 'password123'
    };

    const result = await userSchema.parseAsync(validData);

    expect(result).toEqual(validData);
  });

  it('应该拒绝无效的邮箱格式', async () => {
    const invalidData = {
      username: 'testuser',
      email: 'invalid-email',
      password: 'password123'
    };

    await expect(userSchema.parseAsync(invalidData)).rejects.toThrow();
  });

  it('应该拒绝空密码', async () => {
    const invalidData = {
      username: 'testuser',
      email: 'test@example.com',
      password: ''
    };

    await expect(userSchema.parseAsync(invalidData)).rejects.toThrow();
  });
});

3. 测试工具函数

import { describe, it, expect } from 'vitest';
import { hashPassword, comparePassword } from '../src/utils/password.util';

describe('密码工具函数', () => {
  describe('hashPassword()', () => {
    it('应该返回哈希后的密码', async () => {
      const password = 'password123';
      const hash = await hashPassword(password);

      expect(hash).not.toBe(password);
      expect(hash).toMatch(/^\$2[ayb]\$.{56}$/); // bcrypt格式
    });

    it('应该为相同密码生成不同的哈希值', async () => {
      const password = 'password123';
      const hash1 = await hashPassword(password);
      const hash2 = await hashPassword(password);

      expect(hash1).not.toBe(hash2);
    });
  });

  describe('comparePassword()', () => {
    it('应该返回true当密码匹配', async () => {
      const password = 'password123';
      const hash = await hashPassword(password);

      const result = await comparePassword(password, hash);

      expect(result).toBe(true);
    });

    it('应该返回false当密码不匹配', async () => {
      const hash = await hashPassword('password123');

      const result = await comparePassword('wrongpassword', hash);

      expect(result).toBe(false);
    });
  });
});

集成测试最佳实践

1. 测试路由层

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { integrateRoutes } from 'hono/testing';
import { Hono } from 'hono';
import { getTestDataSource } from './fixtures/test-db';
import { userRoutes } from '../src/routes/user.routes';
import { seedTestUser } from './fixtures/factories';

describe('用户路由集成测试', () => {
  let dataSource: DataSource;
  let app: Hono;

  beforeEach(async () => {
    dataSource = await getTestDataSource();
    await dataSource.initialize();

    app = new Hono();
    app.route('/api/users', userRoutes);
  });

  afterEach(async () => {
    await dataSource.destroy();
  });

  describe('GET /api/users', () => {
    it('应该返回用户列表', async () => {
      // Arrange
      await seedTestUser(dataSource, { username: 'user1' });
      await seedTestUser(dataSource, { username: 'user2' });

      // Act
      const res = await integrateRoutes(app).GET('/api/users');

      // Assert
      expect(res.status).toBe(200);
      const json = await res.json();
      expect(json.data).toHaveLength(2);
      expect(json.data[0].username).toBe('user1');
    });

    it('应该支持分页查询', async () => {
      // 创建15个用户
      for (let i = 1; i <= 15; i++) {
        await seedTestUser(dataSource, { username: `user${i}` });
      }

      // 查询第一页
      const res1 = await integrateRoutes(app).GET('/api/users?page=1&pageSize=10');
      expect(res1.status).toBe(200);
      const json1 = await res1.json();
      expect(json1.data).toHaveLength(10);
      expect(json1.total).toBe(15);

      // 查询第二页
      const res2 = await integrateRoutes(app).GET('/api/users?page=2&pageSize=10');
      expect(res2.status).toBe(200);
      const json2 = await res2.json();
      expect(json2.data).toHaveLength(5);
    });
  });

  describe('GET /api/users/:id', () => {
    it('应该返回用户详情', async () => {
      const user = await seedTestUser(dataSource, { username: 'testuser' });

      const res = await integrateRoutes(app).GET(`/api/users/${user.id}`);

      expect(res.status).toBe(200);
      const json = await res.json();
      expect(json.username).toBe('testuser');
    });

    it('应该返回404当用户不存在', async () => {
      const res = await integrateRoutes(app).GET('/api/users/99999');

      expect(res.status).toBe(404);
    });
  });

  describe('POST /api/users', () => {
    it('应该创建新用户', async () => {
      const userData = {
        username: 'newuser',
        email: 'new@example.com',
        password: 'password123'
      };

      const res = await integrateRoutes(app).POST('/api/users', { json: userData });

      expect(res.status).toBe(201);
      const json = await res.json();
      expect(json.username).toBe('newuser');

      // 验证数据库中的数据
      const userRepo = dataSource.getRepository(User);
      const user = await userRepo.findOne({ where: { username: 'newuser' } });
      expect(user).toBeDefined();
    });

    it('应该拒绝重复的用户名', async () => {
      await seedTestUser(dataSource, { username: 'existing' });

      const res = await integrateRoutes(app).POST('/api/users', {
        json: {
          username: 'existing',
          email: 'different@example.com',
          password: 'password123'
        }
      });

      expect(res.status).toBe(400);
      const json = await res.json();
      expect(json.error).toContain('用户名已存在');
    });
  });
});

2. 测试Service与数据库集成

describe('UserService与数据库集成', () => {
  let dataSource: DataSource;
  let userService: UserService;

  beforeEach(async () => {
    dataSource = await getTestDataSource();
    await dataSource.initialize();

    const userRepo = dataSource.getRepository(User);
    userService = new UserService(userRepo);
  });

  afterEach(async () => {
    await dataSource.destroy();
  });

  it('应该从数据库查询用户', async () => {
    // 创建测试数据
    const userRepo = dataSource.getRepository(User);
    const createdUser = await userRepo.save({
      username: 'testuser',
      email: 'test@example.com',
      password: 'hashedpassword'
    });

    // 测试查询
    const foundUser = await userService.findById(createdUser.id);

    expect(foundUser).toBeDefined();
    expect(foundUser?.username).toBe('testuser');
  });

  it('应该正确处理事务', async () => {
    await dataSource.transaction(async (manager) => {
      const user1 = await manager.save(User, {
        username: 'user1',
        email: 'user1@example.com',
        password: 'hash'
      });

      const user2 = await manager.save(User, {
        username: 'user2',
        email: 'user2@example.com',
        password: 'hash'
      });

      expect(user1.id).toBeDefined();
      expect(user2.id).toBeDefined();
    });
  });
});

3. 测试中间件集成

import { authMiddleware } from '../src/middleware/auth.middleware';
import { generateToken } from '@d8d/shared-utils/jwt.util';

describe('认证中间件集成测试', () => {
  it('应该允许有效token的请求', async () => {
    const app = new Hono();
    app.use('/api/protected/*', authMiddleware);
    app.get('/api/protected/data', (c) => c.json({ message: 'Protected data' }));

    const token = generateToken({ userId: 1, username: 'testuser' });
    const res = await integrateRoutes(app).GET('/api/protected/data', {
      headers: {
        Authorization: `Bearer ${token}`
      }
    });

    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ message: 'Protected data' });
  });

  it('应该拒绝无效token的请求', async () => {
    const app = new Hono();
    app.use('/api/protected/*', authMiddleware);
    app.get('/api/protected/data', (c) => c.json({ message: 'Protected data' }));

    const res = await integrateRoutes(app).GET('/api/protected/data', {
      headers: {
        Authorization: 'Bearer invalid-token'
      }
    });

    expect(res.status).toBe(401);
  });
});

测试数据工厂模式

factories.ts

import { User } from '../src/entities/user.entity';
import { Repository } from 'typeorm';

export function buildUser(overrides = {}): Partial<User> {
  return {
    id: Math.floor(Math.random() * 10000),
    username: `testuser_${Date.now()}`,
    email: `test_${Date.now()}@example.com`,
    password: 'hashedpassword',
    role: 'user',
    active: true,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides
  };
}

export async function createTestUser(
  userRepo: Repository<User>,
  overrides = {}
): Promise<User> {
  const userData = buildUser(overrides);
  const user = userRepo.create(userData);
  return await userRepo.save(user);
}

// 批量创建测试数据
export async function createTestUsers(
  userRepo: Repository<User>,
  count: number,
  overrides = {}
): Promise<User[]> {
  const users = [];
  for (let i = 0; i < count; i++) {
    const user = await createTestUser(userRepo, {
      username: `user${i}`,
      email: `user${i}@example.com`,
      ...overrides
    });
    users.push(user);
  }
  return users;
}

数据库测试策略

1. 使用事务回滚

describe('使用事务回滚', () => {
  let queryRunner: QueryRunner;
  let dataSource: DataSource;

  beforeEach(async () => {
    dataSource = await getTestDataSource();
    await dataSource.initialize();
    queryRunner = dataSource.createQueryRunner();
    await queryRunner.startTransaction();
  });

  afterEach(async () => {
    await queryRunner.rollbackTransaction();
    await queryRunner.release();
    await dataSource.destroy();
  });

  it('每个测试后自动回滚', async () => {
    const userRepo = queryRunner.manager.getRepository(User);
    await userRepo.save({
      username: 'testuser',
      email: 'test@example.com',
      password: 'hash'
    });

    // 数据存在于事务中
    const users = await userRepo.find();
    expect(users).toHaveLength(1);

    // 测试结束后自动回滚,数据库为空
  });
});

2. 使用测试数据库

// tests/fixtures/test-db.ts
import { DataSource } from 'typeorm';
import { User } from '../src/entities/user.entity';
import { Role } from '../src/entities/role.entity';

export async function getTestDataSource(): Promise<DataSource> {
  return new DataSource({
    type: 'postgres',
    host: 'localhost',
    port: 5432,
    username: 'postgres',
    password: 'test_password',
    database: 'test_d8dai', // 独立的测试数据库
    entities: [User, Role],
    synchronize: true, // 自动同步表结构
    dropSchema: true, // 每次测试前清空数据库
    logging: false // 测试环境关闭SQL日志
  });
}

覆盖率标准

测试类型 最低要求 目标要求 关键模块要求
单元测试 70% 80% 90%
集成测试 50% 60% 70%

关键模块定义

  • 认证授权Service:90%单元测试覆盖率
  • 数据库操作Service:85%单元测试覆盖率
  • 核心业务逻辑:80%集成测试覆盖率

运行测试

本地开发

# 运行所有测试
cd packages/user-module && pnpm test

# 只运行单元测试
pnpm test:unit

# 只运行集成测试
pnpm test:integration

# 生成覆盖率报告
pnpm test:coverage

# 运行特定测试文件
pnpm test user.service.test.ts

# 运行特定测试用例
pnpm test --testNamePattern="应该创建用户"

CI/CD

user-module-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 install
    - run: cd packages/user-module && pnpm test

all-modules-tests:
  runs-on: ubuntu-latest
  steps:
    - run: cd packages/user-module && pnpm test
    - run: cd packages/auth-module && pnpm test
    - run: cd packages/file-module && pnpm test

测试命名约定

文件命名

  • Service单元测试:[service].service.test.ts
  • Schema单元测试:[schema].schema.test.ts
  • 路由集成测试:[route].routes.integration.test.ts
  • Service集成测试:[service].service.integration.test.ts

测试描述

describe('UserService', () => {
  describe('findById()', () => {
    it('应该返回用户数据', async () => { });
    it('应该返回null当用户不存在', async () => { });
  });

  describe('createUser()', () => {
    it('应该创建新用户', async () => { });
    it('应该拒绝重复的用户名', async () => { });
  });
});

常见错误避免

❌ 不要在单元测试中使用真实数据库

// 错误:单元测试连接真实数据库
it('应该查询用户', async () => {
  const dataSource = await getTestDataSource(); // 不要在单元测试中
  // ...
});

// 正确:使用模拟的Repository
it('应该查询用户', async () => {
  const mockRepo = { findOne: vi.fn() };
  const userService = new UserService(mockRepo);
  // ...
});

❌ 不要在集成测试中mock数据库

// 错误:集成测试中mock数据库
vi.mock('typeorm', () => ({
  getRepository: vi.fn(() => mockRepo)
}));

// 正确:使用真实数据库
const dataSource = await getTestDataSource();
const userRepo = dataSource.getRepository(User);

❌ 不要共享测试数据

// 错误:测试间共享数据
let testUserId: number;

it('应该创建用户', async () => {
  const user = await createUser({ username: 'test' });
  testUserId = user.id; // 污染下一个测试
});

it('应该更新用户', async () => {
  await updateUser(testUserId, { username: 'updated' }); // 依赖上一个测试
});

// 正确:每个测试独立创建数据
it('应该更新用户', async () => {
  const user = await createTestUser(dataSource);
  await updateUser(user.id, { username: 'updated' });
});

调试技巧

1. 查看SQL查询

const dataSource = new DataSource({
  // ...
  logging: true, // 显示所有SQL
  maxQueryExecutionTime: 100 // 记录慢查询
});

2. 打印测试数据

it('调试测试', async () => {
  const users = await userService.findAll();
  console.debug('用户列表:', users);
  console.debug('用户数量:', users.length);
});

3. 使用only运行特定测试

it.only('只运行这个测试', async () => {
  // ...
});

参考实现

  • 用户模块包:packages/user-module/tests/
  • 认证模块包:packages/auth-module/tests/
  • 文件模块包:packages/file-module/tests/

相关文档


文档状态: 正式版 适用范围: packages/*-module