有的项目一上来就让人补测试大多数人的第一反应是补业务 Service 或 Controller。但真把请求链路走一遍就知道最该先补的是 Auth 这类中间件它是每个请求的必经之门门要是松了后面所有业务测试都建立在沙地上门要是误杀了明明业务没问题线上却到处飘 401。这篇就针对 Auth 中间件的单元测试把“通过”和“拒绝”两类场景掰开揉碎讲清楚从模拟请求、伪造 Handler 到参数化用例最后再聊几个我实际踩过的坑。写这一类测试不需要搭建完整的数据库、Redis 或者 Web Server只需要一个测试框架、一个 PSR-7 兼容的 Request 对象、一个能记住自己是否被调用的 Handler 替身以及稳住的断言姿势。只要这几样备齐你就能在几秒内把中间件的每个分支都按在地上摩擦一遍。这篇文章适合正在给老项目补测试、以及刚接触 PHP 中间件测试、想知道“到底测到什么程度才算稳”的开发者阅读。1. 先搞清楚中间件要测的是什么为什么不能全扔给集成测试1.1 中间件在请求链路里的真实位置现代 PHP 框架里中间件长得都差不多接收一个 Request做校验或加工然后决定是继续往下一个中间件传还是直接返回一个响应中断链路。Auth 中间件属于最典型的“守门员”角色通常挂在受保护路由的最前面负责确认“这个人是谁”以及“这个人能不能进”。这个位置决定了它的测试重点很单纯但又极度重要没有多余的业务计算核心逻辑就是对输入凭证做判断然后输出“放行”或“拒绝”两种结果。正因为判断逻辑集中在一个窄小的范围内单元测试把它单独拎出来是性价比最高的做法。集成测试会带着路由、控制器、数据库一起跑一旦失败了很难快速分清是 Auth 拦截了请求还是控制器里某个查询崩了。1.2 为什么单独抽出来做单元测试而不是甩给集成测试我见过一个项目把登录态校验放在一个大型集成测试套件里覆盖每个用例启动前都要准备数据库种子数据、还要确认 Redis 里有对应 session。问题来了某次 CI 里 Redis 连接超时几十个用例一起失败最后查了半天发现根本不是 Auth 代码的问题而是测试环境的基础设施不稳定。这种失败的排查成本极高而且会让人对 Auth 测试逐渐失去信任。单元测试的好处在于隔离。我们只关心 Auth 中间件自己写的那些逻辑外部依赖全部用替身顶替。这样既不用连数据库也不用起队列速度在毫秒级任何人跑出红都能立刻断定是中间件本身的回归。真正的鉴权实现与 Redis、数据库的联动放到集成测试阶段覆盖就足够了单元测试负责把 Auth 的决策分支锁死。一个清晰的边界是单元测试负责验证“当凭证合法时放行、非法时拒绝”的决策本身集成测试负责验证“这个凭证在真实存储体系里到底合不合法”。两者都不少但绝不能混在一起测。2. 测试前的准备搭一个最小可测的中间件骨架2.1 三个必须模拟的协作对象中间件的方法签名一般是这样public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface它只依赖三个东西Request、Handler、以及自身构造时注入的辅助服务。写单元测试时就要分别对付这三者。Request 最好用项目内现成的 PSR-7 实现直接 new 一个然后用withHeader()、withAttribute()这些不可变方法往里塞数据。别用 Mock Builder 去伪造 Request因为 PSR-7 的不可变方法特别多一行一行去配置各种返回值非常啰嗦也容易把测试写得跟强迫症一样。Handler 则是测试里最重要的替身它需要能记录自己“有没有被调用”以及“被调用时拿到的 Request 是什么样”。在 PHPUnit 里你可以创建一个 Mock 对象并断言expects($this-once())也可以用匿名类写一个简单的 Spy。我的习惯是写一个名为SpiedHandler的小类放到测试命名空间里字段少意图直白查可读性也更好。namespace Tests\Unit\Middleware; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; final class SpiedHandler implements RequestHandlerInterface { public bool $called false; public ?ServerRequestInterface $capturedRequest null; public function handle(ServerRequestInterface $request): ResponseInterface { $this-called true; $this-capturedRequest $request; return new JsonResponse([code 0]); } }注意SpiedHandler 返回的响应是一个正常业务响应。这个设计很重要因为测试通过场景时我们不仅关心 handler 被调用还要关心中间件把它返回的响应原样交出去。2.2 测试文件与基础测试类的组织方式我习惯把中间件测试放在tests/Unit/Middleware/AuthMiddlewareTest.php里与tests/Integration区分开。这样 CI 配置阶段就能直接区分“快速单测”和“慢速集成”不会互相拖累。在测试类里准备好构造函数依赖的 stub。比如 Auth 中间件依赖一个 TokenValidator单元测试里就不该用真实的 TokenValidator而是用一个固定的替身返回预设结果。这里有两种做法一种是 PHPUnit 的 createMock另一种是匿名类。真实项目里我偏爱匿名类因为可以把返回值写得非常直白。$validator new class implements TokenValidatorInterface { public function validate(string $token): ?int { return $token valid-token ? 12345 : null; } };这段代码说的是只有遇到字面量valid-token时才返回用户 ID 12345其他情况一律 null。Auth 中间件拿到 null 就知道凭证无效直接拒绝。这就把中间件的决策逻辑和真实的签名算法解耦了我们测的是“门”不是“钥匙本身”的密码学原理。3. 覆盖通过场景Auth 放行逻辑的完整用例3.1 正常登录态的三种断言方式“通过”场景里有个核心语义请求携带了有效凭证中间件验证通过后必须调用下一个 Handler。所以第一个断言一定是 handler 被调用而且只调用一次。第二个重要断言是“透传身份信息”。大部分 Auth 中间件在验证通过后会把用户 ID 塞到 Request 的 attribute 里方便后续 Controller 直接用而不是重新解析一次 token。这是我们业务里非常关键的约定应当在测试里明确锁住。第三个断言是响应的传递。Handler 返回的 Response 必须由中间件原样返回不能中途被篡改。三个断言凑齐一个通过场景才算真正闭环。public function testValidTokenPassesRequestToHandlerAndAddsIdentity(): void { $request (new ServerRequest()) -withHeader(Authorization, Bearer valid-token); $auth new AuthMiddleware($validator); $handler new SpiedHandler(); $response $auth-process($request, $handler); $this-assertTrue($handler-called); $this-assertSame(12345, $handler-capturedRequest-getAttribute(currentUserId)); $this-assertSame(200, $response-getStatusCode()); }如果 Handler 用的是 PHPUnit Mock第二个断言拿不到 capturedRequest就得换成withAttribute()的参数断言复杂度会高不少。所以我强烈推荐 Spy 类简单问题不需要引入太重的手段。3.2 让 Handler 表示“已通过”——mock 与 spy 的取舍很多 PHPUnit 教程喜欢写$handler-expects($this-once())-method(handle)这在语义上没问题但当你需要验证“handle 收到的是哪个 Request”时就必须写笨重的回调捕获参数。真人写代码时Spy 往往比 Mock 更顺手。区别在于Mock 是“我提前规定你会被怎么调用”Spy 是“我事后看看你有没有被调用、被调用的证据是什么”。Auth 中间件的测试更关注结果记录所以 Spy 的直觉更贴合。你会在实际操作中发现Spy 类的字段名只要起得直白整个用例读起来就像一段自然语言called 为真就说明放行了capturedRequest 里的 userId 正确就说明身份透传成功。复杂场景下还可以给 SpiedHandler 增加计数器字段用于拒绝场景中确认 handler 从未被调用。比如这样final class CountingHandler implements RequestHandlerInterface { public int $called 0; public function handle(ServerRequestInterface $request): ResponseInterface { $this-called; return new JsonResponse([code 0]); } }代码不需要任何魔法断言assertSame(0, $handler-called)就足够表达“请求被拦下来了业务处理器根本没资格执行”。4. 覆盖拒绝场景401 与 403 的各个分支4.1 未登录时如何返回 401 并中断链路拒绝场景比通过场景更值得细写因为业务线上的各种异常输入远比正常输入多。首先要覆盖的是“没有任何凭证”的情况请求头里干脆没有 Authorization。中间件必须立刻返回 401且不得调用后续 Handler。其次是“凭证格式不对”比如有人传了 Basic 头或者一个空Bearer。框架能一眼看出不符合期望格式同样直接拒绝。最后是“凭证存在但校验无效”比如过期 token、被吊销的 token。这部分在单元测试里由 TokenValidator 的 stub 返回 null 即可不需要真的去算一段签名。这里有个关键的编码纪律所有非法输入都必须走同一个出口返回 401不能在某些分支里直接抛异常。否则线上就会出现“应为 401 却返回 500”的尴尬现象。我遇过一个项目某次重构后在 token 解析失败时抛出 RuntimeException结果网关只能返回 500。这类问题写一个拒绝场景的单测就能揪出来但没写测试前谁也不知道。public function testInvalidOrMissingCredentialsReturnsUnauthorized(): void { $request (new ServerRequest())-withHeader(Authorization, invalid-token); $auth new AuthMiddleware($validator); $handler new CountingHandler(); $response $auth-process($request, $handler); $this-assertSame(0, $handler-called); $this-assertSame(401, $response-getStatusCode()); }4.2 已登录但无权限时返回 403 的处理401 和 403 的语义区别我在好几个团队里反复强调401 是“我不认识你”403 是“我认识你但你没有权限做这件事”。Auth 中间件里常见出现的分支是token 有效用户也存在但账号被禁用或缺少特定角色。这种场景返回 401 会误导客户端让用户误以为重新登录就能解决实际上重新登录也解决不了。所以测试要区分开。例如构造一个 User 对象其isActive()返回 falseAuth 中间件发现用户状态异常时直接返回 403。如果中间件同时承担角色权限校验那还要测“角色不足”的情况。场景状态码Handler 是否被调用token 缺失或非法401否用户被禁用403否角色权限不足403否凭证有效且状态正常200是这张表可以直接作为团队评审时的检查清单每一个分支都应该至少有一个对应用例。别觉得麻烦Auth 逻辑写成表格后你会发现分支数量其实有限一次写齐后续收益巨大。4.3 token 过期、签名错误等中间态分支一些项目会把 token 过期单独返回 401但响应体附带一个特定错误码比如AUTH_TOKEN_EXPIRED方便前端判断是否静默刷新。这种业务约定也值得用参数化方式固定下来。此时建议把测试方法参数化同一个测试方法喂入不同 Request断言相同或者相近的输出。PHPUnit 10 以上用 DataProvider 属性比较方便。public static function unauthorizedRequestProvider(): array { return [ 没有Authorization头 [new ServerRequest()], Basic认证混入 [(new ServerRequest())-withHeader(Authorization, Basic abc123)], 空Bearer [(new ServerRequest())-withHeader(Authorization, Bearer)], 过期token [(new ServerRequest())-withHeader(Authorization, Bearer expired-token)], ]; } #[DataProvider(unauthorizedRequestProvider)] public function testUnauthorizedRequestsAreRejected(ServerRequestInterface $request): void { $auth new AuthMiddleware($validator); $handler new CountingHandler(); $response $auth-process($request, $handler); $this-assertSame(0, $handler-called); $this-assertSame(401, $response-getStatusCode()); }注意每个 provider 条目都要带上简短解释比如“没有Authorization头”。这不是注释负担而是要让测试报告变成人能读懂的文档。跑挂了第一个看见的就是场景名立刻知道是哪个输入出问题。5. 把这些用例组织成可持续演进的测试集5.1 Data Provider 驱动“通过/拒绝”参数化把通过/拒绝场景变成一张表格非常有效。除了拒绝场景的 provider通过场景也可以参数化多个合法 token 指向不同用户验证中间件能把各自对应的 userId 正确透传。这种做法特别适合后续扩展多登录方式或者多租户场景。不过要控制一个度provider 里的数据只放“直接影响 Auth 决策”的变量。如果把几十个无关字段都塞进去测试报告反而会变得又臭又长。我从实践里总结的经验是每个 provider 的数据条数尽量控制在 5 到 10 条超过这个量就得考虑是不是把测试切得太细了或者某一段逻辑本身设计得过于复杂。5.2 命名规范与测试的可读性命名看起来是小事实际影响测试集的长期维护。我常用的模式是test场景_预期行为例如testInvalidToken_returns401WithoutCallingNextHandler。英文 下划线不算优雅但放在 CI 日志里非常清晰一屏扫下来就知道是哪个分支出了问题。注释要克制。好的用例名字本身就是文档注释只补充“为什么这么断言”这种代码上表达不出来的背景。比如某个 401 场景要求响应体必须包含WWW-Authenticate头这是协议需求光看代码看不出来就需要一行注释解释它存在的理由。5.3 CI 里的关注点并发、随机顺序、速度Auth 中间件测试因为不碰数据库和缓存几乎可以随便跑但要让它在 CI 里稳定还需要注意两点。第一是不要共享静态状态尤其是请求对象或用户对象否则用例执行顺序变化会导致结果不稳定。第二是给这类测试单独归类放进 pre-merge 的快速测试阶段。我会在 phpunit.xml 里把 Unit 与 Integration 分开testsuites testsuite nameUnit directorytests/Unit/directory /testsuite testsuite nameIntegration directorytests/Integration/directory /testsuite /testsuites这样提交代码时本地先跑 Unit几十秒内完成集成测试留到 CI 或者合并前再跑。Auth 中间件的用例属于每次提交必跑的因为它足够快且失败代价极高。6. 避开我踩过的坑几个典型问题与排查记录6.1 单测里误用了真实的 Session 或 Cache这是新手最容易踩的坑。为了“更真实”把 Auth 中间件依赖的会话服务、缓存服务都换成真实 Redis 连接结果本地能过CI 跑不过。原因千奇百怪Redis 版本差异、key 残留、并发环境互相污染。我的建议非常直接单元测试里一律使用内存版替身无论 Session 还是 Cache。内存替身不需要实现所有接口只要实现 Auth 中间件用到的几个方法即可。这么做的最大收益是确定性和速度。至于真实 Redis 下能不能跑通那属于集成测试的管辖范围。如果某个中间件内部直接new RedisClient()根本没法注入替身那就是设计问题。赶紧把它改成构造注入或者工厂注入否则任何单元测试策略都救不了你。6.2 对 Response 直接断言“包含 401”而不是比对状态码我见过有人写assertStringContainsString(401, (string)$response-getBody())。这非常危险因为响应体里完全可能出现一行日志文本、一个错误提示或者其他数字包含 401于是测试在错误的情况下变绿了。正确姿势一定是先断言 HTTP 状态码本身就是整数 401$this-assertSame(401, $response-getStatusCode());然后可以继续断言响应体里的错误码字段等于AUTH_FAILED。这样可以精确区分“HTTP 层面拒绝了”和“业务层面有什么错误”。状态码和业务码各测各的才能避免误判。6.3 一个 Request 被复用后状态被污染PSR-7 的 Request 强调不可变withHeader()返回的是新对象。很多人知道这一点但代码里图省事写了一个$request变量反复调用withHeader()没有把结果重新赋值。结果所有 provider 里的请求对象都等价测试等于没测。排查方法很直接在 provider 里打印实际 header或者给用例加上请求体差异。我更推荐养成习惯每个 provider 返回全新创建的对象避免任何交叉污染。对于有 Body 的请求也应当在每个用例里单独 new不要使用同一个请求实例做多次修改。6.4 几个可以抄作业的排查命令和断言姿势跑单个用例是最常用的调试手段vendor/bin/phpunit --filter testInvalidToken_returns401WithoutCallingNextHandler如果怀疑某个用例执行顺序影响结果可以临时指定执行顺序模式。在 PHPUnit 里可以用--order-byrandom跑几遍看是否还会随机失败。稳定测试不应该依赖执行顺序。真正遇到疑难问题时我的排查路径是先看 handler 是否被调用再拆响应状态码和业务码分别验证最后检查中间件构造时的依赖替身是否传对了。80% 的问题都倒在这三步里剩下 20% 大概率是 Request 对象被复用或者静态属性被污染。多跑几遍随机顺序这类脏问题藏不住。把 Auth 中间件的测试拆成这几刀之后你会发现整套逻辑并不复杂准备替身、分清 401 与 403、参数化分支、盯紧状态与语义。我自己的体会是中间件的单元测试不需要追求覆盖率数字本身但每一个决策分支都要有一个用例陪护。这活儿做在前面看起来多花了半小时但后面每次重构都有人替你站岗。