---
tags: [go, testing, integration-test, project-structure, engineering]
create time: 2026-04-29 15:45
---
# 集成测试目录结构
## 概述
梳理 Go 项目中测试目录的组织方式,聚焦集成测试的定位、目录划分和最佳实践。明确单测与集成测试的边界,帮助团队在工程规模增长时保持测试可维护性。
## 为什么需要独立测试目录?
> [!question] 思考:单测不够吗?为什么还要单独建 tests/?
Go 的社区惯例是**单测和源码放一起**(`xxx_test.go`),这已经覆盖了绝大多数场景。但当测试需要真实基础设施(数据库、Redis、HTTP Server)时,就需要引入独立的集成测试目录。
| 维度 | 包内单测 | `tests/` 集成测试 |
|------|---------|-------------------|
| **视角** | 白盒——能看到 unexported | 黑盒——只调公开 API |
| **速度** | 快(纯内存操作) | 慢(需启动依赖) |
| **职责** | 验证函数行为 | 验证组件协作 |
| **CI 触发** | 每次 commit 必跑 | 可单独控制,按需跑 |
## tests/ 的典型结构
```
tests/
├── integration/ # 集成测试:组件之间正确协作
│ ├── user_test.go # 用户注册全流程(handler → service → repo → DB)
│ └── order_test.go # 下单流程(含事务、并发安全校验)
├── e2e/ # 端到端测试:整个服务对外可见的行为
│ └── api_smoke_test.go # 启动 server,发 HTTP 请求做冒烟测试
├── fixtures/ # 测试数据fixtures
│ ├── users.json # 预置数据
│ └── orders.json
└── testutil/ # 集成测试专用辅助包
└── db.go # TestDB() 封装:创建测试库 + 自动清理
```
### 各子目录的职责
```mermaid
flowchart TD
A["tests/"] --> B["integration/ — 集成测试
组件协作 + 真实依赖"]
A --> C["e2e/ — 端到端测试
完整服务 + HTTP 调用"]
A --> D["fixtures/ — 测试数据"]
A --> E["testutil/ — 基础设施辅助"]
style B fill:#bfb,stroke:#333,stroke-width:3px
style C fill:#fbf,stroke:#333
style D fill:#fff,stroke:#333
style E fill:#fff,stroke:#333
```
> [!tip] 何时需要哪个?
>
> - **只有 unit test**:项目初期、CRUD 为主 → 不需要 `tests/`,全放包内
> - **加了外部依赖后**:有数据库/缓存/消息队列 → 引入 `tests/integration/`
> - **上线前质量保障**:微服务、对外 API → 增加 `tests/e2e/`
## 集成测试的最佳实践
### 1. 测试数据隔离
每个测试用例应使用独立的数据命名空间,避免相互污染:
```go
func TestCreateOrder(t *testing.T) {
// 为当前测试准备干净的数据库状态
db := testutil.NewTestDB(t)
defer db.Cleanup() // 事务回滚或 truncate
svc := service.NewOrderService(db.Conn())
res, err := svc.Create(ctx, payload)
assert.NoError(t, err)
// 断言基于干净状态的预期结果
}
```
> [!info] 两种隔离策略
| 策略 | 做法 | 适用场景 |
|------|------|----------|
| **事务回滚** | 每件事务包裹在 BEGIN/ROLLBACK 中 | PostgreSQL/MySQL,速度快 |
| **临时 Schema** | 每个 test 创建独立 schema | 需要测试迁移、DDL 等场景 |
### 2. 避免硬编码端口
集成测试需要启动服务,端口冲突是 CI 最常见的失败原因:
```go
func TestAPIIntegration(t *testing.T) {
// ✅ 动态分配可用端口
l, err := net.Listen("tcp", "127.0.0.1:0")
addr := l.Addr().String()
l.Close()
srv := &http.Server{Addr: addr, Handler: router}
// ...
}
```
> [!warning] 常见坑
>
> - 不要写死 `8080`——本地开发可能已经在跑别的服务
> - `net.Listener` 用 `"127.0.0.1:0"` 让内核分配空闲端口是最稳妥的方式
### 3. CI 中的分级执行
```yaml
# .github/workflows/test.yml 示例思路
- run: go test ./... # 所有单测(每次 commit)
- run: go test ./tests/integration/... # 集成测试(push to main 或手动触发)
- run: go test ./tests/e2e/... # E2E(release 前)
```
> [!summary] 分级执行的理由
>
> 集成测试和 E2E 跑起来可能几分钟甚至更久,混在常规 CI 里会拖慢开发节奏。按阶段分级,既能保证质量又不牺牲效率。
## 与内部包的配合
集成测试虽然放在项目根级的 `tests/` 下,但它访问的是你 `internal/` 里的代码。这里有个重要细节:
```
my-project/
├── internal/user/service/
│ └── service.go # package service — 对外不可见
├── tests/integration/
│ └── user_test.go # import "my-project/internal/user/service" — 合法!
```
> [!tip] 关键点
>
> - `internal/` 的限制是**对其它 module 而言**的,同一 module 内的任何位置都可以 import
> - `tests/` 和 `internal/` 属于同一个 module,所以可以毫无阻碍地访问私有包
> - 这意味着集成测试其实是**半白盒**的——你能看到 internal 但看不到第三方 module 的代码
## 关联笔记
- [[Go 工程模块化]]