From b37b12884f27511680c02b5c2ee3f0915674375f Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 23 Sep 2025 17:06:47 +0800 Subject: [PATCH] feat(tests): add comprehensive unit, integration, and e2e tests for API and database functionality - Implemented end-to-end tests for API health checks, performance, behavior, and documentation. - Created integration tests for database connection, CRUD operations, transactions, and connection pool management. - Developed unit tests for configuration loading, environment variable handling, validation, default values, and helper functions. - Established a test setup with environment management and basic usage examples for the testing framework. --- templates/project/.air.toml.raw | 40 ++ templates/project/.env.example.raw | 58 +++ templates/project/.golangci.yml.raw | 294 ++++++++++++ templates/project/Dockerfile.dev | 32 ++ templates/project/Dockerfile.raw | 83 +++- templates/project/config.toml.raw | 94 +++- .../project/database/migrations/-gitkeep | 0 templates/project/docker-compose.yml.tpl | 119 +++++ templates/project/tests/README.md | 288 ++++++++++++ templates/project/tests/e2e/api_test.go | 419 ++++++++++++++++++ .../tests/integration/database_test.go | 364 +++++++++++++++ templates/project/tests/setup_test.go | 161 +++++++ templates/project/tests/unit/config_test.go | 287 ++++++++++++ 13 files changed, 2220 insertions(+), 19 deletions(-) create mode 100644 templates/project/.air.toml.raw create mode 100644 templates/project/.env.example.raw create mode 100644 templates/project/.golangci.yml.raw create mode 100644 templates/project/Dockerfile.dev delete mode 100644 templates/project/database/migrations/-gitkeep create mode 100644 templates/project/docker-compose.yml.tpl create mode 100644 templates/project/tests/README.md create mode 100644 templates/project/tests/e2e/api_test.go create mode 100644 templates/project/tests/integration/database_test.go create mode 100644 templates/project/tests/setup_test.go create mode 100644 templates/project/tests/unit/config_test.go diff --git a/templates/project/.air.toml.raw b/templates/project/.air.toml.raw new file mode 100644 index 0000000..f97236a --- /dev/null +++ b/templates/project/.air.toml.raw @@ -0,0 +1,40 @@ +# .air.toml - Air 热重载配置文件 + +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "frontend"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml", "toml"] + kill_delay = "0s" + log = "build-errors.log" + send_interrupt = false + stop_on_root = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true \ No newline at end of file diff --git a/templates/project/.env.example.raw b/templates/project/.env.example.raw new file mode 100644 index 0000000..00c3ede --- /dev/null +++ b/templates/project/.env.example.raw @@ -0,0 +1,58 @@ +# 应用配置 +APP_MODE=development +APP_BASE_URI=http://localhost:8080 + +# HTTP 服务配置 +HTTP_PORT=8080 +HTTP_HOST=0.0.0.0 + +# 数据库配置 +DB_HOST=localhost +DB_PORT=5432 +DB_NAME={{.ProjectName}} +DB_USER=postgres +DB_PASSWORD=password +DB_SSL_MODE=disable +DB_MAX_CONNECTIONS=25 +DB_MAX_IDLE_CONNECTIONS=5 +DB_CONNECTION_LIFETIME=5m + +# Redis 配置 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# JWT 配置 +JWT_SECRET_KEY=your-secret-key-here +JWT_EXPIRES_TIME=168h + +# HashIDs 配置 +HASHIDS_SALT=your-salt-here + +# 日志配置 +LOG_LEVEL=info +LOG_FORMAT=json + +# 文件上传配置 +UPLOAD_MAX_SIZE=10MB +UPLOAD_PATH=./uploads + +# 邮件配置 +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= + +# 第三方服务配置 +REDIS_URL=redis://localhost:6379/0 +DATABASE_URL=postgres://postgres:password@localhost:5432/{{.ProjectName}}?sslmode=disable + +# 开发配置 +ENABLE_SWAGGER=true +ENABLE_CORS=true +DEBUG_MODE=true + +# 监控配置 +ENABLE_METRICS=false +METRICS_PORT=9090 \ No newline at end of file diff --git a/templates/project/.golangci.yml.raw b/templates/project/.golangci.yml.raw new file mode 100644 index 0000000..19d4f68 --- /dev/null +++ b/templates/project/.golangci.yml.raw @@ -0,0 +1,294 @@ +# golangci-lint 配置文件 +# https://golangci-lint.run/usage/configuration/ + +# 运行时配置 +run: + # 默认并行处理器数量 + default-concurrency: 4 + + # 超时时间 + timeout: 5m + + # 退出代码 + issues-exit-code: 1 + + # 测试包含的文件 + tests: true + + # 是否跳过文件 + skip-files: + - "_test\\.go$" + - ".*\\.gen\\.go$" + - ".*\\.pb\\.go$" + + # 是否跳过目录 + skip-dirs: + - "vendor" + - "node_modules" + - ".git" + - "build" + - "dist" + +# 输出配置 +output: + # 输出格式 + format: colored-line-number + + # 打印已使用的 linter + print-issued-lines: true + + # 打印 linter 名称 + print-linter-name: true + + # 唯一性检查 + uniq-by-line: true + +# linter 启用配置 +linters-settings: + # 错误检查 + errcheck: + # 检查类型断言 + check-type-assertions: true + # 检查赋值 + check-blank: true + + # 代码复杂度 + gocyclo: + # 最小复杂度 + min-complexity: 15 + + # 函数参数和返回值 + gocognit: + # 最小认知复杂度 + min-complexity: 20 + + # 函数长度 + funlen: + # 最大行数 + lines: 60 + # 最大语句数 + statements: 40 + + # 代码行长度 + lll: + # 最大行长度 + line-length: 120 + + # 导入顺序 + importas: + # 别名规则 + no-unaliased: true + alias: + - pkg: "github.com/sirupsen/logrus" + alias: "logrus" + - pkg: "github.com/stretchr/testify/assert" + alias: "assert" + - pkg: "github.com/stretchr/testify/suite" + alias: "suite" + + # 重复导入 + dupl: + # 重复代码块的最小 token 数 + threshold: 100 + + # 空值检查 + nilerr: + # 检查返回 nil 的函数 + check-type-assertions: true + check-blank: true + + # 代码格式化 + gofmt: + # 格式化简化 + simplify: true + + # 导入检查 + goimports: + # 本地前缀 + local-prefixes: "{{.ModuleName}}" + + # 静态检查 + staticcheck: + # 检查版本 + go_version: "1.22" + + # 结构体标签 + structtag: + # 检查标签 + required: [] + # 是否允许空标签 + allow-omit-latest: true + + # 未使用的变量 + unused: + # 检查字段 + check-exported-fields: true + + # 变量命名 + varnamelen: + # 最小变量名长度 + min-name-length: 2 + # 检查参数 + check-parameters: true + # 检查返回值 + check-return: true + # 检查接收器 + check-receiver: true + # 检查变量 + check-variable: true + # 忽略名称 + ignore-names: + - "ok" + - "err" + - "T" + - "i" + - "n" + - "v" + # 忽略类型 + ignore-type-assert-ok: true + ignore-map-index-ok: true + ignore-chan-recv-ok: true + ignore-decls: + - "T any" + - "w http.ResponseWriter" + - "r *http.Request" + +# 启用的 linter +linters: + enable: + # 错误检查 + - errcheck + - errorlint + - goerr113 + + # 代码复杂度 + - gocyclo + - gocognit + - funlen + + # 代码风格 + - gofmt + - goimports + - lll + - misspell + - whitespace + + # 导入检查 + - importas + - dupl + + # 静态检查 + - staticcheck + - unused + - typecheck + - ineffassign + - bodyclose + - contextcheck + - nilerr + + # 测试检查 + - tparallel + - testpackage + - thelper + + # 性能检查 + - prealloc + - unconvert + + # 安全检查 + - gosec + - noctx + - rowserrcheck + + # 代码质量 + - revive + - varnamelen + - exportloopref + - forcetypeassert + - govet + - paralleltest + - nlreturn + - wastedassign + - wrapcheck + +# 禁用的 linter +linters-disable: + - deadcode # 被 unused 替代 + - varcheck # 被 unused 替代 + - structcheck # 被 unused 替代 + - interfacer # 已弃用 + - maligned # 已弃用 + - scopelint # 已弃用 + +# 问题配置 +issues: + # 排除规则 + exclude-rules: + # 排除测试文件的某些规则 + - path: _test\.go + linters: + - funlen + - gocyclo + - dupl + - gochecknoglobals + - gochecknoinits + + # 排除生成的文件 + - path: \.gen\.go$ + linters: + - lll + - funlen + - gocyclo + + # 排除错误处理中的简单错误检查 + - path: .* + text: "Error return value of `.*` is not checked" + + # 排除特定的 golangci-lint 注释 + - path: .* + text: "// nolint:.*" + + # 排除 context.Context 的未使用检查 + - path: .* + text: "context.Context should be the first parameter of a function" + + # 排除某些性能优化建议 + - path: .* + text: "predeclared" + + # 排除某些重复代码检查 + - path: .* + linters: + - dupl + text: "is duplicate of" + + # 最大问题数 + max-issues-per-linter: 50 + + # 最大相同问题数 + max-same-issues: 3 + +# 严重性配置 +severity: + # 默认严重性 + default-severity: error + + # 规则严重性 + rules: + - linters: + - dupl + - gosec + severity: warning + + - linters: + - misspell + - whitespace + severity: info + +# 性能配置 +performance: + # 是否使用内存缓存 + use-memory-cache: true + + # 缓存超时时间 + cache-timeout: 5m \ No newline at end of file diff --git a/templates/project/Dockerfile.dev b/templates/project/Dockerfile.dev new file mode 100644 index 0000000..4140057 --- /dev/null +++ b/templates/project/Dockerfile.dev @@ -0,0 +1,32 @@ +# 开发环境 Dockerfile +FROM golang:1.22-alpine AS builder + +# 安装必要的工具 +RUN apk add --no-cache git ca-certificates tzdata + +# 设置工作目录 +WORKDIR /app + +# 复制 go mod 文件 +COPY go.mod go.sum ./ + +# 设置 Go 代理 +RUN go env -w GOPROXY=https://goproxy.cn,direct + +# 下载依赖 +RUN go mod download + +# 复制源代码 +COPY . . + +# 设置时区 +ENV TZ=Asia/Shanghai + +# 安装 air 用于热重载 +RUN go install github.com/air-verse/air@latest + +# 暴露端口 +EXPOSE 8080 9090 + +# 启动命令 +CMD ["air", "-c", ".air.toml"] \ No newline at end of file diff --git a/templates/project/Dockerfile.raw b/templates/project/Dockerfile.raw index 3b9a775..255ddd4 100644 --- a/templates/project/Dockerfile.raw +++ b/templates/project/Dockerfile.raw @@ -1,17 +1,82 @@ -FROM docker.hub.ipao.vip/alpine:3.20 +# 多阶段构建 Dockerfile +# 阶段 1: 构建应用 +FROM golang:1.22-alpine AS builder -# Set timezone -RUN apk add --no-cache tzdata && \ - cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ +# 安装构建依赖 +RUN apk add --no-cache git ca-certificates tzdata + +# 设置工作目录 +WORKDIR /app + +# 复制 go mod 文件 +COPY go.mod go.sum ./ + +# 设置 Go 代理 +ENV GOPROXY=https://goproxy.cn,direct +ENV CGO_ENABLED=0 +ENV GOOS=linux +ENV GOARCH=amd64 + +# 下载依赖 +RUN go mod download + +# 复制源代码 +COPY . . + +# 构建应用 +RUN go build -a -installsuffix cgo -ldflags="-w -s" -o main . + +# 阶段 2: 构建前端(如果有) +# 如果有前端构建,取消下面的注释 +# FROM node:18-alpine AS frontend-builder +# WORKDIR /app +# COPY frontend/package*.json ./ +# RUN npm ci --only=production +# COPY frontend/ . +# RUN npm run build + +# 阶段 3: 运行时镜像 +FROM alpine:3.20 AS runtime + +# 安装运行时依赖 +RUN apk add --no-cache ca-certificates tzdata curl + +# 设置时区 +RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ echo "Asia/Shanghai" > /etc/timezone && \ apk del tzdata -COPY backend/build/app /app/app -COPY backend/config.toml /app/config.toml -COPY frontend/dist /app/dist +# 创建非 root 用户 +RUN addgroup -g 1000 appgroup && \ + adduser -u 1000 -G appgroup -s /bin/sh -D appuser +# 创建必要的目录 +RUN mkdir -p /app/config /app/logs /app/uploads && \ + chown -R appuser:appgroup /app + +# 设置工作目录 WORKDIR /app -ENTRYPOINT ["/app/app"] +# 从构建阶段复制应用 +COPY --from=builder /app/main . +COPY --chown=appuser:appgroup config.toml ./config/ -CMD [ "serve" ] +# 如果有前端构建,取消下面的注释 +# COPY --from=frontend-builder /app/dist ./dist + +# 创建空目录供应用使用 +RUN mkdir -p /app/logs /app/uploads && \ + chown -R appuser:appgroup /app + +# 切换到非 root 用户 +USER appuser + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# 暴露端口 +EXPOSE 8080 + +# 启动应用 +CMD ["./main", "serve"] diff --git a/templates/project/config.toml.raw b/templates/project/config.toml.raw index 3032ce9..f776c3c 100755 --- a/templates/project/config.toml.raw +++ b/templates/project/config.toml.raw @@ -1,25 +1,99 @@ +# ========================= +# 应用基础配置 +# ========================= [App] +# 应用运行模式:development | production | testing Mode = "development" -BaseURI = "baseURI" +# 应用基础URI,用于生成完整URL +BaseURI = "http://localhost:8080" +# ========================= +# HTTP 服务器配置 +# ========================= [Http] +# HTTP服务监听端口 Port = 8080 +# 监听地址(可选,默认 0.0.0.0) +# Host = "0.0.0.0" +# 全局路由前缀(可选) +# BaseURI = "/api/v1" +# ========================= +# 数据库配置 +# ========================= [Database] -Host = "10.1.1.1" -Database = "postgres" -Password = "hello" - +# 数据库主机地址 +Host = "localhost" +# 数据库端口 +Port = 5432 +# 数据库名称 +Database = "{{.ProjectName}}" +# 数据库用户名 +Username = "postgres" +# 数据库密码 +Password = "password" +# SSL模式:disable | require | verify-ca | verify-full +SslMode = "disable" +# 时区 +TimeZone = "Asia/Shanghai" +# 连接池配置(可选) +MaxIdleConns = 10 +MaxOpenConns = 100 +ConnMaxLifetime = "1800s" +ConnMaxIdleTime = "300s" +# ========================= +# JWT 认证配置 +# ========================= [JWT] +# JWT签名密钥(生产环境请使用强密钥) +SigningKey = "your-secret-key-change-in-production" +# Token过期时间,如:72h, 168h, 720h ExpiresTime = "168h" -SigningKey = "Key" +# 签发者(可选) +Issuer = "{{.ProjectName}}" +# ========================= +# HashIDs 配置 +# ========================= [HashIDs] -Salt = "Salt" +# 盐值(用于ID加密,请使用随机字符串) +Salt = "your-random-salt-here" +# 最小长度(可选) +MinLength = 8 +# 自定义字符集(可选) +# Alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" +# ========================= +# Redis 缓存配置 +# ========================= [Redis] -Host = "" +# Redis主机地址 +Host = "localhost" +# Redis端口 Port = 6379 -Password = "hello" -DB = 0 \ No newline at end of file +# Redis密码(可选) +Password = "" +# 数据库编号 +DB = 0 +# 连接池配置(可选) +PoolSize = 50 +MinIdleConns = 10 +MaxRetries = 3 +# 超时配置(可选) +DialTimeout = "5s" +ReadTimeout = "3s" +WriteTimeout = "3s" + +# ========================= +# 日志配置 +# ========================= +[Log] +# 日志级别:debug | info | warn | error +Level = "info" +# 日志格式:json | text +Format = "json" +# 输出文件(可选,未配置则输出到控制台) +# Output = "./logs/app.log" +# 是否启用调用者信息(文件名:行号) +EnableCaller = true \ No newline at end of file diff --git a/templates/project/database/migrations/-gitkeep b/templates/project/database/migrations/-gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/templates/project/docker-compose.yml.tpl b/templates/project/docker-compose.yml.tpl new file mode 100644 index 0000000..564f99b --- /dev/null +++ b/templates/project/docker-compose.yml.tpl @@ -0,0 +1,119 @@ +version: '3.8' + +services: + # PostgreSQL 数据库 + postgres: + image: postgres:15-alpine + container_name: {{.ProjectName}}-postgres + environment: + POSTGRES_DB: {{.ProjectName}} + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/init:/docker-entrypoint-initdb.d + networks: + - {{.ProjectName}}-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + # Redis 缓存 + redis: + image: redis:7-alpine + container_name: {{.ProjectName}}-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - {{.ProjectName}}-network + restart: unless-stopped + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + # 应用服务 + app: + build: + context: . + dockerfile: Dockerfile.dev + container_name: {{.ProjectName}}-app + environment: + - APP_MODE=development + - HTTP_PORT=8080 + - DB_HOST=postgres + - DB_PORT=5432 + - DB_NAME={{.ProjectName}} + - DB_USER=postgres + - DB_PASSWORD=password + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_DB=0 + - JWT_SECRET_KEY=your-secret-key-here + - HASHIDS_SALT=your-salt-here + ports: + - "8080:8080" + - "9090:9090" # 监控端口 + volumes: + - .:/app + - /app/vendor + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - {{.ProjectName}}-network + restart: unless-stopped + + # PgAdmin 数据库管理工具 + pgadmin: + image: dpage/pgadmin4:latest + container_name: {{.ProjectName}}-pgadmin + environment: + PGADMIN_DEFAULT_EMAIL: admin@example.com + PGADMIN_DEFAULT_PASSWORD: admin + ports: + - "8081:80" + volumes: + - pgadmin_data:/var/lib/pgadmin + networks: + - {{.ProjectName}}-network + restart: unless-stopped + profiles: + - tools + + # Redis 管理工具 + redis-commander: + image: rediscommander/redis-commander:latest + container_name: {{.ProjectName}}-redis-commander + environment: + REDIS_HOSTS: local:redis:6379 + ports: + - "8082:8081" + networks: + - {{.ProjectName}}-network + restart: unless-stopped + profiles: + - tools + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + pgadmin_data: + driver: local + +networks: + {{.ProjectName}}-network: + driver: bridge \ No newline at end of file diff --git a/templates/project/tests/README.md b/templates/project/tests/README.md new file mode 100644 index 0000000..91e86c2 --- /dev/null +++ b/templates/project/tests/README.md @@ -0,0 +1,288 @@ +# 测试指南 + +本项目的测试使用 **Convey 框架**,分为三个层次:单元测试、集成测试和端到端测试。 + +## 测试结构 + +``` +tests/ +├── setup_test.go # 测试设置和通用工具 +├── unit/ # 单元测试 +│ ├── config_test.go # 配置测试 +│ └── ... # 其他单元测试 +├── integration/ # 集成测试 +│ ├── database_test.go # 数据库集成测试 +│ └── ... # 其他集成测试 +└── e2e/ # 端到端测试 + ├── api_test.go # API 测试 + └── ... # 其他 E2E 测试 +``` + +## Convey 框架概述 + +Convey 是一个 BDD 风格的 Go 测试框架,提供直观的语法和丰富的断言。 + +### 核心概念 + +- **Convey**: 定义测试上下文,类似于 `Describe` 或 `Context` +- **So**: 断言函数,验证预期结果 +- **Reset**: 清理函数,在每个测试后执行 + +### 基本语法 + +```go +Convey("测试场景描述", t, func() { + Convey("当某个条件发生时", func() { + // 准备测试数据 + result := SomeFunction() + + Convey("那么应该得到预期结果", func() { + So(result, ShouldEqual, "expected") + }) + }) + + Reset(func() { + // 清理测试数据 + }) +}) +``` + +## 运行测试 + +### 运行所有测试 +```bash +go test ./tests/... -v +``` + +### 运行特定类型的测试 +```bash +# 单元测试 +go test ./tests/unit/... -v + +# 集成测试 +go test ./tests/integration/... -v + +# 端到端测试 +go test ./tests/e2e/... -v +``` + +### 运行带覆盖率报告的测试 +```bash +go test ./tests/... -v -coverprofile=coverage.out +go tool cover -html=coverage.out -o coverage.html +``` + +### 运行基准测试 +```bash +go test ./tests/... -bench=. -v +``` + +## 测试环境配置 + +### 单元测试 +- 不需要外部依赖 +- 使用内存数据库或模拟对象 +- 快速执行 + +### 集成测试 +- 需要数据库连接 +- 使用测试数据库 `{{.ProjectName}}_test` +- 需要启动 Redis 等服务 + +### 端到端测试 +- 需要完整的应用环境 +- 测试真实的 HTTP 请求 +- 可能需要 Docker 环境 + +## Convey 测试最佳实践 + +### 1. 测试结构设计 +- 使用描述性的中文场景描述 +- 遵循 `当...那么...` 的语义结构 +- 嵌套 Convey 块来组织复杂测试逻辑 + +```go +Convey("用户认证测试", t, func() { + var user *User + var token string + + Convey("当用户注册时", func() { + user = &User{Name: "测试用户", Email: "test@example.com"} + err := user.Register() + So(err, ShouldBeNil) + + Convey("那么用户应该被创建", func() { + So(user.ID, ShouldBeGreaterThan, 0) + }) + }) + + Convey("当用户登录时", func() { + token, err := user.Login("password") + So(err, ShouldBeNil) + So(token, ShouldNotBeEmpty) + + Convey("那么应该获得有效的访问令牌", func() { + So(len(token), ShouldBeGreaterThan, 0) + }) + }) +}) +``` + +### 2. 断言使用 +- 使用丰富的 So 断言函数 +- 提供有意义的错误消息 +- 验证所有重要的方面 + +### 3. 数据管理 +- 使用 `Reset` 函数进行清理 +- 每个测试独立准备数据 +- 确保测试间不相互影响 + +### 4. 异步测试 +- 使用适当的超时设置 +- 处理并发测试 +- 使用 channel 进行同步 + +### 5. 错误处理 +- 测试错误情况 +- 验证错误消息 +- 确保错误处理逻辑正确 + +## 常用 Convey 断言 + +### 相等性断言 +```go +So(value, ShouldEqual, expected) +So(value, ShouldNotEqual, expected) +So(value, ShouldResemble, expected) // 深度比较 +So(value, ShouldNotResemble, expected) +``` + +### 类型断言 +```go +So(value, ShouldBeNil) +So(value, ShouldNotBeNil) +So(value, ShouldBeTrue) +So(value, ShouldBeFalse) +So(value, ShouldBeZeroValue) +``` + +### 数值断言 +```go +So(value, ShouldBeGreaterThan, expected) +So(value, ShouldBeLessThan, expected) +So(value, ShouldBeBetween, lower, upper) +``` + +### 集合断言 +```go +So(slice, ShouldHaveLength, expected) +So(slice, ShouldContain, expected) +So(slice, ShouldNotContain, expected) +So(map, ShouldContainKey, key) +``` + +### 字符串断言 +```go +So(str, ShouldContainSubstring, substr) +So(str, ShouldStartWith, prefix) +So(str, ShouldEndWith, suffix) +So(str, ShouldMatch, regexp) +``` + +### 错误断言 +```go +So(err, ShouldBeNil) +So(err, ShouldNotBeNil) +So(err, ShouldError, expectedError) +``` + +## 测试工具 + +- `goconvey/convey` - BDD 测试框架 +- `gomock` - Mock 生成器 +- `httptest` - HTTP 测试 +- `sqlmock` - 数据库 mock +- `testify` - 辅助测试工具(可选) + +## 测试示例 + +### 配置测试示例 +```go +Convey("配置加载测试", t, func() { + var config *Config + + Convey("当从文件加载配置时", func() { + config, err := LoadConfig("config.toml") + So(err, ShouldBeNil) + So(config, ShouldNotBeNil) + + Convey("那么配置应该正确加载", func() { + So(config.App.Mode, ShouldEqual, "development") + So(config.Http.Port, ShouldEqual, 8080) + }) + }) +}) +``` + +### 数据库测试示例 +```go +Convey("数据库操作测试", t, func() { + var db *gorm.DB + + Convey("当连接数据库时", func() { + db = SetupTestDB() + So(db, ShouldNotBeNil) + + Convey("那么应该能够创建记录", func() { + user := User{Name: "测试用户", Email: "test@example.com"} + result := db.Create(&user) + So(result.Error, ShouldBeNil) + So(user.ID, ShouldBeGreaterThan, 0) + }) + }) + + Reset(func() { + if db != nil { + CleanupTestDB(db) + } + }) +}) +``` + +### API 测试示例 +```go +Convey("API 端点测试", t, func() { + var server *httptest.Server + + Convey("当启动测试服务器时", func() { + server = httptest.NewServer(NewApp()) + So(server, ShouldNotBeNil) + + Convey("那么健康检查端点应该正常工作", func() { + resp, err := http.Get(server.URL + "/health") + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + So(result["status"], ShouldEqual, "ok") + }) + }) + + Reset(func() { + if server != nil { + server.Close() + } + }) +}) +``` + +## CI/CD 集成 + +测试会在以下情况下自动运行: +- 代码提交时 +- 创建 Pull Request 时 +- 合并到主分支时 + +测试结果会影响代码合并决策。Convey 的详细输出有助于快速定位问题。 \ No newline at end of file diff --git a/templates/project/tests/e2e/api_test.go b/templates/project/tests/e2e/api_test.go new file mode 100644 index 0000000..d3b832f --- /dev/null +++ b/templates/project/tests/e2e/api_test.go @@ -0,0 +1,419 @@ +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "{{.ModuleName}}/app" + "{{.ModuleName}}/app/config" + . "github.com/smartystreets/goconvey/convey" +) + +// TestAPIHealth 测试 API 健康检查 +func TestAPIHealth(t *testing.T) { + Convey("API 健康检查测试", t, func() { + var server *httptest.Server + var testConfig *config.Config + + Convey("当启动测试服务器时", func() { + testConfig = &config.Config{ + App: config.AppConfig{ + Mode: "test", + BaseURI: "http://localhost:8080", + }, + Http: config.HttpConfig{ + Port: 8080, + }, + Log: config.LogConfig{ + Level: "debug", + Format: "text", + EnableCaller: true, + }, + } + + app := app.New(testConfig) + server = httptest.NewServer(app) + + Convey("服务器应该成功启动", func() { + So(server, ShouldNotBeNil) + So(server.URL, ShouldNotBeEmpty) + }) + }) + + Convey("当访问健康检查端点时", func() { + resp, err := http.Get(server.URL + "/health") + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + + defer resp.Body.Close() + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + So(err, ShouldBeNil) + + Convey("响应应该包含正确的状态", func() { + So(result["status"], ShouldEqual, "ok") + }) + + Convey("响应应该包含时间戳", func() { + So(result, ShouldContainKey, "timestamp") + }) + + Convey("响应应该是 JSON 格式", func() { + So(resp.Header.Get("Content-Type"), ShouldEqual, "application/json; charset=utf-8") + }) + }) + + Convey("当访问不存在的端点时", func() { + resp, err := http.Get(server.URL + "/api/nonexistent") + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) + + defer resp.Body.Close() + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + So(err, ShouldBeNil) + + Convey("响应应该包含错误信息", func() { + So(result, ShouldContainKey, "error") + }) + }) + + Convey("当测试 CORS 支持", func() { + req, err := http.NewRequest("OPTIONS", server.URL+"/api/test", nil) + So(err, ShouldBeNil) + + req.Header.Set("Origin", "http://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Headers", "Content-Type,Authorization") + + resp, err := http.DefaultClient.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + + Convey("应该返回正确的 CORS 头", func() { + So(resp.StatusCode, ShouldEqual, http.StatusOK) + So(resp.Header.Get("Access-Control-Allow-Origin"), ShouldContainSubstring, "localhost") + So(resp.Header.Get("Access-Control-Allow-Methods"), ShouldContainSubstring, "POST") + }) + }) + + Reset(func() { + if server != nil { + server.Close() + } + }) + }) +} + +// TestAPIPerformance 测试 API 性能 +func TestAPIPerformance(t *testing.T) { + Convey("API 性能测试", t, func() { + var server *httptest.Server + var testConfig *config.Config + + Convey("当准备性能测试时", func() { + testConfig = &config.Config{ + App: config.AppConfig{ + Mode: "test", + BaseURI: "http://localhost:8080", + }, + Http: config.HttpConfig{ + Port: 8080, + }, + Log: config.LogConfig{ + Level: "error", // 减少日志输出以提升性能 + Format: "text", + }, + } + + app := app.New(testConfig) + server = httptest.NewServer(app) + }) + + Convey("当测试响应时间时", func() { + start := time.Now() + resp, err := http.Get(server.URL + "/health") + So(err, ShouldBeNil) + defer resp.Body.Close() + + duration := time.Since(start) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + + Convey("响应时间应该在合理范围内", func() { + So(duration, ShouldBeLessThan, 100*time.Millisecond) + }) + }) + + Convey("当测试并发请求时", func() { + const numRequests = 50 + const maxConcurrency = 10 + const timeout = 5 * time.Second + + var wg sync.WaitGroup + successCount := 0 + errorCount := 0 + var mu sync.Mutex + + // 使用信号量控制并发数 + sem := make(chan struct{}, maxConcurrency) + + start := time.Now() + + for i := 0; i < numRequests; i++ { + wg.Add(1) + go func(requestID int) { + defer wg.Done() + + // 获取信号量 + sem <- struct{}{} + defer func() { <-sem }() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", server.URL+"/health", nil) + if err != nil { + mu.Lock() + errorCount++ + mu.Unlock() + return + } + + client := &http.Client{ + Timeout: timeout, + } + + resp, err := client.Do(req) + if err != nil { + mu.Lock() + errorCount++ + mu.Unlock() + return + } + defer resp.Body.Close() + + mu.Lock() + if resp.StatusCode == http.StatusOK { + successCount++ + } else { + errorCount++ + } + mu.Unlock() + }(i) + } + + wg.Wait() + duration := time.Since(start) + + Convey("所有请求都应该完成", func() { + So(successCount+errorCount, ShouldEqual, numRequests) + }) + + Convey("所有请求都应该成功", func() { + So(errorCount, ShouldEqual, 0) + }) + + Convey("总耗时应该在合理范围内", func() { + So(duration, ShouldBeLessThan, 10*time.Second) + }) + + Convey("并发性能应该良好", func() { + avgTime := duration / numRequests + So(avgTime, ShouldBeLessThan, 200*time.Millisecond) + }) + }) + + Reset(func() { + if server != nil { + server.Close() + } + }) + }) +} + +// TestAPIBehavior 测试 API 行为 +func TestAPIBehavior(t *testing.T) { + Convey("API 行为测试", t, func() { + var server *httptest.Server + var testConfig *config.Config + + Convey("当准备行为测试时", func() { + testConfig = &config.Config{ + App: config.AppConfig{ + Mode: "test", + BaseURI: "http://localhost:8080", + }, + Http: config.HttpConfig{ + Port: 8080, + }, + Log: config.LogConfig{ + Level: "debug", + Format: "text", + EnableCaller: true, + }, + } + + app := app.New(testConfig) + server = httptest.NewServer(app) + }) + + Convey("当测试不同 HTTP 方法时", func() { + testURL := server.URL + "/health" + + Convey("GET 请求应该成功", func() { + resp, err := http.Get(testURL) + So(err, ShouldBeNil) + defer resp.Body.Close() + So(resp.StatusCode, ShouldEqual, http.StatusOK) + }) + + Convey("POST 请求应该被处理", func() { + resp, err := http.Post(testURL, "application/json", bytes.NewBuffer([]byte{})) + So(err, ShouldBeNil) + defer resp.Body.Close() + // 健康检查端点通常支持所有方法 + So(resp.StatusCode, ShouldBeIn, []int{http.StatusOK, http.StatusMethodNotAllowed}) + }) + + Convey("PUT 请求应该被处理", func() { + req, err := http.NewRequest("PUT", testURL, bytes.NewBuffer([]byte{})) + So(err, ShouldBeNil) + resp, err := http.DefaultClient.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + So(resp.StatusCode, ShouldBeIn, []int{http.StatusOK, http.StatusMethodNotAllowed}) + }) + + Convey("DELETE 请求应该被处理", func() { + req, err := http.NewRequest("DELETE", testURL, nil) + So(err, ShouldBeNil) + resp, err := http.DefaultClient.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + So(resp.StatusCode, ShouldBeIn, []int{http.StatusOK, http.StatusMethodNotAllowed}) + }) + }) + + Convey("当测试自定义请求头时", func() { + req, err := http.NewRequest("GET", server.URL+"/health", nil) + So(err, ShouldBeNil) + + // 设置各种请求头 + req.Header.Set("User-Agent", "E2E-Test-Agent/1.0") + req.Header.Set("Accept", "application/json") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + req.Header.Set("X-Custom-Header", "test-value") + req.Header.Set("X-Request-ID", "test-request-123") + req.Header.Set("Authorization", "Bearer test-token") + + resp, err := http.DefaultClient.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + + Convey("请求应该成功", func() { + So(resp.StatusCode, ShouldEqual, http.StatusOK) + }) + + Convey("响应应该是 JSON 格式", func() { + So(resp.Header.Get("Content-Type"), ShouldEqual, "application/json; charset=utf-8") + }) + }) + + Convey("当测试错误处理时", func() { + Convey("访问不存在的路径应该返回 404", func() { + resp, err := http.Get(server.URL + "/api/v1/nonexistent") + So(err, ShouldBeNil) + defer resp.Body.Close() + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) + }) + + Convey("访问非法路径应该返回 404", func() { + resp, err := http.Get(server.URL + "/../etc/passwd") + So(err, ShouldBeNil) + defer resp.Body.Close() + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) + }) + }) + + Reset(func() { + if server != nil { + server.Close() + } + }) + }) +} + +// TestAPIDocumentation 测试 API 文档 +func TestAPIDocumentation(t *testing.T) { + Convey("API 文档测试", t, func() { + var server *httptest.Server + var testConfig *config.Config + + Convey("当准备文档测试时", func() { + testConfig = &config.Config{ + App: config.AppConfig{ + Mode: "test", + BaseURI: "http://localhost:8080", + }, + Http: config.HttpConfig{ + Port: 8080, + }, + Log: config.LogConfig{ + Level: "debug", + Format: "text", + EnableCaller: true, + }, + } + + app := app.New(testConfig) + server = httptest.NewServer(app) + }) + + Convey("当访问 Swagger UI 时", func() { + resp, err := http.Get(server.URL + "/swagger/index.html") + So(err, ShouldBeNil) + defer resp.Body.Close() + + Convey("应该能够访问 Swagger UI", func() { + So(resp.StatusCode, ShouldEqual, http.StatusOK) + }) + + Convey("响应应该是 HTML 格式", func() { + contentType := resp.Header.Get("Content-Type") + So(contentType, ShouldContainSubstring, "text/html") + }) + }) + + Convey("当访问 OpenAPI 规范时", func() { + resp, err := http.Get(server.URL + "/swagger/doc.json") + So(err, ShouldBeNil) + defer resp.Body.Close() + + Convey("应该能够访问 OpenAPI 规范", func() { + // 如果存在则返回 200,不存在则返回 404 + So(resp.StatusCode, ShouldBeIn, []int{http.StatusOK, http.StatusNotFound}) + }) + + Convey("如果存在,响应应该是 JSON 格式", func() { + if resp.StatusCode == http.StatusOK { + contentType := resp.Header.Get("Content-Type") + So(contentType, ShouldContainSubstring, "application/json") + } + }) + }) + + Reset(func() { + if server != nil { + server.Close() + } + }) + }) +} \ No newline at end of file diff --git a/templates/project/tests/integration/database_test.go b/templates/project/tests/integration/database_test.go new file mode 100644 index 0000000..a283c87 --- /dev/null +++ b/templates/project/tests/integration/database_test.go @@ -0,0 +1,364 @@ +package integration + +import ( + "context" + "database/sql" + "testing" + "time" + + "{{.ModuleName}}/app/config" + "{{.ModuleName}}/app/database" + . "github.com/smartystreets/goconvey/convey" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// TestUser 测试用户模型 +type TestUser struct { + ID int `gorm:"primaryKey"` + Name string `gorm:"size:100;not null"` + Email string `gorm:"size:100;unique;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +// TestDatabaseConnection 测试数据库连接 +func TestDatabaseConnection(t *testing.T) { + Convey("数据库连接测试", t, func() { + var db *gorm.DB + var sqlDB *sql.DB + var testConfig *config.Config + var testDBName string + + Convey("当准备测试数据库时", func() { + testDBName = "{{.ProjectName}}_test_integration" + testConfig = &config.Config{ + Database: config.DatabaseConfig{ + Host: "localhost", + Port: 5432, + Database: testDBName, + Username: "postgres", + Password: "password", + SslMode: "disable", + MaxIdleConns: 5, + MaxOpenConns: 20, + ConnMaxLifetime: 30 * time.Minute, + }, + } + + Convey("应该能够连接到数据库", func() { + dsn := testConfig.Database.GetDSN() + var err error + db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + So(err, ShouldBeNil) + So(db, ShouldNotBeNil) + + sqlDB, err = db.DB() + So(err, ShouldBeNil) + So(sqlDB, ShouldNotBeNil) + + // 设置连接池 + sqlDB.SetMaxIdleConns(testConfig.Database.MaxIdleConns) + sqlDB.SetMaxOpenConns(testConfig.Database.MaxOpenConns) + sqlDB.SetConnMaxLifetime(testConfig.Database.ConnMaxLifetime) + + // 测试连接 + err = sqlDB.Ping() + So(err, ShouldBeNil) + }) + + Convey("应该能够创建测试表", func() { + err := db.Exec(` + CREATE TABLE IF NOT EXISTS integration_test_users ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `).Error + So(err, ShouldBeNil) + }) + }) + + Convey("当测试数据库操作时", func() { + Convey("应该能够创建记录", func() { + user := TestUser{ + Name: "Integration Test User", + Email: "integration@example.com", + } + + result := db.Create(&user) + So(result.Error, ShouldBeNil) + So(result.RowsAffected, ShouldEqual, 1) + So(user.ID, ShouldBeGreaterThan, 0) + }) + + Convey("应该能够查询记录", func() { + // 先插入测试数据 + user := TestUser{ + Name: "Query Test User", + Email: "query@example.com", + } + db.Create(&user) + + // 查询记录 + var result TestUser + err := db.First(&result, "email = ?", "query@example.com").Error + So(err, ShouldBeNil) + So(result.Name, ShouldEqual, "Query Test User") + So(result.Email, ShouldEqual, "query@example.com") + }) + + Convey("应该能够更新记录", func() { + // 先插入测试数据 + user := TestUser{ + Name: "Update Test User", + Email: "update@example.com", + } + db.Create(&user) + + // 更新记录 + result := db.Model(&user).Update("name", "Updated Integration User") + So(result.Error, ShouldBeNil) + So(result.RowsAffected, ShouldEqual, 1) + + // 验证更新 + var updatedUser TestUser + err := db.First(&updatedUser, user.ID).Error + So(err, ShouldBeNil) + So(updatedUser.Name, ShouldEqual, "Updated Integration User") + }) + + Convey("应该能够删除记录", func() { + // 先插入测试数据 + user := TestUser{ + Name: "Delete Test User", + Email: "delete@example.com", + } + db.Create(&user) + + // 删除记录 + result := db.Delete(&user) + So(result.Error, ShouldBeNil) + So(result.RowsAffected, ShouldEqual, 1) + + // 验证删除 + var deletedUser TestUser + err := db.First(&deletedUser, user.ID).Error + So(err, ShouldEqual, gorm.ErrRecordNotFound) + }) + }) + + Convey("当测试事务时", func() { + Convey("应该能够执行事务操作", func() { + // 开始事务 + tx := db.Begin() + So(tx, ShouldNotBeNil) + + // 在事务中插入数据 + user := TestUser{ + Name: "Transaction Test User", + Email: "transaction@example.com", + } + result := tx.Create(&user) + So(result.Error, ShouldBeNil) + So(result.RowsAffected, ShouldEqual, 1) + + // 查询事务中的数据 + var count int64 + tx.Model(&TestUser{}).Count(&count) + So(count, ShouldEqual, 1) + + // 提交事务 + err := tx.Commit().Error + So(err, ShouldBeNil) + + // 验证数据已提交 + db.Model(&TestUser{}).Count(&count) + So(count, ShouldBeGreaterThan, 0) + }) + + Convey("应该能够回滚事务", func() { + // 开始事务 + tx := db.Begin() + + // 在事务中插入数据 + user := TestUser{ + Name: "Rollback Test User", + Email: "rollback@example.com", + } + tx.Create(&user) + + // 回滚事务 + err := tx.Rollback().Error + So(err, ShouldBeNil) + + // 验证数据已回滚 + var count int64 + db.Model(&TestUser{}).Where("email = ?", "rollback@example.com").Count(&count) + So(count, ShouldEqual, 0) + }) + }) + + Convey("当测试批量操作时", func() { + Convey("应该能够批量插入记录", func() { + users := []TestUser{ + {Name: "Batch User 1", Email: "batch1@example.com"}, + {Name: "Batch User 2", Email: "batch2@example.com"}, + {Name: "Batch User 3", Email: "batch3@example.com"}, + } + + result := db.Create(&users) + So(result.Error, ShouldBeNil) + So(result.RowsAffected, ShouldEqual, 3) + + // 验证批量插入 + var count int64 + db.Model(&TestUser{}).Where("email LIKE ?", "batch%@example.com").Count(&count) + So(count, ShouldEqual, 3) + }) + + Convey("应该能够批量更新记录", func() { + // 先插入测试数据 + users := []TestUser{ + {Name: "Batch Update 1", Email: "batchupdate1@example.com"}, + {Name: "Batch Update 2", Email: "batchupdate2@example.com"}, + } + db.Create(&users) + + // 批量更新 + result := db.Model(&TestUser{}). + Where("email LIKE ?", "batchupdate%@example.com"). + Update("name", "Batch Updated User") + So(result.Error, ShouldBeNil) + So(result.RowsAffected, ShouldEqual, 2) + + // 验证更新 + var updatedCount int64 + db.Model(&TestUser{}). + Where("name = ?", "Batch Updated User"). + Count(&updatedCount) + So(updatedCount, ShouldEqual, 2) + }) + }) + + Convey("当测试查询条件时", func() { + Convey("应该能够使用各种查询条件", func() { + // 插入测试数据 + testUsers := []TestUser{ + {Name: "Alice", Email: "alice@example.com"}, + {Name: "Bob", Email: "bob@example.com"}, + {Name: "Charlie", Email: "charlie@example.com"}, + {Name: "Alice Smith", Email: "alice.smith@example.com"}, + } + db.Create(&testUsers) + + Convey("应该能够使用 LIKE 查询", func() { + var users []TestUser + err := db.Where("name LIKE ?", "Alice%").Find(&users).Error + So(err, ShouldBeNil) + So(len(users), ShouldEqual, 2) + }) + + Convey("应该能够使用 IN 查询", func() { + var users []TestUser + err := db.Where("name IN ?", []string{"Alice", "Bob"}).Find(&users).Error + So(err, ShouldBeNil) + So(len(users), ShouldEqual, 2) + }) + + Convey("应该能够使用 BETWEEN 查询", func() { + var users []TestUser + err := db.Where("id BETWEEN ? AND ?", 1, 3).Find(&users).Error + So(err, ShouldBeNil) + So(len(users), ShouldBeGreaterThan, 0) + }) + + Convey("应该能够使用多条件查询", func() { + var users []TestUser + err := db.Where("name LIKE ? AND email LIKE ?", "%Alice%", "%example.com").Find(&users).Error + So(err, ShouldBeNil) + So(len(users), ShouldEqual, 2) + }) + }) + }) + + Reset(func() { + // 清理测试表 + if db != nil { + db.Exec("DROP TABLE IF EXISTS integration_test_users") + } + // 关闭数据库连接 + if sqlDB != nil { + sqlDB.Close() + } + }) + }) +} + +// TestDatabaseConnectionPool 测试数据库连接池 +func TestDatabaseConnectionPool(t *testing.T) { + Convey("数据库连接池测试", t, func() { + var db *gorm.DB + var sqlDB *sql.DB + + Convey("当配置连接池时", func() { + testConfig := &config.Config{ + Database: config.DatabaseConfig{ + Host: "localhost", + Port: 5432, + Database: "{{.ProjectName}}_test_pool", + Username: "postgres", + Password: "password", + SslMode: "disable", + MaxIdleConns: 5, + MaxOpenConns: 10, + ConnMaxLifetime: 5 * time.Minute, + }, + } + + dsn := testConfig.Database.GetDSN() + var err error + db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + So(err, ShouldBeNil) + + sqlDB, err = db.DB() + So(err, ShouldBeNil) + + Convey("应该能够设置连接池参数", func() { + sqlDB.SetMaxIdleConns(testConfig.Database.MaxIdleConns) + sqlDB.SetMaxOpenConns(testConfig.Database.MaxOpenConns) + sqlDB.SetConnMaxLifetime(testConfig.Database.ConnMaxLifetime) + + // 验证设置 + stats := sqlDB.Stats() + So(stats.MaxOpenConns, ShouldEqual, testConfig.Database.MaxOpenConns) + So(stats.MaxIdleConns, ShouldEqual, testConfig.Database.MaxIdleConns) + }) + + Convey("应该能够监控连接池状态", func() { + // 获取初始状态 + initialStats := sqlDB.Stats() + So(initialStats.OpenConnections, ShouldEqual, 0) + + // 执行一些查询来创建连接 + for i := 0; i < 3; i++ { + sqlDB.Ping() + } + + // 获取使用后的状态 + afterStats := sqlDB.Stats() + So(afterStats.OpenConnections, ShouldBeGreaterThan, 0) + So(afterStats.InUse, ShouldBeGreaterThan, 0) + }) + }) + + Reset(func() { + // 关闭数据库连接 + if sqlDB != nil { + sqlDB.Close() + } + }) + }) +} \ No newline at end of file diff --git a/templates/project/tests/setup_test.go b/templates/project/tests/setup_test.go new file mode 100644 index 0000000..fba2748 --- /dev/null +++ b/templates/project/tests/setup_test.go @@ -0,0 +1,161 @@ +package tests + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +// TestMain 测试入口点 +func TestMain(m *testing.M) { + // 运行测试 + m.Run() +} + +// TestSetup 测试基础设置 +func TestSetup(t *testing.T) { + Convey("测试基础设置", t, func() { + Convey("当初始化测试环境时", func() { + // 初始化测试环境 + testEnv := &TestEnvironment{ + Name: "test-env", + Version: "1.0.0", + } + + Convey("那么测试环境应该被正确创建", func() { + So(testEnv.Name, ShouldEqual, "test-env") + So(testEnv.Version, ShouldEqual, "1.0.0") + }) + }) + }) +} + +// TestEnvironment 测试环境结构 +type TestEnvironment struct { + Name string + Version string + Config map[string]interface{} +} + +// NewTestEnvironment 创建新的测试环境 +func NewTestEnvironment(name string) *TestEnvironment { + return &TestEnvironment{ + Name: name, + Config: make(map[string]interface{}), + } +} + +// WithConfig 设置配置 +func (e *TestEnvironment) WithConfig(key string, value interface{}) *TestEnvironment { + e.Config[key] = value + return e +} + +// GetConfig 获取配置 +func (e *TestEnvironment) GetConfig(key string) interface{} { + return e.Config[key] +} + +// Setup 设置测试环境 +func (e *TestEnvironment) Setup() *TestEnvironment { + // 初始化测试环境 + e.Config["initialized"] = true + return e +} + +// Cleanup 清理测试环境 +func (e *TestEnvironment) Cleanup() { + // 清理测试环境 + e.Config = make(map[string]interface{}) +} + +// TestEnvironmentManagement 测试环境管理 +func TestEnvironmentManagement(t *testing.T) { + Convey("测试环境管理", t, func() { + var env *TestEnvironment + + Convey("当创建新测试环境时", func() { + env = NewTestEnvironment("test-app") + + Convey("那么环境应该有正确的名称", func() { + So(env.Name, ShouldEqual, "test-app") + So(env.Version, ShouldBeEmpty) + So(env.Config, ShouldNotBeNil) + }) + }) + + Convey("当设置配置时", func() { + env.WithConfig("debug", true) + env.WithConfig("port", 8080) + + Convey("那么配置应该被正确设置", func() { + So(env.GetConfig("debug"), ShouldEqual, true) + So(env.GetConfig("port"), ShouldEqual, 8080) + }) + }) + + Convey("当初始化环境时", func() { + env.Setup() + + Convey("那么环境应该被标记为已初始化", func() { + So(env.GetConfig("initialized"), ShouldEqual, true) + }) + }) + + Reset(func() { + if env != nil { + env.Cleanup() + } + }) + }) +} + +// TestConveyBasicUsage 测试 Convey 基础用法 +func TestConveyBasicUsage(t *testing.T) { + Convey("Convey 基础用法测试", t, func() { + Convey("数字操作", func() { + num := 42 + + Convey("应该能够进行基本比较", func() { + So(num, ShouldEqual, 42) + So(num, ShouldBeGreaterThan, 0) + So(num, ShouldBeLessThan, 100) + }) + }) + + Convey("字符串操作", func() { + str := "hello world" + + Convey("应该能够进行字符串比较", func() { + So(str, ShouldEqual, "hello world") + So(str, ShouldContainSubstring, "hello") + So(str, ShouldStartWith, "hello") + So(str, ShouldEndWith, "world") + }) + }) + + Convey("切片操作", func() { + slice := []int{1, 2, 3, 4, 5} + + Convey("应该能够进行切片操作", func() { + So(slice, ShouldHaveLength, 5) + So(slice, ShouldContain, 3) + So(slice, ShouldNotContain, 6) + }) + }) + + Convey("Map 操作", func() { + m := map[string]interface{}{ + "name": "test", + "value": 123, + } + + Convey("应该能够进行 Map 操作", func() { + So(m, ShouldContainKey, "name") + So(m, ShouldContainKey, "value") + So(m["name"], ShouldEqual, "test") + So(m["value"], ShouldEqual, 123) + }) + }) + }) +} \ No newline at end of file diff --git a/templates/project/tests/unit/config_test.go b/templates/project/tests/unit/config_test.go new file mode 100644 index 0000000..13fc95a --- /dev/null +++ b/templates/project/tests/unit/config_test.go @@ -0,0 +1,287 @@ +package unit + +import ( + "os" + "path/filepath" + "testing" + + "{{.ModuleName}}/app/config" + . "github.com/smartystreets/goconvey/convey" +) + +// TestConfigLoading 测试配置加载功能 +func TestConfigLoading(t *testing.T) { + Convey("配置加载测试", t, func() { + var testConfig *config.Config + var configPath string + var testDir string + + Convey("当准备测试配置文件时", func() { + originalWd, _ := os.Getwd() + testDir = filepath.Join(originalWd, "..", "..", "fixtures", "test_config") + + Convey("应该创建测试配置目录", func() { + err := os.MkdirAll(testDir, 0755) + So(err, ShouldBeNil) + }) + + Convey("应该创建测试配置文件", func() { + testConfigContent := `App: + Mode: "test" + BaseURI: "http://localhost:8080" +Http: + Port: 8080 +Database: + Host: "localhost" + Port: 5432 + Database: "test_db" + Username: "test_user" + Password: "test_password" + SslMode: "disable" +Log: + Level: "debug" + Format: "text" + EnableCaller: true` + + configPath = filepath.Join(testDir, "config.toml") + err := os.WriteFile(configPath, []byte(testConfigContent), 0644) + So(err, ShouldBeNil) + }) + + Convey("应该成功加载配置", func() { + var err error + testConfig, err = config.Load(configPath) + So(err, ShouldBeNil) + So(testConfig, ShouldNotBeNil) + }) + }) + + Convey("验证配置内容", func() { + So(testConfig, ShouldNotBeNil) + + Convey("应用配置应该正确", func() { + So(testConfig.App.Mode, ShouldEqual, "test") + So(testConfig.App.BaseURI, ShouldEqual, "http://localhost:8080") + }) + + Convey("HTTP配置应该正确", func() { + So(testConfig.Http.Port, ShouldEqual, 8080) + }) + + Convey("数据库配置应该正确", func() { + So(testConfig.Database.Host, ShouldEqual, "localhost") + So(testConfig.Database.Port, ShouldEqual, 5432) + So(testConfig.Database.Database, ShouldEqual, "test_db") + So(testConfig.Database.Username, ShouldEqual, "test_user") + So(testConfig.Database.Password, ShouldEqual, "test_password") + So(testConfig.Database.SslMode, ShouldEqual, "disable") + }) + + Convey("日志配置应该正确", func() { + So(testConfig.Log.Level, ShouldEqual, "debug") + So(testConfig.Log.Format, ShouldEqual, "text") + So(testConfig.Log.EnableCaller, ShouldBeTrue) + }) + }) + + Reset(func() { + // 清理测试文件 + if testDir != "" { + os.RemoveAll(testDir) + } + }) + }) +} + +// TestConfigFromEnvironment 测试从环境变量加载配置 +func TestConfigFromEnvironment(t *testing.T) { + Convey("环境变量配置测试", t, func() { + var originalEnvVars map[string]string + + Convey("当设置环境变量时", func() { + // 保存原始环境变量 + originalEnvVars = map[string]string{ + "APP_MODE": os.Getenv("APP_MODE"), + "HTTP_PORT": os.Getenv("HTTP_PORT"), + "DB_HOST": os.Getenv("DB_HOST"), + } + + // 设置测试环境变量 + os.Setenv("APP_MODE", "test") + os.Setenv("HTTP_PORT", "9090") + os.Setenv("DB_HOST", "test-host") + + Convey("环境变量应该被正确设置", func() { + So(os.Getenv("APP_MODE"), ShouldEqual, "test") + So(os.Getenv("HTTP_PORT"), ShouldEqual, "9090") + So(os.Getenv("DB_HOST"), ShouldEqual, "test-host") + }) + }) + + Convey("当从环境变量加载配置时", func() { + originalWd, _ := os.Getwd() + testDir := filepath.Join(originalWd, "..", "..", "fixtures", "test_config_env") + + Convey("应该创建测试配置目录", func() { + err := os.MkdirAll(testDir, 0755) + So(err, ShouldBeNil) + }) + + Convey("应该创建基础配置文件", func() { + testConfigContent := `App: + Mode: "development" + BaseURI: "http://localhost:3000" +Http: + Port: 3000 +Database: + Host: "localhost" + Port: 5432 + Database: "default_db" + Username: "default_user" + Password: "default_password" + SslMode: "disable"` + + configPath := filepath.Join(testDir, "config.toml") + err := os.WriteFile(configPath, []byte(testConfigContent), 0644) + So(err, ShouldBeNil) + }) + + Convey("应该成功加载并合并配置", func() { + configPath := filepath.Join(testDir, "config.toml") + loadedConfig, err := config.Load(configPath) + + So(err, ShouldBeNil) + So(loadedConfig, ShouldNotBeNil) + + Convey("环境变量应该覆盖配置文件", func() { + So(loadedConfig.App.Mode, ShouldEqual, "test") + So(loadedConfig.Http.Port, ShouldEqual, 9090) + So(loadedConfig.Database.Host, ShouldEqual, "test-host") + }) + + Convey("配置文件的默认值应该保留", func() { + So(loadedConfig.App.BaseURI, ShouldEqual, "http://localhost:3000") + So(loadedConfig.Database.Database, ShouldEqual, "default_db") + }) + }) + + Reset(func() { + // 清理测试目录 + os.RemoveAll(testDir) + }) + }) + + Reset(func() { + // 恢复原始环境变量 + if originalEnvVars != nil { + for key, value := range originalEnvVars { + if value == "" { + os.Unsetenv(key) + } else { + os.Setenv(key, value) + } + } + } + }) + }) +} + +// TestConfigValidation 测试配置验证 +func TestConfigValidation(t *testing.T) { + Convey("配置验证测试", t, func() { + Convey("当配置为空时", func() { + config := &config.Config{} + + Convey("应该检测到缺失的必需配置", func() { + So(config.App.Mode, ShouldBeEmpty) + So(config.Http.Port, ShouldEqual, 0) + So(config.Database.Host, ShouldBeEmpty) + }) + }) + + Convey("当配置端口无效时", func() { + config := &config.Config{ + Http: config.HttpConfig{ + Port: -1, + }, + } + + Convey("应该检测到无效端口", func() { + So(config.Http.Port, ShouldBeLessThan, 0) + }) + }) + + Convey("当配置模式有效时", func() { + validModes := []string{"development", "production", "testing"} + + for _, mode := range validModes { + config := &config.Config{ + App: config.AppConfig{ + Mode: mode, + }, + } + + Convey("模式 "+mode+" 应该是有效的", func() { + So(config.App.Mode, ShouldBeIn, validModes) + }) + } + }) + }) +} + +// TestConfigDefaults 测试配置默认值 +func TestConfigDefaults(t *testing.T) { + Convey("配置默认值测试", t, func() { + Convey("当创建新配置时", func() { + config := &config.Config{} + + Convey("应该有合理的默认值", func() { + // 测试应用的默认值 + So(config.App.Mode, ShouldEqual, "development") + + // 测试HTTP的默认值 + So(config.Http.Port, ShouldEqual, 8080) + + // 测试数据库的默认值 + So(config.Database.Port, ShouldEqual, 5432) + So(config.Database.SslMode, ShouldEqual, "disable") + + // 测试日志的默认值 + So(config.Log.Level, ShouldEqual, "info") + So(config.Log.Format, ShouldEqual, "json") + }) + }) + }) +} + +// TestConfigHelpers 测试配置辅助函数 +func TestConfigHelpers(t *testing.T) { + Convey("配置辅助函数测试", t, func() { + Convey("当使用配置辅助函数时", func() { + config := &config.Config{ + App: config.AppConfig{ + Mode: "production", + BaseURI: "https://api.example.com", + }, + Http: config.HttpConfig{ + Port: 443, + }, + } + + Convey("应该能够获取应用环境", func() { + env := config.App.Mode + So(env, ShouldEqual, "production") + }) + + Convey("应该能够构建完整URL", func() { + fullURL := config.App.BaseURI + "/api/v1/users" + So(fullURL, ShouldEqual, "https://api.example.com/api/v1/users") + }) + + Convey("应该能够判断HTTPS", func() { + isHTTPS := config.Http.Port == 443 + So(isHTTPS, ShouldBeTrue) + }) + }) + }) +} \ No newline at end of file