1803 lines
38 KiB
Markdown
1803 lines
38 KiB
Markdown
# 📚 API 与测试文档
|
||
|
||
> **💡 推荐使用 OpenAPI 规范**
|
||
>
|
||
> 本项目现在提供标准的 **OpenAPI 3.0 规范**!
|
||
>
|
||
> - 📖 **交互式 API 文档**: [http://localhost:8080/api/docs](http://localhost:8080/api/docs) (Swagger UI)
|
||
> - 📄 **OpenAPI 规范文件**: [openapi.yaml](./openapi.yaml)
|
||
> - 🔧 **在线测试**: 在 Swagger UI 中直接测试所有 API
|
||
> - 🚀 **客户端生成**: 使用 OpenAPI 规范自动生成客户端代码
|
||
>
|
||
> **本文档 (Markdown 版本)** 作为参考文档保留,内容与 OpenAPI 规范保持一致。
|
||
> 如需最新的 API 定义,请参考 [openapi.yaml](./openapi.yaml)。
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
### Part 1: API 文档
|
||
- [基础信息](#基础信息)
|
||
- [认证 API](#认证-api)
|
||
- [集群管理 API](#集群管理-api)
|
||
- [Registry 管理 API](#registry-管理-api)
|
||
- [Artifact 浏览 API](#artifact-浏览-api)
|
||
- [实例管理 API](#实例管理-api)
|
||
- [监控 API](#监控-api)
|
||
- [响应格式](#响应格式)
|
||
- [错误处理](#错误处理)
|
||
|
||
### Part 2: 测试文档
|
||
- [测试策略](#测试策略)
|
||
- [单元测试](#单元测试)
|
||
- [集成测试](#集成测试)
|
||
- [API 测试](#api-测试)
|
||
- [E2E 测试](#e2e-测试)
|
||
- [Mock 测试](#mock-测试)
|
||
- [测试工具](#测试工具)
|
||
- [测试最佳实践](#测试最佳实践)
|
||
|
||
---
|
||
|
||
# Part 1: API 文档
|
||
|
||
## 基础信息
|
||
|
||
### Base URL
|
||
|
||
```
|
||
http://localhost:8080/api/v1
|
||
```
|
||
|
||
### 健康检查
|
||
|
||
```bash
|
||
GET /health
|
||
|
||
# 响应
|
||
{
|
||
"status": "healthy"
|
||
}
|
||
```
|
||
|
||
### 通用请求头
|
||
|
||
```
|
||
Content-Type: application/json
|
||
Authorization: Bearer <JWT_TOKEN> # 部分接口需要
|
||
```
|
||
|
||
---
|
||
|
||
## 认证 API
|
||
|
||
### 用户注册
|
||
|
||
```bash
|
||
POST /api/v1/auth/register
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"username": "john",
|
||
"password": "secret123",
|
||
"email": "john@example.com"
|
||
}
|
||
|
||
# 响应 201
|
||
{
|
||
"id": "user-123",
|
||
"username": "john",
|
||
"email": "john@example.com",
|
||
"createdAt": "2025-11-09T10:00:00Z"
|
||
}
|
||
```
|
||
|
||
### 用户登录
|
||
|
||
```bash
|
||
POST /api/v1/auth/login
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"username": "john",
|
||
"password": "secret123"
|
||
}
|
||
|
||
# 响应 200
|
||
{
|
||
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
|
||
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
|
||
"userId": "user-123",
|
||
"username": "john"
|
||
}
|
||
```
|
||
|
||
### 刷新 Token
|
||
|
||
```bash
|
||
POST /api/v1/auth/refresh
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
|
||
}
|
||
|
||
# 响应 200
|
||
{
|
||
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
|
||
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
|
||
"userId": "user-123",
|
||
"username": "john"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 集群管理 API
|
||
|
||
### 创建集群
|
||
|
||
```bash
|
||
POST /api/v1/clusters
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"name": "Production Cluster",
|
||
"host": "https://k8s.example.com:6443",
|
||
"description": "生产环境集群",
|
||
"caData": "LS0tLS1CRUdJTi0...", # Base64 编码的 CA 证书
|
||
"certData": "LS0tLS1CRUdJTi0...", # Base64 编码的客户端证书
|
||
"keyData": "LS0tLS1CRUdJTi0..." # Base64 编码的客户端密钥
|
||
}
|
||
|
||
# 响应 201
|
||
{
|
||
"id": "cluster-abc123",
|
||
"name": "Production Cluster",
|
||
"host": "https://k8s.example.com:6443",
|
||
"description": "生产环境集群",
|
||
"status": "healthy",
|
||
"createdAt": "2025-11-09T10:00:00Z",
|
||
"updatedAt": "2025-11-09T10:00:00Z"
|
||
}
|
||
```
|
||
|
||
### 列出所有集群
|
||
|
||
```bash
|
||
GET /api/v1/clusters
|
||
|
||
# 响应 200
|
||
[
|
||
{
|
||
"id": "cluster-abc123",
|
||
"name": "Production Cluster",
|
||
"host": "https://k8s.example.com:6443",
|
||
"description": "生产环境集群",
|
||
"status": "healthy",
|
||
"createdAt": "2025-11-09T10:00:00Z"
|
||
}
|
||
]
|
||
```
|
||
|
||
### 获取集群详情
|
||
|
||
```bash
|
||
GET /api/v1/clusters/{clusterId}
|
||
|
||
# 响应 200
|
||
{
|
||
"id": "cluster-abc123",
|
||
"name": "Production Cluster",
|
||
"host": "https://k8s.example.com:6443",
|
||
"description": "生产环境集群",
|
||
"status": "healthy",
|
||
"version": "v1.28.0",
|
||
"nodeCount": 5,
|
||
"createdAt": "2025-11-09T10:00:00Z",
|
||
"updatedAt": "2025-11-09T10:00:00Z"
|
||
}
|
||
```
|
||
|
||
### 更新集群
|
||
|
||
```bash
|
||
PUT /api/v1/clusters/{clusterId}
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"name": "Production Cluster (Updated)",
|
||
"description": "更新后的描述"
|
||
}
|
||
|
||
# 响应 200
|
||
{
|
||
"id": "cluster-abc123",
|
||
"name": "Production Cluster (Updated)",
|
||
"description": "更新后的描述",
|
||
"updatedAt": "2025-11-09T11:00:00Z"
|
||
}
|
||
```
|
||
|
||
### 删除集群
|
||
|
||
```bash
|
||
DELETE /api/v1/clusters/{clusterId}
|
||
|
||
# 响应 204 No Content
|
||
```
|
||
|
||
### 集群健康检查
|
||
|
||
```bash
|
||
GET /api/v1/clusters/{clusterId}/health
|
||
|
||
# 响应 200
|
||
{
|
||
"clusterId": "cluster-abc123",
|
||
"status": "healthy",
|
||
"version": "v1.28.0",
|
||
"nodeCount": 5,
|
||
"readyNodes": 5,
|
||
"cpuCapacity": "40 cores",
|
||
"memoryCapacity": "160Gi",
|
||
"checkedAt": "2025-11-09T12:00:00Z"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Registry 管理 API
|
||
|
||
> **OCI 标准**: 所有 Registry 都遵循 OCI (Open Container Initiative) 标准,支持 Harbor, Docker Hub, GHCR, Nexus, 以及任何兼容 OCI Distribution Spec 的 Registry。
|
||
|
||
### 创建 Registry
|
||
|
||
```bash
|
||
POST /api/v1/registries
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"name": "Harbor Production",
|
||
"url": "https://harbor.example.com",
|
||
"description": "生产环境 Harbor 仓库",
|
||
"username": "admin",
|
||
"password": "secret",
|
||
"insecure": false
|
||
}
|
||
|
||
# 响应 201
|
||
{
|
||
"id": "registry-123",
|
||
"name": "Harbor Production",
|
||
"url": "https://harbor.example.com",
|
||
"description": "生产环境 Harbor 仓库",
|
||
"username": "admin",
|
||
"insecure": false,
|
||
"createdAt": "2025-11-09T10:00:00Z",
|
||
"updatedAt": "2025-11-09T10:00:00Z"
|
||
}
|
||
```
|
||
|
||
**字段说明**:
|
||
- `url`: Registry URL,所有 Registry 都使用 OCI Distribution API
|
||
- `username/password`: 可选,用于私有 Registry 认证
|
||
- `insecure`: 是否跳过 TLS 验证(开发环境可用)
|
||
|
||
> **注意**: 密码不会在响应中返回,存储时会自动加密。
|
||
|
||
### 列出所有 Registries
|
||
|
||
```bash
|
||
GET /api/v1/registries
|
||
|
||
# 响应 200
|
||
[
|
||
{
|
||
"id": "registry-123",
|
||
"name": "Harbor Production",
|
||
"url": "https://harbor.example.com",
|
||
"description": "生产环境 Harbor 仓库",
|
||
"username": "admin",
|
||
"insecure": false,
|
||
"createdAt": "2025-11-09T10:00:00Z"
|
||
}
|
||
]
|
||
```
|
||
|
||
### 获取 Registry 详情
|
||
|
||
```bash
|
||
GET /api/v1/registries/{registryId}
|
||
|
||
# 响应 200
|
||
{
|
||
"id": "registry-123",
|
||
"name": "Harbor Production",
|
||
"url": "https://harbor.example.com",
|
||
"description": "生产环境 Harbor 仓库",
|
||
"username": "admin",
|
||
"insecure": false,
|
||
"createdAt": "2025-11-09T10:00:00Z",
|
||
"updatedAt": "2025-11-09T10:00:00Z"
|
||
}
|
||
```
|
||
|
||
### 更新 Registry
|
||
|
||
```bash
|
||
PUT /api/v1/registries/{registryId}
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"name": "Harbor Production (Updated)",
|
||
"url": "https://new-harbor.example.com",
|
||
"password": "new-secret" # 可选,只在需要更新密码时提供
|
||
}
|
||
|
||
# 响应 200
|
||
{
|
||
"id": "registry-123",
|
||
"name": "Harbor Production (Updated)",
|
||
"url": "https://new-harbor.example.com",
|
||
"updatedAt": "2025-11-09T11:00:00Z"
|
||
}
|
||
```
|
||
|
||
### 删除 Registry
|
||
|
||
```bash
|
||
DELETE /api/v1/registries/{registryId}
|
||
|
||
# 响应 204 No Content
|
||
```
|
||
|
||
### Registry 健康检查
|
||
|
||
```bash
|
||
GET /api/v1/registries/{registryId}/health
|
||
|
||
# 响应 200
|
||
{
|
||
"registryId": "registry-123",
|
||
"status": "healthy",
|
||
"url": "https://harbor.example.com",
|
||
"reachable": true,
|
||
"authenticated": true,
|
||
"responseTime": 125, # 毫秒
|
||
"checkedAt": "2025-11-09T12:00:00Z"
|
||
}
|
||
|
||
# 响应 503 (不健康)
|
||
{
|
||
"registryId": "registry-123",
|
||
"status": "unhealthy",
|
||
"url": "https://harbor.example.com",
|
||
"reachable": false,
|
||
"error": "connection timeout",
|
||
"checkedAt": "2025-11-09T12:00:00Z"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Artifact 浏览 API
|
||
|
||
### 列出 Repositories
|
||
|
||
```bash
|
||
GET /api/v1/registries/{registryId}/repositories
|
||
|
||
# 响应 200
|
||
{
|
||
"registryId": "registry-123",
|
||
"registryUrl": "https://harbor.example.com",
|
||
"repositories": [
|
||
"charts/nginx",
|
||
"charts/redis",
|
||
"charts/vllm-serve",
|
||
"library/alpine"
|
||
],
|
||
"total": 4,
|
||
"catalogSupported": true,
|
||
"source": "catalog"
|
||
}
|
||
```
|
||
|
||
> **注意**: 需要 Registry 支持 `_catalog` API(OCI Distribution Spec)。
|
||
|
||
### 列出 Artifacts
|
||
|
||
```bash
|
||
GET /api/v1/registries/{registryId}/repositories/{repository_name}/artifacts
|
||
|
||
# 示例(需要 URL 编码)
|
||
GET /api/v1/registries/registry-123/repositories/charts%2Fnginx/artifacts
|
||
|
||
# 响应 200
|
||
{
|
||
"repositoryName": "charts/nginx",
|
||
"tags": [
|
||
{
|
||
"name": "1.0.0",
|
||
"digest": "sha256:abc123def456...",
|
||
"type": "chart",
|
||
"size": 12345678,
|
||
"createdAt": "2025-11-01T10:00:00Z"
|
||
}
|
||
],
|
||
"total": 1
|
||
}
|
||
```
|
||
|
||
**Artifact 类型识别**:
|
||
- `chart`: Helm Chart
|
||
- `image`: Docker Image / OCI Image
|
||
- `other`: 其他类型
|
||
|
||
### 获取 Artifact 详情
|
||
|
||
```bash
|
||
GET /api/v1/registries/{registryId}/repositories/{repository_name}/artifacts/{reference}
|
||
|
||
# reference 可以是 tag 或 digest
|
||
GET /api/v1/registries/registry-123/repositories/charts%2Fnginx/artifacts/1.0.0
|
||
|
||
# 响应 200
|
||
{
|
||
"repositoryName": "charts/nginx",
|
||
"tag": "1.0.0",
|
||
"digest": "sha256:abc123def456...",
|
||
"type": "chart",
|
||
"size": 12345678,
|
||
"createdAt": "2025-11-01T10:00:00Z"
|
||
}
|
||
```
|
||
|
||
**字段说明**:
|
||
- `repositoryName`: 仓库名称
|
||
- `tag`: 标签名称(如果使用 tag 引用)
|
||
- `digest`: SHA256 摘要
|
||
- `type`: 制品类型,从 mediaType 自动识别:
|
||
- `chart`: Helm Chart(mediaType 包含 `helm.config` 或 `helm.chart`)
|
||
- `image`: Docker/OCI Image(mediaType 包含 `docker.container.image` 或 `oci.image`)
|
||
- `other`: 其他类型
|
||
- `size`: 总大小(字节)
|
||
- `createdAt`: 创建时间(ISO 8601 格式)
|
||
|
||
### 获取 Helm Chart Values Schema
|
||
|
||
```bash
|
||
GET /api/v1/registries/{registryId}/repositories/{repository_name}/artifacts/{reference}/values-schema
|
||
|
||
# 仅支持 Helm Chart 类型
|
||
GET /api/v1/registries/registry-123/repositories/charts%2Fnginx/artifacts/1.0.0/values-schema
|
||
|
||
# 响应 200
|
||
{
|
||
"repositoryName": "charts/nginx",
|
||
"tag": "1.0.0",
|
||
"valuesSchema": {
|
||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||
"type": "object",
|
||
"properties": {
|
||
"replicaCount": {
|
||
"type": "integer",
|
||
"default": 1,
|
||
"description": "Number of replicas"
|
||
},
|
||
"image": {
|
||
"type": "object",
|
||
"properties": {
|
||
"repository": {
|
||
"type": "string",
|
||
"default": "nginx"
|
||
},
|
||
"tag": {
|
||
"type": "string",
|
||
"default": "latest"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> **URL 编码提示**: Repository 名称包含斜杠时(如 `charts/nginx`),需要编码为 `charts%2Fnginx`。
|
||
|
||
---
|
||
|
||
## 实例管理 API
|
||
|
||
### 安装应用
|
||
|
||
```bash
|
||
POST /api/v1/clusters/{clusterId}/instances
|
||
Content-Type: application/json
|
||
|
||
# 方式 1: 使用 JSON values
|
||
{
|
||
"name": "my-nginx",
|
||
"namespace": "default",
|
||
"registryId": "registry-123",
|
||
"repository": "charts/nginx",
|
||
"chart": "nginx",
|
||
"version": "1.0.0",
|
||
"description": "My NGINX deployment",
|
||
"values": {
|
||
"replicaCount": 2,
|
||
"image": {
|
||
"tag": "1.21.0"
|
||
}
|
||
}
|
||
}
|
||
|
||
# 响应 201
|
||
{
|
||
"id": "instance-xyz789",
|
||
"name": "my-nginx",
|
||
"namespace": "default",
|
||
"clusterId": "cluster-abc123",
|
||
"registryId": "registry-123",
|
||
"chart": "nginx",
|
||
"version": "1.0.0",
|
||
"status": "deployed",
|
||
"revision": 1,
|
||
"description": "My NGINX deployment",
|
||
"createdAt": "2025-11-09T10:00:00Z",
|
||
"updatedAt": "2025-11-09T10:00:00Z"
|
||
}
|
||
```
|
||
|
||
### 列出应用实例
|
||
|
||
```bash
|
||
GET /api/v1/clusters/{clusterId}/instances
|
||
|
||
# 响应 200
|
||
[
|
||
{
|
||
"id": "instance-xyz789",
|
||
"name": "my-nginx",
|
||
"namespace": "default",
|
||
"chart": "nginx",
|
||
"version": "1.0.0",
|
||
"status": "deployed",
|
||
"revision": 1,
|
||
"createdAt": "2025-11-09T10:00:00Z"
|
||
}
|
||
]
|
||
```
|
||
|
||
### 升级应用
|
||
|
||
```bash
|
||
PUT /api/v1/clusters/{clusterId}/instances/{instanceId}
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"version": "1.1.0",
|
||
"values": {
|
||
"replicaCount": 3
|
||
},
|
||
"description": "Upgrade to v1.1.0"
|
||
}
|
||
|
||
# 响应 200
|
||
{
|
||
"id": "instance-xyz789",
|
||
"name": "my-nginx",
|
||
"version": "1.1.0",
|
||
"status": "deployed",
|
||
"revision": 4,
|
||
"updatedAt": "2025-11-09T12:00:00Z"
|
||
}
|
||
```
|
||
|
||
### 卸载应用
|
||
|
||
```bash
|
||
DELETE /api/v1/clusters/{clusterId}/instances/{instanceId}
|
||
|
||
# 响应 204 No Content
|
||
```
|
||
|
||
### 获取实例访问入口
|
||
|
||
```bash
|
||
GET /api/v1/clusters/{clusterId}/instances/{instanceId}/entries
|
||
|
||
# 响应 200
|
||
[
|
||
{
|
||
"kind": "Service",
|
||
"name": "test-nginx",
|
||
"namespace": "default",
|
||
"type": "ClusterIP",
|
||
"clusterIP": "10.43.120.15",
|
||
"ports": [
|
||
{
|
||
"name": "http",
|
||
"protocol": "TCP",
|
||
"port": 80,
|
||
"targetPort": "http"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"kind": "Ingress",
|
||
"name": "test-nginx",
|
||
"namespace": "default",
|
||
"type": "nginx",
|
||
"hosts": [
|
||
{
|
||
"host": "nginx.example.com",
|
||
"paths": [
|
||
{
|
||
"path": "/",
|
||
"serviceName": "test-nginx",
|
||
"servicePort": "http"
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"loadBalancerIngress": [
|
||
"34.120.0.12"
|
||
]
|
||
}
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
## 监控 API
|
||
|
||
### 列出集群监控信息
|
||
|
||
```bash
|
||
GET /api/v1/monitoring/clusters
|
||
|
||
# 响应 200
|
||
[
|
||
{
|
||
"clusterId": "cluster-abc123",
|
||
"clusterName": "Production Cluster",
|
||
"status": "healthy",
|
||
"cpuUsage": 45.2,
|
||
"memoryUsage": 62.8,
|
||
"nodeCount": 5,
|
||
"readyNodes": 5,
|
||
"podCount": 120,
|
||
"timestamp": "2025-11-09T12:00:00Z"
|
||
}
|
||
]
|
||
```
|
||
|
||
### 获取监控摘要
|
||
|
||
```bash
|
||
GET /api/v1/monitoring/summary
|
||
|
||
# 响应 200
|
||
{
|
||
"totalClusters": 3,
|
||
"healthyClusters": 2,
|
||
"unhealthyClusters": 1,
|
||
"totalNodes": 15,
|
||
"totalInstances": 45,
|
||
"averageCpuUsage": 42.3,
|
||
"averageMemoryUsage": 58.6,
|
||
"timestamp": "2025-11-09T12:00:00Z"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 响应格式
|
||
|
||
### 成功响应
|
||
|
||
**单个资源**:
|
||
|
||
```json
|
||
{
|
||
"id": "resource-123",
|
||
"name": "Resource Name",
|
||
"status": "active",
|
||
"createdAt": "2025-11-09T10:00:00Z"
|
||
}
|
||
```
|
||
|
||
**资源列表**:
|
||
|
||
```json
|
||
[
|
||
{
|
||
"id": "resource-123",
|
||
"name": "Resource 1"
|
||
},
|
||
{
|
||
"id": "resource-456",
|
||
"name": "Resource 2"
|
||
}
|
||
]
|
||
```
|
||
|
||
### HTTP 状态码
|
||
|
||
| 状态码 | 说明 | 使用场景 |
|
||
|--------|------|----------|
|
||
| 200 | OK | 请求成功 |
|
||
| 201 | Created | 资源创建成功 |
|
||
| 204 | No Content | 删除成功(无返回内容) |
|
||
| 400 | Bad Request | 请求参数错误 |
|
||
| 401 | Unauthorized | 未认证或 Token 无效 |
|
||
| 403 | Forbidden | 无权限访问 |
|
||
| 404 | Not Found | 资源不存在 |
|
||
| 409 | Conflict | 资源冲突(如重复创建) |
|
||
| 500 | Internal Server Error | 服务器内部错误 |
|
||
| 503 | Service Unavailable | 服务不可用 |
|
||
|
||
---
|
||
|
||
## 错误处理
|
||
|
||
### 错误响应格式
|
||
|
||
```json
|
||
{
|
||
"error": "Error Title",
|
||
"message": "Detailed error message",
|
||
"code": "ERROR_CODE",
|
||
"details": {...}
|
||
}
|
||
```
|
||
|
||
### 常见错误示例
|
||
|
||
#### 400 Bad Request
|
||
|
||
```json
|
||
{
|
||
"error": "Invalid Request",
|
||
"message": "Field 'name' is required"
|
||
}
|
||
```
|
||
|
||
#### 401 Unauthorized
|
||
|
||
```json
|
||
{
|
||
"error": "Unauthorized",
|
||
"message": "Invalid or expired token"
|
||
}
|
||
```
|
||
|
||
#### 404 Not Found
|
||
|
||
```json
|
||
{
|
||
"error": "Not Found",
|
||
"message": "Cluster with ID 'cluster-123' not found"
|
||
}
|
||
```
|
||
|
||
#### 500 Internal Server Error
|
||
|
||
```json
|
||
{
|
||
"error": "Internal Server Error",
|
||
"message": "Failed to connect to database"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
# Part 2: 测试文档
|
||
|
||
## 测试策略
|
||
|
||
OCDP Backend 采用多层测试策略,确保代码质量和系统稳定性。
|
||
|
||
### 测试金字塔
|
||
|
||
```
|
||
┌────────────┐
|
||
╱ E2E ╱│ 少量(慢速、昂贵)
|
||
╱ 集成测试 ╱ │ 中等(中速、适度)
|
||
╱ 单元测试 ╱ │ 大量(快速、便宜)
|
||
└──────────┘ │
|
||
│ │
|
||
└────────────┘
|
||
```
|
||
|
||
### 测试类型
|
||
|
||
| 测试类型 | 范围 | 速度 | 依赖 | 执行频率 |
|
||
|---------|------|------|------|----------|
|
||
| **单元测试** | Domain Layer | 快 | 无 | 每次提交 |
|
||
| **集成测试** | 跨层交互 | 中 | Mock | 每次提交 |
|
||
| **API 测试** | HTTP 接口 | 中 | Mock | 每次 PR |
|
||
| **E2E 测试** | 完整流程 | 慢 | 真实环境 | 发布前 |
|
||
|
||
---
|
||
|
||
## 单元测试
|
||
|
||
### 测试 Domain Service
|
||
|
||
单元测试专注于业务逻辑,使用 Mock Repository。
|
||
|
||
#### 示例:测试 ClusterService
|
||
|
||
```go
|
||
// internal/domain/service/cluster_service_test.go
|
||
package service_test
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"ocdp-backend/internal/adapter/output/persistence/mock"
|
||
"ocdp-backend/internal/domain/service"
|
||
)
|
||
|
||
func TestClusterService_CreateCluster(t *testing.T) {
|
||
// Arrange
|
||
repo := mock.NewClusterRepositoryMock()
|
||
svc := service.NewClusterService(repo)
|
||
|
||
ctx := context.Background()
|
||
name := "Test Cluster"
|
||
host := "https://k8s.test:6443"
|
||
|
||
// Act
|
||
cluster, err := svc.CreateCluster(ctx, name, host, "desc", "ca", "cert", "key")
|
||
|
||
// Assert
|
||
require.NoError(t, err)
|
||
assert.NotEmpty(t, cluster.ID)
|
||
assert.Equal(t, name, cluster.Name)
|
||
assert.Equal(t, host, cluster.Host)
|
||
}
|
||
|
||
func TestClusterService_GetCluster(t *testing.T) {
|
||
// Arrange
|
||
repo := mock.NewClusterRepositoryMock()
|
||
svc := service.NewClusterService(repo)
|
||
ctx := context.Background()
|
||
|
||
// 先创建集群
|
||
created, _ := svc.CreateCluster(ctx, "Test", "https://k8s.test:6443", "", "", "", "")
|
||
|
||
// Act
|
||
cluster, err := svc.GetCluster(ctx, created.ID)
|
||
|
||
// Assert
|
||
require.NoError(t, err)
|
||
assert.Equal(t, created.ID, cluster.ID)
|
||
assert.Equal(t, created.Name, cluster.Name)
|
||
}
|
||
|
||
func TestClusterService_GetCluster_NotFound(t *testing.T) {
|
||
// Arrange
|
||
repo := mock.NewClusterRepositoryMock()
|
||
svc := service.NewClusterService(repo)
|
||
ctx := context.Background()
|
||
|
||
// Act
|
||
cluster, err := svc.GetCluster(ctx, "non-existent-id")
|
||
|
||
// Assert
|
||
assert.Error(t, err)
|
||
assert.Nil(t, cluster)
|
||
}
|
||
|
||
func TestClusterService_ListClusters(t *testing.T) {
|
||
// Arrange
|
||
repo := mock.NewClusterRepositoryMock()
|
||
svc := service.NewClusterService(repo)
|
||
ctx := context.Background()
|
||
|
||
// 创建多个集群
|
||
svc.CreateCluster(ctx, "Cluster 1", "https://k8s1.test:6443", "", "", "", "")
|
||
svc.CreateCluster(ctx, "Cluster 2", "https://k8s2.test:6443", "", "", "", "")
|
||
|
||
// Act
|
||
clusters, err := svc.ListClusters(ctx)
|
||
|
||
// Assert
|
||
require.NoError(t, err)
|
||
assert.Len(t, clusters, 2)
|
||
}
|
||
|
||
func TestClusterService_DeleteCluster(t *testing.T) {
|
||
// Arrange
|
||
repo := mock.NewClusterRepositoryMock()
|
||
svc := service.NewClusterService(repo)
|
||
ctx := context.Background()
|
||
|
||
cluster, _ := svc.CreateCluster(ctx, "Test", "https://k8s.test:6443", "", "", "", "")
|
||
|
||
// Act
|
||
err := svc.DeleteCluster(ctx, cluster.ID)
|
||
|
||
// Assert
|
||
require.NoError(t, err)
|
||
|
||
// 验证已删除
|
||
_, err = svc.GetCluster(ctx, cluster.ID)
|
||
assert.Error(t, err)
|
||
}
|
||
```
|
||
|
||
#### 示例:测试 AuthService
|
||
|
||
```go
|
||
// internal/domain/service/auth_service_test.go
|
||
package service_test
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"ocdp-backend/internal/adapter/output/persistence/mock"
|
||
"ocdp-backend/internal/domain/service"
|
||
"ocdp-backend/internal/pkg/password"
|
||
"ocdp-backend/internal/pkg/jwt"
|
||
)
|
||
|
||
func TestAuthService_Register(t *testing.T) {
|
||
// Arrange
|
||
userRepo := mock.NewUserRepositoryMock()
|
||
hasher := password.NewBcryptHasher()
|
||
jwtGen := jwt.NewJWTGenerator("test-secret")
|
||
svc := service.NewAuthService(userRepo, hasher, jwtGen)
|
||
|
||
ctx := context.Background()
|
||
|
||
// Act
|
||
user, err := svc.Register(ctx, "testuser", "password123", "test@example.com")
|
||
|
||
// Assert
|
||
require.NoError(t, err)
|
||
assert.NotEmpty(t, user.ID)
|
||
assert.Equal(t, "testuser", user.Username)
|
||
assert.NotEqual(t, "password123", user.PasswordHash) // 密码已哈希
|
||
}
|
||
|
||
func TestAuthService_Login(t *testing.T) {
|
||
// Arrange
|
||
userRepo := mock.NewUserRepositoryMock()
|
||
hasher := password.NewBcryptHasher()
|
||
jwtGen := jwt.NewJWTGenerator("test-secret")
|
||
svc := service.NewAuthService(userRepo, hasher, jwtGen)
|
||
|
||
ctx := context.Background()
|
||
|
||
// 先注册用户
|
||
svc.Register(ctx, "testuser", "password123", "test@example.com")
|
||
|
||
// Act
|
||
response, err := svc.Login(ctx, "testuser", "password123")
|
||
|
||
// Assert
|
||
require.NoError(t, err)
|
||
assert.NotEmpty(t, response.AccessToken)
|
||
assert.NotEmpty(t, response.RefreshToken)
|
||
assert.Equal(t, "testuser", response.User.Username)
|
||
}
|
||
|
||
func TestAuthService_Login_WrongPassword(t *testing.T) {
|
||
// Arrange
|
||
userRepo := mock.NewUserRepositoryMock()
|
||
hasher := password.NewBcryptHasher()
|
||
jwtGen := jwt.NewJWTGenerator("test-secret")
|
||
svc := service.NewAuthService(userRepo, hasher, jwtGen)
|
||
|
||
ctx := context.Background()
|
||
svc.Register(ctx, "testuser", "password123", "test@example.com")
|
||
|
||
// Act
|
||
response, err := svc.Login(ctx, "testuser", "wrongpassword")
|
||
|
||
// Assert
|
||
assert.Error(t, err)
|
||
assert.Nil(t, response)
|
||
}
|
||
```
|
||
|
||
### 运行单元测试
|
||
|
||
```bash
|
||
# 运行所有单元测试
|
||
go test ./internal/domain/...
|
||
|
||
# 运行特定包
|
||
go test ./internal/domain/service
|
||
|
||
# 带覆盖率
|
||
go test -cover ./internal/domain/...
|
||
|
||
# 详细输出
|
||
go test -v ./internal/domain/...
|
||
|
||
# 生成覆盖率报告
|
||
go test -coverprofile=coverage.out ./internal/domain/...
|
||
go tool cover -html=coverage.out
|
||
```
|
||
|
||
---
|
||
|
||
## 集成测试
|
||
|
||
集成测试验证跨层交互,使用 Mock 适配器。
|
||
|
||
### 示例:测试 REST Handler + Service
|
||
|
||
```go
|
||
// internal/adapter/input/http/rest/cluster_handler_test.go
|
||
package rest_test
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"github.com/gorilla/mux"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"ocdp-backend/internal/adapter/input/http/dto"
|
||
"ocdp-backend/internal/adapter/input/http/rest"
|
||
"ocdp-backend/internal/adapter/output/persistence/mock"
|
||
"ocdp-backend/internal/domain/service"
|
||
)
|
||
|
||
func setupClusterHandler() (*rest.ClusterHandler, *service.ClusterService) {
|
||
repo := mock.NewClusterRepositoryMock()
|
||
svc := service.NewClusterService(repo)
|
||
handler := rest.NewClusterHandler(svc)
|
||
return handler, svc
|
||
}
|
||
|
||
func TestClusterHandler_CreateCluster(t *testing.T) {
|
||
// Arrange
|
||
handler, _ := setupClusterHandler()
|
||
|
||
reqBody := dto.CreateClusterRequest{
|
||
Name: "Test Cluster",
|
||
Host: "https://k8s.test:6443",
|
||
Description: "Test cluster",
|
||
CAData: "ca-data",
|
||
CertData: "cert-data",
|
||
KeyData: "key-data",
|
||
}
|
||
|
||
body, _ := json.Marshal(reqBody)
|
||
req := httptest.NewRequest(http.MethodPost, "/api/v1/clusters", bytes.NewBuffer(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
rec := httptest.NewRecorder()
|
||
|
||
// Act
|
||
handler.CreateCluster(rec, req)
|
||
|
||
// Assert
|
||
assert.Equal(t, http.StatusCreated, rec.Code)
|
||
|
||
var response map[string]interface{}
|
||
json.Unmarshal(rec.Body.Bytes(), &response)
|
||
|
||
assert.NotEmpty(t, response["id"])
|
||
assert.Equal(t, "Test Cluster", response["name"])
|
||
}
|
||
|
||
func TestClusterHandler_GetAllClusters(t *testing.T) {
|
||
// Arrange
|
||
handler, svc := setupClusterHandler()
|
||
|
||
// 创建测试数据
|
||
ctx := context.Background()
|
||
svc.CreateCluster(ctx, "Cluster 1", "https://k8s1.test:6443", "", "", "", "")
|
||
svc.CreateCluster(ctx, "Cluster 2", "https://k8s2.test:6443", "", "", "", "")
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/v1/clusters", nil)
|
||
rec := httptest.NewRecorder()
|
||
|
||
// Act
|
||
handler.GetAllClusters(rec, req)
|
||
|
||
// Assert
|
||
assert.Equal(t, http.StatusOK, rec.Code)
|
||
|
||
var clusters []map[string]interface{}
|
||
json.Unmarshal(rec.Body.Bytes(), &clusters)
|
||
|
||
assert.Len(t, clusters, 2)
|
||
}
|
||
|
||
func TestClusterHandler_GetCluster(t *testing.T) {
|
||
// Arrange
|
||
handler, svc := setupClusterHandler()
|
||
ctx := context.Background()
|
||
|
||
cluster, _ := svc.CreateCluster(ctx, "Test", "https://k8s.test:6443", "", "", "", "")
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/v1/clusters/"+cluster.ID, nil)
|
||
req = mux.SetURLVars(req, map[string]string{"clusterId": cluster.ID})
|
||
rec := httptest.NewRecorder()
|
||
|
||
// Act
|
||
handler.GetCluster(rec, req)
|
||
|
||
// Assert
|
||
assert.Equal(t, http.StatusOK, rec.Code)
|
||
|
||
var response map[string]interface{}
|
||
json.Unmarshal(rec.Body.Bytes(), &response)
|
||
|
||
assert.Equal(t, cluster.ID, response["id"])
|
||
}
|
||
```
|
||
|
||
### 运行集成测试
|
||
|
||
```bash
|
||
# 使用 Mock 模式运行所有测试
|
||
ADAPTER_MODE=mock go test ./...
|
||
|
||
# 测试特定模块
|
||
go test ./internal/adapter/input/http/rest/...
|
||
|
||
# 并行测试
|
||
go test -parallel 4 ./...
|
||
```
|
||
|
||
---
|
||
|
||
## API 测试
|
||
|
||
使用 HTTP 客户端测试完整的 API 流程。
|
||
|
||
### 使用 cURL 测试
|
||
|
||
```bash
|
||
#!/bin/bash
|
||
# scripts/test-api.sh
|
||
|
||
BASE_URL="http://localhost:8080/api/v1"
|
||
|
||
# 健康检查
|
||
echo "=== Health Check ==="
|
||
curl -X GET http://localhost:8080/health
|
||
|
||
# 注册用户
|
||
echo "\n=== Register User ==="
|
||
curl -X POST $BASE_URL/auth/register \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"username": "testuser",
|
||
"password": "test123",
|
||
"email": "test@example.com"
|
||
}'
|
||
|
||
# 登录
|
||
echo "\n=== Login ==="
|
||
LOGIN_RESPONSE=$(curl -X POST $BASE_URL/auth/login \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"username": "testuser",
|
||
"password": "test123"
|
||
}')
|
||
|
||
TOKEN=$(echo $LOGIN_RESPONSE | jq -r '.accessToken')
|
||
|
||
# 创建集群
|
||
echo "\n=== Create Cluster ==="
|
||
curl -X POST $BASE_URL/clusters \
|
||
-H "Content-Type: application/json" \
|
||
-H "Authorization: Bearer $TOKEN" \
|
||
-d '{
|
||
"name": "Test Cluster",
|
||
"host": "https://k8s.test:6443",
|
||
"description": "Test cluster",
|
||
"caData": "LS0tLS...",
|
||
"certData": "LS0tLS...",
|
||
"keyData": "LS0tLS..."
|
||
}'
|
||
|
||
# 列出集群
|
||
echo "\n=== List Clusters ==="
|
||
curl -X GET $BASE_URL/clusters \
|
||
-H "Authorization: Bearer $TOKEN"
|
||
|
||
# 创建 Registry
|
||
echo "\n=== Create Registry ==="
|
||
curl -X POST $BASE_URL/registries \
|
||
-H "Content-Type: application/json" \
|
||
-H "Authorization: Bearer $TOKEN" \
|
||
-d '{
|
||
"name": "Test Harbor",
|
||
"url": "https://harbor.test.com",
|
||
"username": "admin",
|
||
"password": "secret"
|
||
}'
|
||
|
||
# 列出 Registries
|
||
echo "\n=== List Registries ==="
|
||
curl -X GET $BASE_URL/registries \
|
||
-H "Authorization: Bearer $TOKEN"
|
||
```
|
||
|
||
### 使用 Go 测试 HTTP 接口
|
||
|
||
```go
|
||
// test/api/api_test.go
|
||
package api_test
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"net/http"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
const baseURL = "http://localhost:8080/api/v1"
|
||
|
||
func TestAPI_HealthCheck(t *testing.T) {
|
||
resp, err := http.Get("http://localhost:8080/health")
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||
}
|
||
|
||
func TestAPI_AuthFlow(t *testing.T) {
|
||
// 1. 注册
|
||
registerBody := map[string]string{
|
||
"username": "apitest",
|
||
"password": "test123",
|
||
"email": "apitest@example.com",
|
||
}
|
||
|
||
body, _ := json.Marshal(registerBody)
|
||
resp, err := http.Post(baseURL+"/auth/register", "application/json", bytes.NewBuffer(body))
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||
|
||
// 2. 登录
|
||
loginBody := map[string]string{
|
||
"username": "apitest",
|
||
"password": "test123",
|
||
}
|
||
|
||
body, _ = json.Marshal(loginBody)
|
||
resp, err = http.Post(baseURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||
|
||
var loginResponse map[string]interface{}
|
||
json.NewDecoder(resp.Body).Decode(&loginResponse)
|
||
|
||
assert.NotEmpty(t, loginResponse["accessToken"])
|
||
assert.NotEmpty(t, loginResponse["refreshToken"])
|
||
}
|
||
|
||
func TestAPI_ClusterCRUD(t *testing.T) {
|
||
// 先获取 token
|
||
token := getAuthToken(t)
|
||
|
||
// 1. 创建集群
|
||
clusterBody := map[string]string{
|
||
"name": "API Test Cluster",
|
||
"host": "https://k8s.test:6443",
|
||
"description": "Created by API test",
|
||
"caData": "test-ca",
|
||
"certData": "test-cert",
|
||
"keyData": "test-key",
|
||
}
|
||
|
||
body, _ := json.Marshal(clusterBody)
|
||
req, _ := http.NewRequest(http.MethodPost, baseURL+"/clusters", bytes.NewBuffer(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||
|
||
var cluster map[string]interface{}
|
||
json.NewDecoder(resp.Body).Decode(&cluster)
|
||
clusterID := cluster["id"].(string)
|
||
|
||
// 2. 获取集群
|
||
req, _ = http.NewRequest(http.MethodGet, baseURL+"/clusters/"+clusterID, nil)
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
||
resp, err = http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||
|
||
// 3. 删除集群
|
||
req, _ = http.NewRequest(http.MethodDelete, baseURL+"/clusters/"+clusterID, nil)
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
||
resp, err = http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||
}
|
||
|
||
func getAuthToken(t *testing.T) string {
|
||
// 登录并返回 token
|
||
loginBody := map[string]string{
|
||
"username": "admin",
|
||
"password": "admin123",
|
||
}
|
||
|
||
body, _ := json.Marshal(loginBody)
|
||
resp, _ := http.Post(baseURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
||
defer resp.Body.Close()
|
||
|
||
var response map[string]interface{}
|
||
json.NewDecoder(resp.Body).Decode(&response)
|
||
|
||
return response["accessToken"].(string)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## E2E 测试
|
||
|
||
端到端测试验证完整的用户场景。
|
||
|
||
### E2E 测试示例
|
||
|
||
```go
|
||
// test/e2e/deployment_test.go
|
||
package e2e_test
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
func TestE2E_CompleteDeploymentFlow(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("Skipping E2E test in short mode")
|
||
}
|
||
|
||
ctx := context.Background()
|
||
|
||
// 1. 注册用户
|
||
t.Log("Step 1: Register user")
|
||
user := registerUser(t, "e2euser", "password123")
|
||
assert.NotEmpty(t, user.ID)
|
||
|
||
// 2. 登录获取 token
|
||
t.Log("Step 2: Login")
|
||
token := login(t, "e2euser", "password123")
|
||
assert.NotEmpty(t, token)
|
||
|
||
// 3. 创建 Registry
|
||
t.Log("Step 3: Create registry")
|
||
registry := createRegistry(t, token, "Test Harbor", "https://harbor.test.com")
|
||
assert.NotEmpty(t, registry.ID)
|
||
|
||
// 4. 创建 Cluster
|
||
t.Log("Step 4: Create cluster")
|
||
cluster := createCluster(t, token, "Test K8s", "https://k8s.test:6443")
|
||
assert.NotEmpty(t, cluster.ID)
|
||
|
||
// 5. 部署应用
|
||
t.Log("Step 5: Deploy application")
|
||
instance := deployApp(t, token, cluster.ID, registry.ID, "nginx", "1.0.0")
|
||
assert.Equal(t, "deployed", instance.Status)
|
||
|
||
// 6. 等待应用就绪
|
||
t.Log("Step 6: Wait for application ready")
|
||
time.Sleep(10 * time.Second)
|
||
|
||
// 7. 检查应用状态
|
||
t.Log("Step 7: Check application status")
|
||
status := getInstanceStatus(t, token, cluster.ID, instance.ID)
|
||
assert.Equal(t, "deployed", status)
|
||
|
||
// 8. 升级应用
|
||
t.Log("Step 8: Upgrade application")
|
||
upgraded := upgradeApp(t, token, cluster.ID, instance.ID, "1.1.0")
|
||
assert.Equal(t, 2, upgraded.Revision)
|
||
|
||
// 9. 卸载应用
|
||
t.Log("Step 9: Uninstall application")
|
||
err := uninstallApp(t, token, cluster.ID, instance.ID)
|
||
assert.NoError(t, err)
|
||
|
||
// 10. 清理
|
||
t.Log("Step 10: Cleanup")
|
||
deleteCluster(t, token, cluster.ID)
|
||
deleteRegistry(t, token, registry.ID)
|
||
}
|
||
```
|
||
|
||
### 运行 E2E 测试
|
||
|
||
```bash
|
||
# 使用 Production 模式运行 E2E 测试
|
||
ADAPTER_MODE=production DATABASE_URL="..." go test ./test/e2e/... -timeout 30m
|
||
|
||
# 跳过 E2E 测试
|
||
go test -short ./...
|
||
```
|
||
|
||
---
|
||
|
||
## Mock 测试
|
||
|
||
### Mock Repository 示例
|
||
|
||
所有 Repository 都有 Mock 实现,位于 `internal/adapter/output/persistence/mock/`。
|
||
|
||
```go
|
||
// internal/adapter/output/persistence/mock/cluster_repository_mock.go
|
||
package mock
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sync"
|
||
|
||
"ocdp-backend/internal/domain/entity"
|
||
"ocdp-backend/internal/domain/repository"
|
||
)
|
||
|
||
type ClusterRepositoryMock struct {
|
||
clusters map[string]*entity.Cluster
|
||
mu sync.RWMutex
|
||
}
|
||
|
||
func NewClusterRepositoryMock() repository.ClusterRepository {
|
||
return &ClusterRepositoryMock{
|
||
clusters: make(map[string]*entity.Cluster),
|
||
}
|
||
}
|
||
|
||
func (r *ClusterRepositoryMock) Create(ctx context.Context, cluster *entity.Cluster) error {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
|
||
if _, exists := r.clusters[cluster.ID]; exists {
|
||
return fmt.Errorf("cluster already exists")
|
||
}
|
||
|
||
r.clusters[cluster.ID] = cluster
|
||
return nil
|
||
}
|
||
|
||
func (r *ClusterRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
|
||
cluster, exists := r.clusters[id]
|
||
if !exists {
|
||
return nil, fmt.Errorf("cluster not found")
|
||
}
|
||
|
||
return cluster, nil
|
||
}
|
||
|
||
func (r *ClusterRepositoryMock) List(ctx context.Context) ([]*entity.Cluster, error) {
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
|
||
clusters := make([]*entity.Cluster, 0, len(r.clusters))
|
||
for _, cluster := range r.clusters {
|
||
clusters = append(clusters, cluster)
|
||
}
|
||
|
||
return clusters, nil
|
||
}
|
||
|
||
func (r *ClusterRepositoryMock) Delete(ctx context.Context, id string) error {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
|
||
if _, exists := r.clusters[id]; !exists {
|
||
return fmt.Errorf("cluster not found")
|
||
}
|
||
|
||
delete(r.clusters, id)
|
||
return nil
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 测试工具
|
||
|
||
### 推荐的测试库
|
||
|
||
```go
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert" // 断言
|
||
"github.com/stretchr/testify/require" // 必需条件
|
||
"github.com/stretchr/testify/mock" // Mock 对象
|
||
"github.com/stretchr/testify/suite" // 测试套件
|
||
)
|
||
```
|
||
|
||
### 安装测试依赖
|
||
|
||
```bash
|
||
go get github.com/stretchr/testify
|
||
```
|
||
|
||
### 测试覆盖率
|
||
|
||
```bash
|
||
# 生成覆盖率报告
|
||
go test -coverprofile=coverage.out ./...
|
||
|
||
# 查看覆盖率
|
||
go tool cover -func=coverage.out
|
||
|
||
# HTML 报告
|
||
go tool cover -html=coverage.out -o coverage.html
|
||
```
|
||
|
||
### 性能测试
|
||
|
||
```go
|
||
func BenchmarkClusterService_CreateCluster(b *testing.B) {
|
||
repo := mock.NewClusterRepositoryMock()
|
||
svc := service.NewClusterService(repo)
|
||
ctx := context.Background()
|
||
|
||
b.ResetTimer()
|
||
for i := 0; i < b.N; i++ {
|
||
svc.CreateCluster(ctx, "Test", "https://k8s.test:6443", "", "", "", "")
|
||
}
|
||
}
|
||
|
||
// 运行性能测试
|
||
// go test -bench=. -benchmem ./internal/domain/service/
|
||
```
|
||
|
||
---
|
||
|
||
## 测试最佳实践
|
||
|
||
### 1. 测试命名规范
|
||
|
||
```go
|
||
// ✅ 好的命名
|
||
func TestClusterService_CreateCluster(t *testing.T)
|
||
func TestClusterService_CreateCluster_DuplicateName(t *testing.T)
|
||
func TestClusterHandler_GetCluster_NotFound(t *testing.T)
|
||
|
||
// ❌ 不好的命名
|
||
func TestCreate(t *testing.T)
|
||
func Test1(t *testing.T)
|
||
```
|
||
|
||
### 2. AAA 模式 (Arrange-Act-Assert)
|
||
|
||
```go
|
||
func TestExample(t *testing.T) {
|
||
// Arrange - 准备测试数据
|
||
repo := mock.NewClusterRepositoryMock()
|
||
svc := service.NewClusterService(repo)
|
||
|
||
// Act - 执行操作
|
||
cluster, err := svc.CreateCluster(ctx, "Test", "https://k8s.test:6443", "", "", "", "")
|
||
|
||
// Assert - 验证结果
|
||
require.NoError(t, err)
|
||
assert.NotEmpty(t, cluster.ID)
|
||
}
|
||
```
|
||
|
||
### 3. 表驱动测试
|
||
|
||
```go
|
||
func TestClusterValidation(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
input string
|
||
want bool
|
||
wantErr bool
|
||
}{
|
||
{"valid URL", "https://k8s.example.com:6443", true, false},
|
||
{"invalid URL", "not-a-url", false, true},
|
||
{"empty URL", "", false, true},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
got, err := validateClusterURL(tt.input)
|
||
|
||
if tt.wantErr {
|
||
assert.Error(t, err)
|
||
} else {
|
||
require.NoError(t, err)
|
||
assert.Equal(t, tt.want, got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4. 使用测试辅助函数
|
||
|
||
```go
|
||
// test/helpers/helpers.go
|
||
package helpers
|
||
|
||
func CreateTestCluster(t *testing.T, svc *service.ClusterService) *entity.Cluster {
|
||
t.Helper()
|
||
|
||
cluster, err := svc.CreateCluster(
|
||
context.Background(),
|
||
"Test Cluster",
|
||
"https://k8s.test:6443",
|
||
"", "", "", "",
|
||
)
|
||
|
||
require.NoError(t, err)
|
||
return cluster
|
||
}
|
||
|
||
// 在测试中使用
|
||
func TestSomething(t *testing.T) {
|
||
cluster := helpers.CreateTestCluster(t, svc)
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### 5. 清理测试数据
|
||
|
||
```go
|
||
func TestWithCleanup(t *testing.T) {
|
||
repo := mock.NewClusterRepositoryMock()
|
||
svc := service.NewClusterService(repo)
|
||
ctx := context.Background()
|
||
|
||
cluster, _ := svc.CreateCluster(ctx, "Test", "https://k8s.test:6443", "", "", "", "")
|
||
|
||
// 确保清理
|
||
t.Cleanup(func() {
|
||
svc.DeleteCluster(ctx, cluster.ID)
|
||
})
|
||
|
||
// 测试逻辑
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### 6. 并行测试
|
||
|
||
```go
|
||
func TestParallel(t *testing.T) {
|
||
t.Parallel() // 标记为可并行
|
||
|
||
// 测试逻辑
|
||
}
|
||
```
|
||
|
||
### 7. 跳过测试
|
||
|
||
```go
|
||
func TestSomething(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("Skipping test in short mode")
|
||
}
|
||
|
||
// 长时间运行的测试
|
||
}
|
||
|
||
// 运行: go test -short
|
||
```
|
||
|
||
---
|
||
|
||
## CI/CD 集成
|
||
|
||
### GitHub Actions 示例
|
||
|
||
```yaml
|
||
# .github/workflows/test.yml
|
||
name: Tests
|
||
|
||
on: [push, pull_request]
|
||
|
||
jobs:
|
||
test:
|
||
runs-on: ubuntu-latest
|
||
|
||
services:
|
||
postgres:
|
||
image: postgres:15
|
||
env:
|
||
POSTGRES_PASSWORD: postgres
|
||
POSTGRES_DB: ocdp_test
|
||
options: >-
|
||
--health-cmd pg_isready
|
||
--health-interval 10s
|
||
--health-timeout 5s
|
||
--health-retries 5
|
||
ports:
|
||
- 5432:5432
|
||
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
|
||
- name: Setup Go
|
||
uses: actions/setup-go@v4
|
||
with:
|
||
go-version: '1.21'
|
||
|
||
- name: Install dependencies
|
||
run: go mod download
|
||
|
||
- name: Run unit tests
|
||
run: go test -short -cover ./internal/domain/...
|
||
|
||
- name: Run integration tests (Mock)
|
||
env:
|
||
ADAPTER_MODE: mock
|
||
run: go test -short ./...
|
||
|
||
- name: Run integration tests (Production)
|
||
env:
|
||
ADAPTER_MODE: production
|
||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/ocdp_test?sslmode=disable
|
||
run: go test ./...
|
||
|
||
- name: Generate coverage report
|
||
run: |
|
||
go test -coverprofile=coverage.out ./...
|
||
go tool cover -func=coverage.out
|
||
```
|
||
|
||
---
|
||
|
||
## 相关文档
|
||
|
||
- [架构文档](architecture.md)
|
||
- [部署文档](deployment.md)
|
||
- [主 README](../README.md)
|
||
|
||
---
|
||
|
||
**Last Updated**: 2025-11-09
|
||
**API Version**: v1
|