PHP为关键中间件 (如 Auth) 编写单元测试,覆盖通过/拒绝场景的庖丁解牛
一、核心概念铺垫:中间件单元测试的底层逻辑
1. 鉴权中间件的核心行为
PHP 中间件(以 Laravel/Slim 等框架为例)的核心是「请求拦截→逻辑校验→放行/拒绝」,Auth 中间件的典型流程:
单元测试的核心目标:验证不同输入(Token 有效/无效/过期/无权限)下,中间件是否输出预期结果(放行/拒绝)。
2. 单元测试的关键原则(针对中间件)
- 隔离性:测试 Auth 中间件时,不依赖数据库、Redis 等外部服务(用「模拟(Mock)」替代);
- 场景全覆盖:至少覆盖「通过」「拒绝」两大核心场景,细分场景如 Token 过期、无 Token、权限不足;
- 无副作用:测试执行后不修改真实数据(如数据库、缓存)。
3. 工具准备
- 测试框架:PHPUnit(PHP 官方标准,Laravel/Symfony 均内置);
- 模拟工具:PHPUnit 自带的
MockBuilder(模拟依赖类,如 Redis、User 模型); - 框架适配:Laravel 提供
Illuminate\Testing\TestCase,简化请求/响应的模拟。
二、拆解 Auth 中间件的测试场景(核心)
Auth 中间件的测试场景需覆盖「正向通过」和「反向拒绝」,每个场景对应具体的输入和预期输出:
| 测试场景 | 输入条件 | 预期输出 |
|---|---|---|
| ✅ 正常通过(有效Token) | 请求头携带有效的、未过期的 Token | 中间件放行,请求传递到下一个处理环节 |
| ❌ 拒绝(无Token) | 请求头无 Authorization Token | 返回 401 响应,终止请求 |
| ❌ 拒绝(无效Token) | Token 格式错误/伪造 | 返回 401 响应,终止请求 |
| ❌ 拒绝(Token过期) | Token 合法但已过有效期 | 返回 401 响应,终止请求 |
| ❌ 拒绝(权限不足) | Token 有效,但用户无接口访问权限 | 返回 403 响应,终止请求 |
三、实战:为 Laravel Auth 中间件编写单元测试
以 Laravel 框架的 JWT 鉴权中间件为例(非框架场景可参考核心逻辑),完整拆解测试编写过程:
步骤1:准备待测试的 Auth 中间件
先定义一个典型的 JWT 鉴权中间件(app/Http/Middleware/AuthJwtMiddleware.php):
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use App\Exceptions\AuthException;
class AuthJwtMiddleware
{
// JWT 密钥(实际项目中放配置文件)
private $secret = 'your-jwt-secret';
/**
* 处理传入的请求
* @param Request $request
* @param Closure $next
* @param string|null $permission 可选:需要的权限
* @return mixed
*/
public function handle(Request $request, Closure $next, string $permission = null)
{
// 1. 获取 Token
$token = $request->header('Authorization');
if (!$token) {
throw new AuthException('未提供Token', 401);
}
// 移除 Bearer 前缀
$token = str_replace('Bearer ', '', $token);
try {
// 2. 验证 Token 有效性
$decoded = JWT::decode($token, new Key($this->secret, 'HS256'));
$userId = $decoded->sub;
// 3. 验证权限(可选)
if ($permission && !$this->hasPermission($userId, $permission)) {
throw new AuthException('权限不足', 403);
}
// 4. 设置当前用户(放行)
$request->attributes->set('user_id', $userId);
return $next($request);
} catch (ExpiredException $e) {
throw new AuthException('Token已过期', 401);
} catch (\Exception $e) {
throw new AuthException('无效的Token', 401);
}
}
/**
* 验证用户权限(模拟方法,实际需查数据库/缓存)
* @param int $userId
* @param string $permission
* @return bool
*/
private function hasPermission(int $userId, string $permission): bool
{
// 实际逻辑:查询用户权限表
$userPermissions = [1 => ['admin', 'user:read'], 2 => ['user:read']];
return in_array($permission, $userPermissions[$userId] ?? []);
}
}
步骤2:编写单元测试用例
创建测试文件 tests/Unit/Middleware/AuthJwtMiddlewareTest.php,覆盖所有核心场景:
<?php
namespace Tests\Unit\Middleware;
use Tests\TestCase;
use Illuminate\Http\Request;
use Illuminate\Http\Exceptions\HttpResponseException;
use App\Http\Middleware\AuthJwtMiddleware;
use App\Exceptions\AuthException;
use Firebase\JWT\JWT;
use Closure;
class AuthJwtMiddlewareTest extends TestCase
{
// 待测试的中间件实例
private $middleware;
// JWT 密钥(与中间件一致)
private $secret = 'your-jwt-secret';
/**
* 测试前置:每次测试前初始化中间件
*/
protected function setUp(): void
{
parent::setUp();
$this->middleware = new AuthJwtMiddleware();
}
// ====================== 正向场景:通过 ======================
/**
* @test 有效Token且无权限要求时,中间件放行
*/
public function valid_token_without_permission_allow_request()
{
// 1. 准备测试数据:生成有效JWT Token
$payload = [
'sub' => 1, // 用户ID
'exp' => time() + 3600 // 1小时后过期
];
$validToken = JWT::encode($payload, $this->secret, 'HS256');
// 2. 模拟请求(携带有效Token)
$request = new Request();
$request->headers->set('Authorization', "Bearer {$validToken}");
// 3. 模拟 Closure(下一个中间件/控制器)
$next = $this->createMock(Closure::class);
// 断言:Closure 会被调用(说明中间件放行)
$next->expects($this->once())
->method('__invoke')
->with($this->equalTo($request));
// 4. 执行中间件
$response = $this->middleware->handle($request, $next);
// 5. 验证结果:请求中已设置user_id
$this->assertEquals(1, $request->attributes->get('user_id'));
}
// ====================== 反向场景:拒绝 ======================
/**
* @test 无Token时,抛出401异常
*/
public function no_token_throws_401_exception()
{
// 1. 模拟请求(无Token)
$request = new Request();
// 2. 模拟 Closure
$next = $this->createMock(Closure::class);
// 断言:Closure 不会被调用(说明中间件终止请求)
$next->expects($this->never())->method('__invoke');
// 3. 执行并断言异常
$this->expectException(AuthException::class);
$this->expectExceptionMessage('未提供Token');
$this->expectExceptionCode(401);
$this->middleware->handle($request, $next);
}
/**
* @test 过期Token时,抛出401异常
*/
public function expired_token_throws_401_exception()
{
// 1. 生成过期Token
$payload = [
'sub' => 1,
'exp' => time() - 3600 // 1小时前过期
];
$expiredToken = JWT::encode($payload, $this->secret, 'HS256');
// 2. 模拟请求
$request = new Request();
$request->headers->set('Authorization', "Bearer {$expiredToken}");
// 3. 模拟 Closure
$next = $this->createMock(Closure::class);
$next->expects($this->never())->method('__invoke');
// 4. 执行并断言异常
$this->expectException(AuthException::class);
$this->expectExceptionMessage('Token已过期');
$this->expectExceptionCode(401);
$this->middleware->handle($request, $next);
}
/**
* @test 权限不足时,抛出403异常
*/
public function insufficient_permission_throws_403_exception()
{
// 1. 生成有效Token(用户2只有user:read权限,无admin权限)
$payload = [
'sub' => 2,
'exp' => time() + 3600
];
$validToken = JWT::encode($payload, $this->secret, 'HS256');
// 2. 模拟请求
$request = new Request();
$request->headers->set('Authorization', "Bearer {$validToken}");
// 3. 模拟 Closure
$next = $this->createMock(Closure::class);
$next->expects($this->never())->method('__invoke');
// 4. 执行中间件(要求admin权限)
$this->expectException(AuthException::class);
$this->expectExceptionMessage('权限不足');
$this->expectExceptionCode(403);
$this->middleware->handle($request, $next, 'admin');
}
/**
* @test 无效Token时,抛出401异常
*/
public function invalid_token_throws_401_exception()
{
// 1. 模拟请求(伪造Token)
$request = new Request();
$request->headers->set('Authorization', 'Bearer fake-token-123456');
// 2. 模拟 Closure
$next = $this->createMock(Closure::class);
$next->expects($this->never())->method('__invoke');
// 3. 执行并断言异常
$this->expectException(AuthException::class);
$this->expectExceptionMessage('无效的Token');
$this->expectExceptionCode(401);
$this->middleware->handle($request, $next);
}
}
步骤3:执行测试并验证结果
1. 执行测试命令
# 执行单个测试文件
./vendor/bin/phpunit tests/Unit/Middleware/AuthJwtMiddlewareTest.php
# 执行所有中间件测试
./vendor/bin/phpunit tests/Unit/Middleware/
2. 预期输出(测试通过)
PHPUnit 9.6.10 by Sebastian Bergmann and contributors.
..... 5 / 5 (100%)
Time: 0.08 seconds, Memory: 8.00 MB
OK (5 tests, 8 assertions)
步骤4:非框架场景的适配(通用逻辑)
若你使用原生 PHP/Slim 等无框架场景,核心逻辑不变,只需调整「请求/响应模拟」方式:
<?php
// 原生PHP模拟Request
$request = new \stdClass();
$request->headers = ['Authorization' => 'Bearer valid-token'];
// 模拟Closure
$next = function ($req) {
return 'next-handler'; // 放行后的返回值
};
// 执行中间件
$middleware = new AuthJwtMiddleware();
$result = $middleware->handle($request, $next);
// 断言:放行后返回next-handler
$this->assertEquals('next-handler', $result);
四、进阶优化:让测试更健壮
1. 使用数据提供者(Data Provider)减少重复代码
当多个拒绝场景的测试逻辑相似时,用 @dataProvider 批量生成测试用例:
/**
* 数据提供者:拒绝场景的测试数据
* @return array
*/
public function rejectionScenarios()
{
return [
'无Token' => [null, '未提供Token', 401],
'过期Token' => [JWT::encode(['sub' => 1, 'exp' => time()-3600], $this->secret, 'HS256'), 'Token已过期', 401],
'无效Token' => ['fake-token', '无效的Token', 401],
];
}
/**
* @test
* @dataProvider rejectionScenarios
* 批量测试拒绝场景
*/
public function rejection_scenarios_throw_exception($token, $expectedMsg, $expectedCode)
{
// 模拟请求
$request = new Request();
if ($token) {
$request->headers->set('Authorization', "Bearer {$token}");
}
// 模拟Closure
$next = $this->createMock(Closure::class);
$next->expects($this->never())->method('__invoke');
// 断言异常
$this->expectException(AuthException::class);
$this->expectExceptionMessage($expectedMsg);
$this->expectExceptionCode($expectedCode);
$this->middleware->handle($request, $next);
}
2. 模拟外部依赖(如Redis/数据库)
若 Auth 中间件依赖 Redis 缓存 Token 黑名单,用 PHPUnit Mock 替代真实 Redis:
/**
* @test Token在黑名单中时,拒绝请求
*/
public function token_in_blacklist_throws_401()
{
// 1. Mock Redis 类
$redisMock = $this->createMock(\Redis::class);
// 断言:Redis的get方法会被调用,且返回true(Token在黑名单)
$redisMock->expects($this->once())
->method('get')
->with('blacklist:token:valid-token')
->willReturn(true);
// 2. 注入Mock到中间件(需修改中间件为构造函数注入Redis)
$middleware = new AuthJwtMiddleware($redisMock);
// 3. 模拟请求(有效Token但在黑名单)
$request = new Request();
$request->headers->set('Authorization', 'Bearer valid-token');
// 4. 执行并断言
$next = $this->createMock(Closure::class);
$next->expects($this->never())->method('__invoke');
$this->expectException(AuthException::class);
$this->expectExceptionMessage('Token已被拉黑');
$this->expectExceptionCode(401);
$middleware->handle($request, $next);
}
3. 测试覆盖率检查
确保测试覆盖中间件的所有代码分支:
# 生成覆盖率报告(需安装Xdebug)
./vendor/bin/phpunit --coverage-html coverage-report tests/Unit/Middleware/AuthJwtMiddlewareTest.php
打开 coverage-report/index.html,查看代码覆盖率(目标:核心逻辑 100% 覆盖)。
五、避坑指南:中间件单元测试的常见错误
-
坑1:测试依赖真实外部服务
→ 问题:测试环境无 Redis/数据库时,测试失败;
→ 解决:所有外部依赖用 Mock 替代,确保测试“自给自足”。 -
坑2:未测试 Closure 是否被调用
→ 问题:仅断言无异常,但未验证中间件是否真的放行;
→ 解决:通过$next->expects($this->once())断言 Closure 被调用(放行),never()断言未被调用(拒绝)。 -
坑3:忽略异常类型/状态码
→ 问题:只断言异常消息,未断言状态码(如 401/403 混淆);
→ 解决:用expectExceptionCode()断言状态码,确保错误类型正确。 -
坑4:测试代码与业务代码耦合
→ 问题:测试中硬编码 JWT 密钥、用户ID等,业务代码修改后测试失效;
→ 解决:将常量抽离到配置文件,测试中读取配置而非硬编码。
总结
- 为 Auth 中间件编写单元测试的核心是覆盖“通过/拒绝”核心场景,细分 Token 无效、过期、权限不足等子场景;
- 测试关键:用 Mock 隔离外部依赖,断言「Closure 是否被调用」(判断放行/拒绝),验证异常的消息和状态码;
- 进阶优化:用数据提供者减少重复代码,检查测试覆盖率,确保核心逻辑无遗漏。
通过这套测试方法,能确保 Auth 中间件在各种边界场景下的行为符合预期,避免线上因鉴权逻辑漏洞导致的安全问题或服务异常。
更多推荐
所有评论(0)