101 lines
3.5 KiB
Markdown
101 lines
3.5 KiB
Markdown
---
|
||
id: backend.go.testing-engineering
|
||
title: 测试和工程化:表格驱动测试、httptest、模块、项目结构
|
||
type: lesson
|
||
domain: backend
|
||
stack: [go]
|
||
tags: [Go, 测试, httptest, go mod, 项目结构]
|
||
level: intermediate
|
||
prerequisites: [backend.go.basics-types]
|
||
est_hours: 3
|
||
related: [backend.go.interview, backend.gin.routing-binding]
|
||
week: [1, 2, 3]
|
||
order: 3
|
||
---
|
||
|
||
# 测试和工程化:表格驱动测试、httptest、模块、项目结构
|
||
|
||
## 解释说明
|
||
|
||
- **go test**:`xxx_test.go` 里的 `TestXxx(t *testing.T)` 会被自动执行。`t.Run` 定义子测试;`-race` 检测数据竞争;`-cover` 看覆盖率;`BenchmarkXxx` 做性能测试。
|
||
- **表格驱动测试**:把多组“输入 + 期望”写成切片循环执行,是 Go 社区的惯例,新增用例只加一行。
|
||
- **httptest**:`httptest.NewRecorder()` 不启动端口直接测 handler;`httptest.NewServer` 启动一个真实的本地服务,用来模拟第三方接口。
|
||
- **模块**:`go mod init 模块名` 生成 `go.mod`;`go get` 加依赖;`go mod tidy` 清理。`go.sum` 记录依赖的校验和,要提交。
|
||
- **项目结构**(社区常见做法,不是强制标准):`cmd/<程序名>/main.go` 放入口;`internal/` 放不希望被其他模块导入的代码(编译器强制);按职责分包(`handler`、`service`、`store`),避免一个 `utils` 包装所有东西。
|
||
|
||
## 案例
|
||
|
||
接口层的测试一般这样分:handler 测试用 `httptest.NewRecorder` + 内存版 store,覆盖参数校验、状态码、鉴权;store 的集成测试连真实数据库(没有数据库时跳过);调用第三方的代码用 `httptest.NewServer` 模拟各种返回(超时、500、格式错误)。
|
||
|
||
## 代码示例
|
||
|
||
```go
|
||
// 文件:slug.go
|
||
package slug
|
||
|
||
import (
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
// Make 把标题转成 URL 片段:小写、空白和标点变成 -,去掉首尾 -
|
||
func Make(title string) string {
|
||
var b strings.Builder
|
||
dash := false
|
||
for _, r := range strings.ToLower(title) {
|
||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||
b.WriteRune(r)
|
||
dash = false
|
||
} else if !dash && b.Len() > 0 {
|
||
b.WriteByte('-')
|
||
dash = true
|
||
}
|
||
}
|
||
return strings.TrimSuffix(b.String(), "-")
|
||
}
|
||
```
|
||
|
||
```go
|
||
// 文件:slug_test.go
|
||
package slug
|
||
|
||
import "testing"
|
||
|
||
func TestMake(t *testing.T) {
|
||
cases := []struct {
|
||
name, in, want string
|
||
}{
|
||
{"普通", "Hello World", "hello-world"},
|
||
{"多个符号", "Go -- 并发!!", "go-并发"},
|
||
{"首尾空白", " Redis ", "redis"},
|
||
{"空", "", ""},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
if got := Make(c.in); got != c.want {
|
||
t.Fatalf("Make(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func BenchmarkMake(b *testing.B) {
|
||
for b.Loop() { // Go 1.24+ 的写法;旧版本用 for i := 0; i < b.N; i++
|
||
Make("Hello World From Go")
|
||
}
|
||
}
|
||
```
|
||
|
||
运行:`go mod init slug && go test -v -race -cover ./... && go test -bench=. ./...`
|
||
|
||
## 易错点
|
||
|
||
- 测试之间共享全局状态(全局 map、同一张表)会让测试结果依赖执行顺序;每个测试自己准备数据,`t.Cleanup` 清理。
|
||
- 只测“正常路径”:错误路径(参数错、下游失败、超时)才是线上出问题的地方。
|
||
- `internal` 包只能被它的父目录及子目录导入;别的模块导入会编译失败——这是故意的。
|
||
- 不提交 `go.sum` 会让别人构建时无法校验依赖。
|
||
|
||
## 面试常问
|
||
|
||
见 [Go 面试题](interview.md):怎么测试依赖数据库或第三方接口的代码、`internal` 的作用。
|