# 📚 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 # 部分接口需要 ``` --- ## 认证 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