mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
* feat: Redesign doc * chore: uopdate site * chore: uopdate site * chore: uopdate site * chore: uopdate site * chore: uopdate site * feat: Uopdate content * chore: New doc * chore: Update content * chore: Update content * chore: add images * chore: add images * chore: add images * chore: add images * feat: Add more images * feat: Add more images * fix: Cannot reach end * chore: Update content * chore: Update content * chore: Update content * chore: Update content * chore: Update content * Revise README content and structure Updated README to reflect changes in project description and removed outdated notes. * Revise 'Getting Started' and TOC in README Updated the 'Getting Started' section and modified the table of contents. * chore: Update content * Revise README structure and content Updated the Getting Started section and removed the Table of Contents. Adjusted the Local Development instructions. * Remove custom themes section from README Removed section about custom themes from README. * Update README.md * Refine introduction and highlight cloud version Updated wording for clarity and added recommendation for cloud version. * chore: Update content * chore: Update content * chore: Update content * chore: Update content * chore: Update content * chore: Update content * chore: Update content * fix: add missing translation * 🔀 chore: Move README changes to feat/readme branch Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add missing translation * chore: update cdn * docs: add migration guide from v1.x local database to v2.x and update help sections Signed-off-by: Innei <tukon479@gmail.com> * fix: add missing translation * fix: add missing images * fix: add missing changelogs * fix: add missing changelogs * fix: add missing changelogs * fix: add missing changelogs * fix: add missing changelogs * style: update cdn --------- Signed-off-by: Innei <tukon479@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: canisminor1990 <i@canisminor.cc> Co-authored-by: Innei <tukon479@gmail.com>
410 lines
10 KiB
Plaintext
410 lines
10 KiB
Plaintext
---
|
||
title: 集成测试指南
|
||
description: 了解集成测试的重要性及最佳实践,确保系统模块协同工作。
|
||
tags:
|
||
- 集成测试
|
||
- 测试指南
|
||
- 软件测试
|
||
- 模块协作
|
||
---
|
||
|
||
# 集成测试指南
|
||
|
||
## 概述
|
||
|
||
集成测试验证多个模块协同工作的正确性,确保完整的调用链路(Router → Service → Model → Database)正常运行。
|
||
|
||
## 为什么需要集成测试?
|
||
|
||
即使单元测试覆盖率很高(80%+),仍可能出现集成问题:
|
||
|
||
### 常见问题示例
|
||
|
||
```typescript
|
||
// ❌ 问题:参数在调用链中丢失
|
||
// Router 层
|
||
const messageId = await messageModel.create({
|
||
content: 'test',
|
||
sessionId: 'xxx',
|
||
topicId: 'yyy', // ← 传入了 topicId
|
||
});
|
||
|
||
// Model 层(假设实现有问题)
|
||
async create(data) {
|
||
return this.db.insert(messages).values({
|
||
content: data.content,
|
||
sessionId: data.sessionId,
|
||
// ❌ 忘记传递 topicId
|
||
});
|
||
}
|
||
|
||
// 结果:单元测试通过(因为 mock 了 Model),但实际运行时 topicId 丢失
|
||
```
|
||
|
||
### 集成测试能发现的问题
|
||
|
||
1. **参数传递遗漏**: containerId、threadId、topicId 等在调用链中丢失
|
||
2. **数据库约束**: 外键关系、级联删除等在 mock 中无法验证
|
||
3. **事务完整性**: 跨表操作的原子性
|
||
4. **权限验证**: 跨用户访问控制
|
||
5. **真实场景**: 模拟用户的完整操作流程
|
||
|
||
## 运行集成测试
|
||
|
||
```bash
|
||
# 运行所有集成测试
|
||
pnpm test:integration
|
||
|
||
# 运行特定文件
|
||
pnpm vitest tests/integration/routers/message.integration.test.ts
|
||
|
||
# 监听模式
|
||
pnpm vitest tests/integration --watch
|
||
|
||
# 生成覆盖率报告
|
||
pnpm test:integration --coverage
|
||
```
|
||
|
||
## 目录结构
|
||
|
||
```
|
||
tests/integration/
|
||
├── README.md # 集成测试说明
|
||
├── setup.ts # 通用设置和工具函数
|
||
└── routers/ # Router 层集成测试
|
||
├── message.integration.test.ts # Message Router 测试
|
||
├── session.integration.test.ts # Session Router 测试
|
||
├── topic.integration.test.ts # Topic Router 测试
|
||
└── chat-flow.integration.test.ts # 完整聊天流程测试
|
||
```
|
||
|
||
## 编写集成测试
|
||
|
||
### 基本模板
|
||
|
||
```typescript
|
||
// @vitest-environment node
|
||
import { eq } from 'drizzle-orm';
|
||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||
|
||
import { getTestDB } from '@/database/models/__tests__/_util';
|
||
import { messages, sessions, users } from '@/database/schemas';
|
||
import { LobeHubDatabase } from '@/database/type';
|
||
import { messageRouter } from '@/server/routers/lambda/message';
|
||
|
||
import { cleanupTestUser, createTestContext, createTestUser } from '../setup';
|
||
|
||
describe('Your Feature Integration Tests', () => {
|
||
let serverDB: LobeHubDatabase;
|
||
let userId: string;
|
||
|
||
beforeEach(async () => {
|
||
// 1. 获取测试数据库
|
||
serverDB = await getTestDB();
|
||
|
||
// 2. 创建测试用户
|
||
userId = await createTestUser(serverDB);
|
||
|
||
// 3. 准备其他测试数据
|
||
// ...
|
||
});
|
||
|
||
afterEach(async () => {
|
||
// 清理测试数据
|
||
await cleanupTestUser(serverDB, userId);
|
||
});
|
||
|
||
it('should do something', async () => {
|
||
// 1. 创建 tRPC caller
|
||
const caller = messageRouter.createCaller(createTestContext(userId));
|
||
|
||
// 2. 执行操作
|
||
const result = await caller.someMethod({
|
||
/* params */
|
||
});
|
||
|
||
// 3. 验证结果
|
||
expect(result).toBeDefined();
|
||
|
||
// 4. 🔥 关键:从数据库验证
|
||
const [dbRecord] = await serverDB.select().from(messages).where(eq(messages.id, result));
|
||
|
||
expect(dbRecord).toMatchObject({
|
||
// 验证所有关键字段
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### 最佳实践
|
||
|
||
#### 1. 测试完整的调用链路
|
||
|
||
```typescript
|
||
it('should create message with correct associations', async () => {
|
||
const caller = messageRouter.createCaller(createTestContext(userId));
|
||
|
||
// 执行操作
|
||
const messageId = await caller.createMessage({
|
||
content: 'Test',
|
||
sessionId: testSessionId,
|
||
topicId: testTopicId,
|
||
});
|
||
|
||
// ✅ 从数据库验证,而不是只验证返回值
|
||
const [message] = await serverDB.select().from(messages).where(eq(messages.id, messageId));
|
||
|
||
expect(message.sessionId).toBe(testSessionId);
|
||
expect(message.topicId).toBe(testTopicId);
|
||
expect(message.userId).toBe(userId);
|
||
});
|
||
```
|
||
|
||
#### 2. 测试级联操作
|
||
|
||
```typescript
|
||
it('should cascade delete messages when session is deleted', async () => {
|
||
const sessionCaller = sessionRouter.createCaller(createTestContext(userId));
|
||
const messageCaller = messageRouter.createCaller(createTestContext(userId));
|
||
|
||
// 创建 session 和 messages
|
||
const sessionId = await sessionCaller.createSession({
|
||
/* ... */
|
||
});
|
||
await messageCaller.createMessage({ sessionId /* ... */ });
|
||
|
||
// 删除 session
|
||
await sessionCaller.removeSession({ id: sessionId });
|
||
|
||
// ✅ 验证相关消息也被删除
|
||
const remainingMessages = await serverDB
|
||
.select()
|
||
.from(messages)
|
||
.where(eq(messages.sessionId, sessionId));
|
||
|
||
expect(remainingMessages).toHaveLength(0);
|
||
});
|
||
```
|
||
|
||
#### 3. 测试跨 Router 协作
|
||
|
||
```typescript
|
||
it('should handle complete chat flow', async () => {
|
||
const sessionCaller = sessionRouter.createCaller(createTestContext(userId));
|
||
const topicCaller = topicRouter.createCaller(createTestContext(userId));
|
||
const messageCaller = messageRouter.createCaller(createTestContext(userId));
|
||
|
||
// 1. 创建 session
|
||
const sessionId = await sessionCaller.createSession({
|
||
/* ... */
|
||
});
|
||
|
||
// 2. 创建 topic
|
||
const topicId = await topicCaller.createTopic({ sessionId /* ... */ });
|
||
|
||
// 3. 创建 message
|
||
const messageId = await messageCaller.createMessage({
|
||
sessionId,
|
||
topicId,
|
||
/* ... */
|
||
});
|
||
|
||
// ✅ 验证完整的关联关系
|
||
const [message] = await serverDB.select().from(messages).where(eq(messages.id, messageId));
|
||
|
||
expect(message.sessionId).toBe(sessionId);
|
||
expect(message.topicId).toBe(topicId);
|
||
});
|
||
```
|
||
|
||
#### 4. 测试错误场景
|
||
|
||
```typescript
|
||
it('should prevent cross-user access', async () => {
|
||
// 用户 A 创建 session
|
||
const sessionId = await sessionRouter.createCaller(createTestContext(userA)).createSession({
|
||
/* ... */
|
||
});
|
||
|
||
// 用户 B 尝试访问
|
||
const callerB = messageRouter.createCaller(createTestContext(userB));
|
||
|
||
// ✅ 应该抛出错误
|
||
await expect(
|
||
callerB.createMessage({
|
||
sessionId,
|
||
content: 'Unauthorized',
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
```
|
||
|
||
#### 5. 测试并发场景
|
||
|
||
```typescript
|
||
it('should handle concurrent operations', async () => {
|
||
const caller = messageRouter.createCaller(createTestContext(userId));
|
||
|
||
// 并发创建多个消息
|
||
const promises = Array.from({ length: 10 }, (_, i) =>
|
||
caller.createMessage({
|
||
content: `Message ${i}`,
|
||
sessionId: testSessionId,
|
||
}),
|
||
);
|
||
|
||
const messageIds = await Promise.all(promises);
|
||
|
||
// ✅ 验证所有消息都创建成功且唯一
|
||
expect(messageIds).toHaveLength(10);
|
||
expect(new Set(messageIds).size).toBe(10);
|
||
});
|
||
```
|
||
|
||
### 数据隔离
|
||
|
||
每个测试用例应该独立,不依赖其他测试:
|
||
|
||
```typescript
|
||
beforeEach(async () => {
|
||
// 为每个测试创建新的数据
|
||
userId = await createTestUser(serverDB);
|
||
testSessionId = await createTestSession(serverDB, userId);
|
||
});
|
||
|
||
afterEach(async () => {
|
||
// 清理测试数据
|
||
await cleanupTestUser(serverDB, userId);
|
||
});
|
||
```
|
||
|
||
### 测试命名
|
||
|
||
使用清晰的命名描述测试意图:
|
||
|
||
```typescript
|
||
// ✅ 好的命名
|
||
it('should create message with correct sessionId and topicId');
|
||
it('should cascade delete messages when session is deleted');
|
||
it('should prevent cross-user access to messages');
|
||
|
||
// ❌ 不好的命名
|
||
it('test message creation');
|
||
it('test delete');
|
||
```
|
||
|
||
## 与单元测试的区别
|
||
|
||
| 维度 | 单元测试 | 集成测试 |
|
||
| ------- | --------- | ------- |
|
||
| **范围** | 单个函数 / 类 | 多个模块协作 |
|
||
| **依赖** | Mock 外部依赖 | 使用真实依赖 |
|
||
| **数据库** | Mock | 真实测试数据库 |
|
||
| **速度** | 快(毫秒级) | 慢(秒级) |
|
||
| **数量** | 多(60%) | 少(30%) |
|
||
| **目的** | 验证逻辑正确性 | 验证集成正确性 |
|
||
|
||
## 测试金字塔
|
||
|
||
```
|
||
/\
|
||
/E2E\ ← 10% (关键业务流程)
|
||
/------\
|
||
/ 集成 \ ← 30% (API 集成测试) ⭐ 本指南重点
|
||
/----------\
|
||
/ 单元测试 \ ← 60% (已有 80%+)
|
||
/--------------\
|
||
```
|
||
|
||
## 覆盖目标
|
||
|
||
### 优先级 P0(必须覆盖)
|
||
|
||
- ✅ 跨层级的 ID 传递(sessionId、topicId、containerId、threadId)
|
||
- ✅ 权限验证(用户只能访问自己的资源)
|
||
- ✅ 级联删除(删除 session 时相关数据也删除)
|
||
- ✅ 外键约束(不能创建不存在的关联)
|
||
|
||
### 优先级 P1(应该覆盖)
|
||
|
||
- 并发场景(多个请求同时操作)
|
||
- 分页查询(正确的数据分页)
|
||
- 搜索功能(关键词搜索)
|
||
- 批量操作(批量创建 / 删除)
|
||
|
||
### 优先级 P2(可以覆盖)
|
||
|
||
- 统计功能(计数、排名)
|
||
- 复杂查询(多条件筛选)
|
||
- 性能测试(大量数据场景)
|
||
|
||
## 调试技巧
|
||
|
||
### 1. 查看测试数据库状态
|
||
|
||
```typescript
|
||
it('debug test', async () => {
|
||
// 执行操作
|
||
await caller.createMessage({
|
||
/* ... */
|
||
});
|
||
|
||
// 打印数据库状态
|
||
const allMessages = await serverDB.select().from(messages);
|
||
console.log('All messages:', allMessages);
|
||
});
|
||
```
|
||
|
||
### 2. 使用 Drizzle Studio
|
||
|
||
```bash
|
||
# 启动 Drizzle Studio 查看测试数据库
|
||
pnpm db:studio
|
||
```
|
||
|
||
### 3. 保留测试数据
|
||
|
||
```typescript
|
||
afterEach(async () => {
|
||
// 临时注释掉清理代码,保留数据用于调试
|
||
// await cleanupTestUser(serverDB, userId);
|
||
});
|
||
```
|
||
|
||
## 常见问题
|
||
|
||
### Q: 集成测试很慢怎么办?
|
||
|
||
A:
|
||
|
||
1. 只测试关键路径,不要过度测试
|
||
2. 使用 `test.concurrent` 并行执行独立的测试
|
||
3. 优化测试数据准备,避免重复创建
|
||
|
||
### Q: 测试之间相互影响怎么办?
|
||
|
||
A:
|
||
|
||
1. 确保每个测试使用独立的 userId
|
||
2. 在 `afterEach` 中彻底清理数据
|
||
3. 使用事务隔离(如果数据库支持)
|
||
|
||
### Q: 如何测试需要认证的 API?
|
||
|
||
A: 使用 `createTestContext(userId)` 创建带认证信息的上下文:
|
||
|
||
```typescript
|
||
const caller = messageRouter.createCaller(createTestContext(userId));
|
||
```
|
||
|
||
## 参考资料
|
||
|
||
- [Vitest 文档](https://vitest.dev/)
|
||
- [Drizzle ORM 文档](https://orm.drizzle.team/)
|
||
- [tRPC 测试指南](https://trpc.io/docs/server/testing)
|
||
- [测试金字塔](https://martinfowler.com/articles/practical-test-pyramid.html)
|
||
|
||
## 贡献
|
||
|
||
欢迎补充更多集成测试用例!请参考现有测试文件的风格。
|