一、核心概念铺垫:中间件单元测试的底层逻辑

1. 鉴权中间件的核心行为

PHP 中间件(以 Laravel/Slim 等框架为例)的核心是「请求拦截→逻辑校验→放行/拒绝」,Auth 中间件的典型流程:

有效

无效

接收请求

校验Token

设置当前用户

返回401/403响应

放行请求到下一个中间件/控制器

终止请求

单元测试的核心目标:验证不同输入(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. 坑1:测试依赖真实外部服务
    → 问题:测试环境无 Redis/数据库时,测试失败;
    → 解决:所有外部依赖用 Mock 替代,确保测试“自给自足”。

  2. 坑2:未测试 Closure 是否被调用
    → 问题:仅断言无异常,但未验证中间件是否真的放行;
    → 解决:通过 $next->expects($this->once()) 断言 Closure 被调用(放行),never() 断言未被调用(拒绝)。

  3. 坑3:忽略异常类型/状态码
    → 问题:只断言异常消息,未断言状态码(如 401/403 混淆);
    → 解决:用 expectExceptionCode() 断言状态码,确保错误类型正确。

  4. 坑4:测试代码与业务代码耦合
    → 问题:测试中硬编码 JWT 密钥、用户ID等,业务代码修改后测试失效;
    → 解决:将常量抽离到配置文件,测试中读取配置而非硬编码。

总结

  1. 为 Auth 中间件编写单元测试的核心是覆盖“通过/拒绝”核心场景,细分 Token 无效、过期、权限不足等子场景;
  2. 测试关键:用 Mock 隔离外部依赖,断言「Closure 是否被调用」(判断放行/拒绝),验证异常的消息和状态码;
  3. 进阶优化:用数据提供者减少重复代码,检查测试覆盖率,确保核心逻辑无遗漏。

通过这套测试方法,能确保 Auth 中间件在各种边界场景下的行为符合预期,避免线上因鉴权逻辑漏洞导致的安全问题或服务异常。

更多推荐