38 KiB
38 KiB
📚 API 与测试文档
💡 推荐使用 OpenAPI 规范
本项目现在提供标准的 OpenAPI 3.0 规范!
- 📖 交互式 API 文档: http://localhost:8080/api/docs (Swagger UI)
- 📄 OpenAPI 规范文件: openapi.yaml
- 🔧 在线测试: 在 Swagger UI 中直接测试所有 API
- 🚀 客户端生成: 使用 OpenAPI 规范自动生成客户端代码
本文档 (Markdown 版本) 作为参考文档保留,内容与 OpenAPI 规范保持一致。 如需最新的 API 定义,请参考 openapi.yaml。
目录
Part 1: API 文档
Part 2: 测试文档
Part 1: API 文档
基础信息
Base URL
http://localhost:8080/api/v1
健康检查
GET /health
# 响应
{
"status": "healthy"
}
通用请求头
Content-Type: application/json
Authorization: Bearer <JWT_TOKEN> # 部分接口需要
认证 API
用户注册
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"
}
用户登录
POST /api/v1/auth/login
Content-Type: application/json
{
"username": "john",
"password": "secret123"
}
# 响应 200
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"userId": "user-123",
"username": "john"
}
刷新 Token
POST /api/v1/auth/refresh
Content-Type: application/json
{
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}
# 响应 200
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"userId": "user-123",
"username": "john"
}
集群管理 API
创建集群
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"
}
列出所有集群
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"
}
]
获取集群详情
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"
}
更新集群
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"
}
删除集群
DELETE /api/v1/clusters/{clusterId}
# 响应 204 No Content
集群健康检查
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
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 APIusername/password: 可选,用于私有 Registry 认证insecure: 是否跳过 TLS 验证(开发环境可用)
注意: 密码不会在响应中返回,存储时会自动加密。
列出所有 Registries
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 详情
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
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
DELETE /api/v1/registries/{registryId}
# 响应 204 No Content
Registry 健康检查
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
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 支持
_catalogAPI(OCI Distribution Spec)。
列出 Artifacts
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 Chartimage: Docker Image / OCI Imageother: 其他类型
获取 Artifact 详情
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
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
安装应用
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"
}
列出应用实例
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"
}
]
升级应用
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"
}
卸载应用
DELETE /api/v1/clusters/{clusterId}/instances/{instanceId}
# 响应 204 No Content
获取实例访问入口
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
列出集群监控信息
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"
}
]
获取监控摘要
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"
}
响应格式
成功响应
单个资源:
{
"id": "resource-123",
"name": "Resource Name",
"status": "active",
"createdAt": "2025-11-09T10:00:00Z"
}
资源列表:
[
{
"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 | 服务不可用 |
错误处理
错误响应格式
{
"error": "Error Title",
"message": "Detailed error message",
"code": "ERROR_CODE",
"details": {...}
}
常见错误示例
400 Bad Request
{
"error": "Invalid Request",
"message": "Field 'name' is required"
}
401 Unauthorized
{
"error": "Unauthorized",
"message": "Invalid or expired token"
}
404 Not Found
{
"error": "Not Found",
"message": "Cluster with ID 'cluster-123' not found"
}
500 Internal Server Error
{
"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
// 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
// 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)
}
运行单元测试
# 运行所有单元测试
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
// 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"])
}
运行集成测试
# 使用 Mock 模式运行所有测试
ADAPTER_MODE=mock go test ./...
# 测试特定模块
go test ./internal/adapter/input/http/rest/...
# 并行测试
go test -parallel 4 ./...
API 测试
使用 HTTP 客户端测试完整的 API 流程。
使用 cURL 测试
#!/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 接口
// 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 测试示例
// 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 测试
# 使用 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/。
// 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
}
测试工具
推荐的测试库
import (
"testing"
"github.com/stretchr/testify/assert" // 断言
"github.com/stretchr/testify/require" // 必需条件
"github.com/stretchr/testify/mock" // Mock 对象
"github.com/stretchr/testify/suite" // 测试套件
)
安装测试依赖
go get github.com/stretchr/testify
测试覆盖率
# 生成覆盖率报告
go test -coverprofile=coverage.out ./...
# 查看覆盖率
go tool cover -func=coverage.out
# HTML 报告
go tool cover -html=coverage.out -o coverage.html
性能测试
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. 测试命名规范
// ✅ 好的命名
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)
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. 表驱动测试
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. 使用测试辅助函数
// 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. 清理测试数据
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. 并行测试
func TestParallel(t *testing.T) {
t.Parallel() // 标记为可并行
// 测试逻辑
}
7. 跳过测试
func TestSomething(t *testing.T) {
if testing.Short() {
t.Skip("Skipping test in short mode")
}
// 长时间运行的测试
}
// 运行: go test -short
CI/CD 集成
GitHub Actions 示例
# .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
相关文档
Last Updated: 2025-11-09
API Version: v1