本日关键词(实战):go test、表驱动测试、httptest、ResponseRecorder、Dockerfile、多阶段构建、镜像体积
本日语法/概念(实战):
| 语法/概念 | 实战用途 | 本日示例 |
|---|---|---|
*_test.go、func TestXxx(t *testing.T) |
单元测试,CI 必跑,改代码不慌 | day6/handler 下 _test.go |
表驱动 []struct{...} + 循环 |
多用例一次写清,易扩展 | 测试多组 path + 期望状态码 |
httptest.NewRequest + httptest.NewRecorder |
测 HTTP Handler 不真起服务,速度快 | HTTP 接口测试 |
go test ./day6/... 里的 ... |
表示「该目录及所有子目录下的包」 | 否则只认 day6/ 一层,跑不到 handler |
| Dockerfile 多阶段构建 | 先 go build 再拷二进制到小镜像,体积小 | day6/Dockerfile |
docker build / docker run |
本地或 CI 打镜像、跑容器 | 部署流程 |
获取实战代码 :如需在本地跑通本文示例,请克隆仓库 WenSongWang/go-quickstart-7days,本文示例在 day6 目录,克隆后在项目根目录执行下文中的命令即可。
一、本篇目标
学完本文并跑通本目录示例,你将掌握:
| 模块 | 内容 |
|---|---|
| 单元测试 | _test.go、testing、表驱动测试 |
| HTTP 测试 | httptest.NewRequest、NewRecorder,不占端口测 Handler |
| Docker | 多阶段构建 Go 镜像(本目录 Dockerfile 构建 Day 7 的 server) |
二、前置要求
- 已完成 Day 1~5。
- 运行 Docker 相关命令需本机已安装 Docker(可选)。
三、本日目录结构与示例(先混个眼熟)
目录结构
day6/
├── handler/
│ ├── handler.go # 被测 Handler:GetUser,按路径 id 返回 JSON
│ └── handler_test.go # 表驱动 + httptest 测 GetUser
├── Dockerfile # 多阶段构建,产物为 day7 的 server
├── README.md
└── csdn.md
| 目录/文件 | 说明 |
|---|---|
| handler/ | 一个包:handler.go 提供 GetUser,handler_test.go 测它;同包故测试里直接调 GetUser,无需 import。 |
| Dockerfile | 两阶段:builder 用 golang 镜像编译,最终阶段用 alpine 只放二进制 + 运行;构建的是 ./day7/cmd/server。 |
示例与知识点
| 示例 | 主要知识点 |
|---|---|
| handler/handler.go | GetUser:Method 校验、TrimPrefix 取 id、Atoi 解析、合法 200 / 非法 400 |
| handler/handler_test.go | 表驱动 []struct、httptest.NewRequest/NewRecorder、GetUser(rec, req)、断言 rec.Code |
| Dockerfile | FROM AS builder、COPY go.mod、go build、COPY --from=builder、EXPOSE、CMD |
四、核心概念与最小示例(不看代码也能懂)
为什么用「表驱动」?
把多组「输入 + 期望结果」写进一个 []struct,用 for 循环依次调被测函数并断言。这样加用例只需在切片里多写一行,易读、易维护,是 Go 里常见的测试写法。
为什么用 httptest,不直接起一个 HTTP 服务再请求?
起真实服务要占端口、要起 goroutine,测试慢且容易受端口占用影响。httptest.NewRequest 造一个「假的」*http.Request,NewRecorder() 造一个**「录响应」的 Writer**(「录」= 记录:Handler 往它写状态码、Body 时,不真发到网络,而是记在 Recorder 里,测试里用 rec.Code、rec.Body 读出来做断言)。把这两个传给 Handler,Handler 像平时一样写,结果都记在 Recorder 里。不经过网络、不占端口,速度快,适合 CI。
go test ./day6/... 里的 ... 是什么?
表示「当前路径及所有子目录 下的包」。本项目中 .go 在 day6/handler/ 里,不在 day6/ 下;若只写 go test ./day6,Go 只看 day6 这一层,没有 .go 就没有包,不会跑测试。写成 ./day6/... 才会把 day6/handler 算进去,从而执行 handler_test.go。
为什么测试里能直接调 GetUser,不用 import?
因为 handler_test.go 和 handler.go 都在同一目录且都是 package handler ,属于同一个包 。Go 规定:同一包内的所有 .go 文件共享命名空间,所以 handler.go 里导出的 GetUser 在测试里直接可见 ,无需 import。Go 里目录即包 ,不需要像 Python 那样放 __init__.py。
Dockerfile 为什么是两阶段?为什么构建的是 day7?
- 两阶段 :第一阶段用带 Go 编译器的镜像(如 golang:1.21-alpine)编译出二进制;第二阶段用很小的 alpine 只拷贝二进制和 ca-certificates,不包含源码和编译器,镜像体积小,部署快。
- 构建 day7 :本系列把「可运行的应用」放在 Day 7,Day 6 只讲测试和镜像写法;所以 Dockerfile 里
go build ... ./day7/cmd/server,打出来的镜像跑的是 Day 7 的 server。学完 Day 7 后,同一 Dockerfile 即可用于上线部署。
易踩坑小结
| 坑 | 原因 | 解法 |
|---|---|---|
| go test ./day6 没跑测试 | day6 下没有 .go,包在 day6/handler | 写成 go test ./day6/... |
| 测试文件不执行 | 文件名不是 _test.go 或函数名不是 TestXxx | 必须 *_test.go 且 func TestXxx(t *testing.T) |
| 测试里找不到 GetUser | 误以为要 import | 同包直接可见,无需 import |
五、Day 6 示例代码与逐段解读
1. handler/handler.go
go
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func GetUser(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/users/")
id, err := strconv.Atoi(path)
if err != nil || id <= 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid id"})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(User{ID: id, Name: "用户" + strconv.Itoa(id)})
}
解读 :只接受 GET;从 r.URL.Path 去掉前缀 /api/users/ 得到 id 字符串,用 strconv.Atoi 转成整数;转换失败或 id≤0 返回 400 + JSON 错误信息,否则返回 200 + User JSON。为 Day 6 的 httptest 提供明确的状态码和 Body,便于断言。
2. handler/handler_test.go
go
package handler
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestGetUser(t *testing.T) {
tests := []struct {
path string
wantStatus int
}{
{"/api/users/1", http.StatusOK},
{"/api/users/42", http.StatusOK},
{"/api/users/0", http.StatusBadRequest},
{"/api/users/abc", http.StatusBadRequest},
}
for _, tt := range tests {
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
rec := httptest.NewRecorder()
GetUser(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("GetUser(%q) status = %d, want %d", tt.path, rec.Code, tt.wantStatus)
}
}
}
解读 :表驱动:tests 里每项是「路径 + 期望状态码」。循环里用 NewRequest 造 GET 请求、NewRecorder 录响应,直接 GetUser(rec, req)(同包无需 import),然后断言 rec.Code == tt.wantStatus,不等则 t.Errorf 报错。和 Python 的「for 用例 in 用例列表: 调接口、assert 状态码」逻辑一致。
3. Dockerfile
dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY go.sum* ./
COPY . .
RUN CGO_ENABLED=0 go build -o /server ./day7/cmd/server
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /server .
EXPOSE 8080
CMD ["./server"]
解读 :第一阶段 builder:拷 go.mod 先 go mod download(利用缓存),再拷全部源码,go build 得到 /server 二进制(关闭 CGO,便于跨平台)。第二阶段:用 alpine,只装 ca-certificates,从 builder 拷出 /server,暴露 8080,启动命令 ./server。最终镜像里没有 Go 环境,只有二进制,体积小。
六、运行当天代码
测试(项目根目录):
bash
go test ./day6/...
# 或带详细输出:go test -v ./day6/...
预期输出示例(-v 时):
=== RUN TestGetUser
--- PASS: TestGetUser (0.00s)
PASS
ok github.com/go-quickstart-7days/day6/handler 0.229s
Docker(可选,需已装 Docker):
bash
docker build -f day6/Dockerfile -t go-7days-app .
docker run -p 8080:8080 go-7days-app
镜像内跑的是 Day 7 的 server,可访问 http://localhost:8080 验证。
七、学习建议
- 先跑测试 :
go test -v ./day6/...,再看 handler_test.go 和 handler.go 对应关系。 - 理解同包:为什么测试里能直接写 GetUser,和「目录即包、同包无需 import」对上号。
- 看 Dockerfile :理解两阶段(编译镜像 → 运行镜像)、
COPY --from=builder,以及为何最终镜像很小。
八、小结
Day 6 补齐「测试 + 镜像构建」:表驱动 + httptest 测 HTTP Handler 不占端口,Docker 多阶段构建出小镜像。Day 7 会把前几天的内容整合成一个完整小项目,并沿用这里的测试与 Docker 方式。