Compare commits

...

2 Commits

Author SHA1 Message Date
Rogee
daf93e7055 feat: 更新构建配置,添加构建信息打印功能并重构 Makefile 2025-09-23 17:23:44 +08:00
Rogee
b37b12884f 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.
2025-09-23 17:06:47 +08:00
19 changed files with 2435 additions and 25 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +1,5 @@
name: Build TGExporter
run-name: ${{ gitea.actor }} Build TGExporter
name: Build Application
run-name: ${{ gitea.actor }} Build Application
on: [push]
jobs:

View File

@@ -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

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -1,7 +1,8 @@
buildAt=`date +%Y/%m/%d-%H:%M:%S`
gitHash=`git rev-parse HEAD`
version=`git rev-parse --abbrev-ref HEAD | grep -v HEAD || git describe --exact-match HEAD || git rev-parse HEAD` ## todo: use current release git tag
flags="-X 'atom/utils.Version=${version}' -X 'atom/utils.BuildAt=${buildAt}' -X 'atom/utils.GitHash=${gitHash}'"
version=`git rev-parse --abbrev-ref HEAD | grep -v HEAD || git describe --exact-match HEAD || git rev-parse HEAD`
# 修改为项目特定的变量路径
flags="-X '{{.ModuleName}}/pkg/utils.Version=${version}' -X '{{.ModuleName}}/pkg/utils.BuildAt=${buildAt}' -X '{{.ModuleName}}/pkg/utils.GitHash=${gitHash}'"
release_flags="-w -s ${flags}"
GOPATH:=$(shell go env GOPATH)
@@ -15,9 +16,25 @@ release:
@CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=${flags} -o bin/release/{{.ProjectName}} .
@cp config.toml bin/release/
.PHONY: build
build:
@go build -ldflags=${flags} -o bin/{{.ProjectName}} .
.PHONY: run
run: build
@./bin/{{.ProjectName}}
.PHONY: test
test:
@go test -v ./... -cover
@go test -v ./tests/... -cover
.PHONY: info
info:
@echo "Build Information:"
@echo "=================="
@echo "Build Time: $(buildAt)"
@echo "Git Hash: $(gitHash)"
@echo "Version: $(version)"
.PHONY: lint
lint:
@@ -44,4 +61,4 @@ init: tools
@buf generate
@go mod tidy
@go get -u
@go mod tidy
@go mod tidy

View File

@@ -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
# 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

View File

@@ -0,0 +1,99 @@
# =========================
# 应用基础配置
# =========================
[App]
# 应用运行模式development | production | testing
Mode = "development"
# 应用基础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 = "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"
# 签发者(可选)
Issuer = "{{.ProjectName}}"
# =========================
# HashIDs 配置
# =========================
[HashIDs]
# 盐值用于ID加密请使用随机字符串
Salt = "your-random-salt-here"
# 最小长度(可选)
MinLength = 8
# 自定义字符集(可选)
# Alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
# =========================
# Redis 缓存配置
# =========================
[Redis]
# Redis主机地址
Host = "localhost"
# Redis端口
Port = 6379
# 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

View File

@@ -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

View File

@@ -3,6 +3,7 @@ package main
import (
"{{.ModuleName}}/app/commands/http"
"{{.ModuleName}}/app/commands/migrate"
"{{.ModuleName}}/pkg/utils"
log "github.com/sirupsen/logrus"
"go.ipao.vip/atom"
@@ -22,7 +23,11 @@ import (
// @securityDefinitions.basic BasicAuth
// @externalDocs.description OpenAPI
// @externalDocs.url https://swagger.io/resources/open-api/
func main() {
//
utils.PrintBuildInfo("{{.ProjectName}}")
opts := []atom.Option{
atom.Name("{{ .ProjectName }}"),
http.Command(),

View File

@@ -0,0 +1,44 @@
package utils
import "fmt"
// 构建信息变量,通过 ldflags 在构建时注入
var (
// Version 应用版本信息
Version string
// BuildAt 构建时间
BuildAt string
// GitHash Git 提交哈希
GitHash string
)
// GetBuildInfo 获取构建信息
func GetBuildInfo() map[string]string {
return map[string]string{
"version": Version,
"buildAt": BuildAt,
"gitHash": GitHash,
}
}
// PrintBuildInfo 打印构建信息
func PrintBuildInfo(appName string) {
buildInfo := GetBuildInfo()
println("========================================")
printf("🚀 %s\n", appName)
println("========================================")
printf("📋 Version: %s\n", buildInfo["version"])
printf("🕐 Build Time: %s\n", buildInfo["buildAt"])
printf("🔗 Git Hash: %s\n", buildInfo["gitHash"])
println("========================================")
println("🌟 Application is starting...")
println()
}
// 为了避免导入 fmt 包,我们使用内置的 print 和 printf 函数
func printf(format string, args ...interface{}) {
print(fmt.Sprintf(format, args...))
}

View File

@@ -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 的详细输出有助于快速定位问题。

View File

@@ -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()
}
})
})
}

View File

@@ -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()
}
})
})
}

View File

@@ -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)
})
})
})
}

View File

@@ -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)
})
})
})
}

View File

@@ -0,0 +1,44 @@
package utils
import "fmt"
// 构建信息变量,通过 ldflags 在构建时注入
var (
// Version 应用版本信息
Version string
// BuildAt 构建时间
BuildAt string
// GitHash Git 提交哈希
GitHash string
)
// GetBuildInfo 获取构建信息
func GetBuildInfo() map[string]string {
return map[string]string{
"version": Version,
"buildAt": BuildAt,
"gitHash": GitHash,
}
}
// PrintBuildInfo 打印构建信息
func PrintBuildInfo(appName string) {
buildInfo := GetBuildInfo()
println("========================================")
printf("🚀 %s\n", appName)
println("========================================")
printf("📋 Version: %s\n", buildInfo["version"])
printf("🕐 Build Time: %s\n", buildInfo["buildAt"])
printf("🔗 Git Hash: %s\n", buildInfo["gitHash"])
println("========================================")
println("🌟 Application is starting...")
println()
}
// 为了避免导入 fmt 包,我们使用内置的 print 和 printf 函数
func printf(format string, args ...interface{}) {
print(fmt.Sprintf(format, args...))
}