中间件和 Handler 的边界,是从哪里开始模糊的
用 Echo 写 Web 服务,最开始的几周通常很愉快:e.GET、e.POST 一挂,中间件往里一塞,接口就通了。直到有一天,你发现同一个动作被拆成三个中间件,而其中一个中间件竟然需要读取数据库,另一个依赖登录态,第三个还偷偷改了响应体。这时候 Handler 本身可能只剩下一行 return c.JSON(200, nil)。问题就来了:这套代码到底怎么测试?

Echo 的设计并不复杂,中间件本质是一个函数,接收 HandlerFunc 作为参数,再返回一个 HandlerFunc。请求像洋葱一样穿过中间件链,最终进入 Handler。但框架的轻巧不代表设计必然正确。真正决定代码质量的,是你如何划分中间件和 Handler 的职责,以及如何让它们可以脱离真实环境单独验证。
三个比想象中更常见的设计误区
先说误区。只有意识到这些问题,后面谈测试才有意义。
- 把参数校验和权限判定直接写进中间件。中间件变成业务逻辑的容器,换一个路由组时发现完全没法复用,还导致测试时不得不构造非常完整的上下文。
- Handler 直接使用包级变量连接数据库,比如一个全局 *sql.DB 或 *gorm.DB。单测时想替换成内存库,结果所有 handler 都引用同一份全局状态,只能靠改环境变量。
- 为了追求中间件链“简洁”,把所有横切逻辑塞进同一个中间件。比如一个中间件里同时做日志、限流、权限、事务。你确实省了路由注册的几行代码,但每次改动都可能影响所有接口,测试要准备一堆 mock 才能跑完。
这些问题的共同点,是把本不该属于中间件或 Handler 的职责放错了位置。中间件适合处理与请求上下文有关的横切关注点,比如 CORS、请求日志、恢复 panic。Handler 适合负责把 HTTP 输入转为领域调用,再把结果转为 HTTP 响应。一旦两者混淆,可测试性必然下降。
让 Handler 只做翻译官:接口注入带来的清晰度
为了让 Handler 可测试,最有用的一个动作是:不要让它直接依赖具体实现,而是依赖一个接口。
我们用一个用户查询接口来演示。最常见的写法是这样:
type UserService interface {
GetByID(ctx context.Context, id int64) (*User, error)
}
type UserHandler struct {
svc UserService
}
func NewUserHandler(svc UserService) *UserHandler {
return &UserHandler{svc: svc}
}
func (h *UserHandler) Get(c echo.Context) error {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid id")
}
u, err := h.svc.GetByID(c.Request().Context(), id)
if err != nil {
return err
}
return c.JSON(http.StatusOK, u)
}
这段代码里,Handler 只做了三件事:解析输入、调用 service、写响应。所有数据库查询、缓存、外部 HTTP 调用都在 UserService 的实现里。测试时只需要写一个简单的 fake,实现同一个接口即可。
有人可能会问:Echo 的 context 不是一个具体结构体吗,怎么 mock?其实 c echo.Context 本身就是接口,在测试里完全可以用 e.NewContext 构造一个真实上下文,并把 req 和 rec 传进去。真正需要替换的是那些连接外部资源的依赖,也就是 UserService 的实现。
中间件测试的关键:构造一个最小闭环
中间件的测试方式与 Handler 略有不同。因为中间件就是包装 handler 的函数,所以测试时你只需要构造一个最小请求和一条 next 调用链,然后观察它是否如预期执行。
func TestRequireAuth(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
var nextCalled bool
next := func(c echo.Context) error {
nextCalled = true
return nil
}
mw := RequireAuth(next)
err := mw(c)
if err == nil {
t.Fatal("expected unauthorized error")
}
if nextCalled {
t.Fatal("next should not be called when auth fails")
}
}
这里的 RequireAuth 不再依赖任何全局对象,它接收 next 函数,返回一个类型为 echo.HandlerFunc 的闭包。由于它只从 context 里读取 header 或 cookie,测试时只需要在 c.Request() 上提前塞好请求头。
如果中间件内部需要访问数据库,你应该考虑这个中间件是否设计过度了。一个纯粹的 auth 中间件只需要判断 token 是否有效,而用户信息的完整查询应该推迟到 Handler 层,通过 service 接口去拿。边界一清楚,测试难度立刻降下来。
经验提醒:不要在中间件里开启数据库事务。事务边界应该由 service 层根据业务规则来决定,中间件的职责是处理 HTTP 级别的横切逻辑,越界后测试成本会直线上升。
中间件的执行顺序,比你想的更值得关注
Echo 的中间件顺序就是注册顺序,而且是洋葱模型。顺序错了,测试出来的行为会完全走样。比如你把 recovery 放在最外层,它能捕获整个链路的 panic;如果放在内层,外层中间件 panic 了它也救不回来。
可测试性在这里的体现是:测试中间件时,需要传入一个 next 来模拟下游。如果 next 执行结果会影响你的中间件逻辑,那测试时要构造的场景就变多了。这也是为什么建议中间件尽量只读 request,不做跨请求的副作用,让行为可以被最小输入触发。
一个常见的顺序策略是:recovery 最外层,然后是 CORS、日志,然后是身份认证,最后才是具体业务中间件。身份认证只负责确认“你是谁”,而“你能做什么”应该交给 Handler 或 service 层去判断。路由组是一个很实用的工具,你可以只给 /admin 组挂认证,而 /health 始终公开。
不同方案对比:你的团队适合哪种 Handler 设计
下面这个表来自我接触到的一些项目团队的常见做法,不涉及具体公司,只是普遍模式。
| 设计方式 | 可测试性 | 耦合度 | 适合场景 |
|---|---|---|---|
| Handler 直接调用全局 DB | 低,难以替换 | 高 | 原型验证、一次性脚本 |
| Handler 依赖具体 Service 结构体 | 中,需构造真实服务 | 中 | 服务逻辑简单,无可替换依赖 |
| Handler 依赖 Service 接口 | 高,可自由注入 fake | 低 | 中大型项目,需要稳定单元测试 |
| 业务逻辑放进中间件 | 低,上下文耦合严重 | 高 | 几乎不推荐,除非是纯横切逻辑 |
这个表格不是让你照搬,而是帮助你在设计时先问一句:这个依赖以后会不会被替换?如果答案是会,那接口是合理选择;如果永远只有一个实现,直接注入具体结构体问题也不大,但为了测试一致性,多数情况下接口的成本并不高。
落地时最值得做的几件事
如果团队已经在用 Echo 且代码里已经出现了中间件和 Handler 缠绕的迹象,建议按下面这几个步骤慢慢收拢,不需要一次推翻重来。
- 先从改动最频繁的接口开始,把业务逻辑抽到一个 Service 接口后面,让 Handler 标签变薄。
- 把与业务无关的中间件拆小,例如日志、恢复、CORS 各司其职,不要让一个中间件负责太多事。
- 在测试中引入 httptest,优先给最关键的一组 Handler 写单元测试,用 fake service 覆盖正常、错误和边界情况。
- 确定中间件注册顺序,让错误处理类的中间件放在最外层,身份认证放在受保护路由组上,而不是全局。
- 重构过程中保持路由地址不变,用测试去保护既有行为,避免一边改结构一边出 bug。
这五件事看起来简单,放在真实项目中你可能会发现,最大的阻力不是 Echo 本身的限制,而是历史代码里散落的全局变量和层层嵌套的中间件。这时候不要试图一晚上改完,每接一个接口就清理一次,让设计逐步回到正轨。
可测试的 Web 层,最终是一种纪律
Echo 只是帮你把路由和 request 分发做好了,但 Web 层的可测试性来自你写的 Handler 是否够薄,中间件的职责是否够窄,依赖的方向是否统一。这不是一个套上去就能生效的模板,而是每加一行代码时都要做的取舍。
下次当你打开 Controller 看到一屏的业务逻辑,或者为某个中间件写测试却不得不连接真实数据库时,可以停下来想一想:是不是边界又歪了。先把边界画清楚,再回头补测试,你会发现那些曾经很痛苦的测试,其实可以很自然地从代码结构里长出来。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/904/