PHPUnit单元测试与Mock技巧实战
发布日期: 2026/08/12 阅读总量: 1

从一个写不下去的单元测试说起

2024年3月,我给公司一个订单查询接口补测试。接口逻辑很简单:接收订单号,查数据库,调第三方物流API,拼装返回。

代码写完了,测试跑不动。原因很直白:测试里要连真实MySQL(里面有测试数据),第三方物流API需要真实外网请求,CI上这两个都没有。跑一次测试,等数据库连接超时就要等30秒。

后来我做了个决定:把所有外部依赖全部Mock掉。结果测试从268秒缩短到3.2秒,CI跑完还能顺手多跑几百个断言。这篇把当时踩坑的过程和最终方案记录下来。

问题:单元测试为什么难写

先看这个最简版业务代码(PHP 8.3 + Laravel 11 + PHPUnit 11):

//app/Services/OrderService.php
<?php
namespace App\Services;

use App\Models\Order;
use App\External\LogisticsApi;

class OrderService
{
    public function __construct(
        private Order $orderModel,
        private LogisticsApi $logisticsApi
    ) {}

    /**
     * 查询订单物流轨迹
     */
    public function getOrderTracking(string $orderNo): array
    {
        // 1. 查本地数据库
        $order = $this->orderModel->where('order_no', $orderNo)->first();

        if (!$order) {
            return ['found' => false];
        }

        // 2. 调第三方物流接口
        $tracking = $this->logisticsApi->query($order->tracking_code);

        // 3. 组装
        return [
            'found' => true,
            'order_no' => $order->order_no,
            'tracking_code' => $order->tracking_code,
            'tracking' => $tracking
        ];
    }
}

这个类有两个外部依赖:Order模型要连MySQL,LogisticsApi要发HTTP请求。直接new真实对象去测,跑一次要几秒钟,CI上还可能失败在环境问题上。

方案对比:两种常见做法

方案A:用测试数据库 + 沙箱环境(传统做法)

  • 配置独立的MySQL测试库,每个测试前用 RefreshDatabase 迁移
  • 第三方接口用沙箱地址或VCR录制回放
  • 优点:更接近真实环境
  • 缺点:慢。跑一个复杂模块的200个用例,整体耗时5分钟以上;本地和CI环境不一致,动不动出现"本地能过CI挂了"

方案B:Mock外部依赖(本文章方案)

  • 用Mockery或PHPUnit原生createMock替换所有外部依赖
  • 单测只验证当前类的逻辑,不访问网络、不查数据库
  • 优点:秒级测试、CI稳定
  • 缺点:可能测不出真实环境问题(比如SQL语法错)。所以还需要少量集成测试兜底,后面避坑部分细说

核心结论:单测目标不是为了证明"能连上MySQL",是为了验证你的逻辑在外部依赖符合预期时是否正确。如果你觉得"必须连真实库才算测过",那是集成测试的事。

PHPUnit 11 + Mockery 完整实现

1. 安装依赖

PHP 8.3 环境,用的是 PHPUnit 11.0、Mockery 1.6.7。composer.json加一行:

composer require --dev phpunit/phpunit:^11.0 mockery/mockery:^1.6

Laravel 11自带的基础TestCast已经加了Mockery集成,不需要额外注册。

2. 手写一个测试用例:最基础的Mock写法

// tests/Feature/OrderServiceTest.php
<?php
namespace Tests\Feature;

use App\Models\Order;
use App\Services\OrderService;
use App\External\LogisticsApi;
use Mockery;
use Tests\TestCase;

class OrderServiceTest extends TestCase
{
    public function test_order_not_found_returns_found_false(): void
    {
        // 1. mock Order模型:永远返回null(查不到订单)
        $orderModel = Mockery::mock(Order::class);
        $orderModel->shouldReceive('where')->andReturnSelf();
        $orderModel->shouldReceive('first')->andReturn(null);

        // 2. mock 物流API:这个用例不该被调用
        $logisticsApi = Mockery::mock(LogisticsApi::class);
        $logisticsApi->shouldNotReceive('query');

        // 3. 直接new服务,注入mock依赖
        $service = new OrderService($orderModel, $logisticsApi);

        $result = $service->getOrderTracking('DNE-ORDER-001');

        $this->assertSame(['found' => false], $result);
    }

    public function test_order_found_calls_api_and_returns_tracking(): void
    {
        // mock出完整的订单对象
        $order = new \stdClass();
        $order->order_no = 'ORD-2024001';
        $order->tracking_code = 'SF123456789';

        // mock Order模型链式调用:where()->first()返回订单
        $orderModel = Mockery::mock(Order::class);
        $orderModel->shouldReceive('where')->andReturnSelf();
        $orderModel->shouldReceive('first')->andReturn($order);

        // mock 物流API:传入快递单号,返回轨迹数组
        $logisticsApi = Mockery::mock(LogisticsApi::class);
        $logisticsApi->shouldReceive('query')
            ->once()
            ->with('SF123456789')
            ->andReturn([['time' => '2024-03-21 10:00', 'desc' => '已签收']]);

        $service = new OrderService($orderModel, $logisticsApi);

        $result = $service->getOrderTracking('ORD-2024001');

        $this->assertTrue($result['found']);
        $this->assertSame('SF123456789', $result['tracking_code']);
        $this->assertCount(1, $result['tracking']);
    }
}

跑一下:

./vendor/bin/phpunit tests/Feature/OrderServiceTest.php --testdox

输出:

PHPUnit 11.0.3 by Sebastian Bergmann.

✔ Order not found returns found false
✔ Order found calls api and returns tracking

Time: 00:00.024, Memory: 12.00 MB

OK (2 tests, 6 assertions)

3. 复杂场景:依赖注入容器+Mockery的另一种替代

如果业务代码是直接通过服务容器解析的(比如控制器里 app(OrderService::class)),mock对象需要绑定到容器。用Laravel的绑定语法:

// 在测试方法里
$this->instance(LogisticsApi::class, Mockery::mock(LogisticsApi::class, function ($mock) {
    $mock->shouldReceive('query')
        ->with('SF123456789')
        ->andReturn(['time' => '2024-03-21 10:00', 'desc' => '已签收']);
}));

绑定之后,所有通过容器解析OrderService的地方都会自动获取这个mock实例。

4. 用PHPUnit原生createMock(不引Mockery也能写)

Mockery在PHPUnit里是增强版,但原生也能做基本功能。看这个例子:

use PHPUnit\Framework\TestCase;

class OrderServiceTest extends TestCase
{
    public function test_uses_dependency_injection(): void
    {
        // 原生createMock
        $logisticsApi = $this->createMock(LogisticsApi::class);

        // 配置方法返回值
        $logisticsApi->method('query')
            ->with('SF123456789')
            ->willReturn([['time' => '2024-03-21 10:00']]);

        // 验证方法被调用了一次
        $logisticsApi->expects($this->once())
            ->method('query');

        $orderModel = $this->createMock(Order::class);
        $orderModel->method('where')->willReturnSelf();
        $orderModel->method('first')->willReturn(null);

        $service = new OrderService($orderModel, $logisticsApi);
        $result = $service->getOrderTracking('NOPE');

        $this->assertSame(['found' => false], $result);
    }
}

5. 更真实的数据验证:用匿名类代替mock

有些场景mock太"虚"。比如你想确保模型返回的字段,服务里都用上了。可以造一个真实但极简的对象,PHP的匿名类直接模拟:

$orderModel = new class extends Order {
    public function where($column, $operator = null, $value = null)
    {
        return $this;
    }
    public function first()
    {
        $order = new \stdClass();
        $order->order_no = 'ORD-2024001';
        $order->tracking_code = 'SF123456789';
        return $order;
    }
};

这种写法适合不想引入Mockery、又希望类型约束可见的场景。但缺点是不能做调用次数校验。我个人推荐Mockery为主、匿名类为辅。

效果数据

同一套测试用例(32个用例,覆盖5个业务方法),三种方式对比:

方案 总耗时 平均单个用例 网络依赖 数据库依赖
真实MySQL + 沙箱物流API 268 秒 8.4 秒 是(DNS解析+TLS握手)
基础缓存 + 本地数据库开关 89 秒 2.8 秒 否(部分降级)
Mockery全mock(本文) 3.2 秒 0.1 秒

3.2秒里还有2秒是Laravel框架初始化,纯测试逻辑1秒多一点。

原理:Mock到底在做什么

Mockery的shouldReceive('query')核心机制是:在mock对象内部,把query方法替换成一个"期望检查器"。你调用时,它不会执行真实代码,而是记录调用参数、调用次数,然后返回预设值。测试结束时Mockery会比对调用次数是否符合预期(比如once()要求恰好一次)。

这个机制成立的前提是:业务类之间通过构造函数或容器接口交互,而不是new硬编码。这提示我们——面向接口编程不只是架构洁癖,更是可测试性的硬需求。

PHPUnit的createMock原理类似,只是它自动生成一个继承自原类的mock子类(通过PHP的匿名类反射机制),在不覆盖方法时才保留真实逻辑。它能mock的范围取决于方法可见性,private方法不能直接mock,需要setAccessible或者重构。

避坑指南

以下坑都是实际踩过的。

坑1:Mockery的shouldReceive方法名大小写敏感

接口方法名是 getUserInfo(),你在mock里写shouldReceive('getuserinfo'),Mockery不会报错,但预期永远不会命中。结果测试永远拿不到返回值。排查方法是:用Mockery::close()时如果出现"method not expected",说明方法名不匹配。建议写mock前先grep原类方法名。

grep -n "function query" app/External/LogisticsApi.php

坑2:接口返回类型与mock返回值类型不一致

PHP 8.3有严格类型,LogisticsApi声明了return array,mock返回了string,直接TypeError。Mockery默认不做类型校验,到用的时候才崩。写法上用andReturn(['time'=>'xxx'])而不是andReturn('xxx')

坑3:final类是mock不了的

你在同事的类上加final修饰,然后Mockery直接抛异常。PHPUnit原生createMock在PHPUnit 10以后能mock final类,但Mockery不行。遇到这种情况,要么去final,要么改用匿名类方案。

坑4:过度mock导致测试废掉

如果什么内容都mock,最后测试就是"0和1的自我验证"。比如连一个简单的字符串拼接都通过mock对象返回,那测试没意义。我的标准是:断言逻辑分支(if/else/foreach)时就mock外部输入,但不要mock内部方法的输出。

坑5:CI上Mockery的静态方法泄漏

如果A测试里mock了一个静态方法,B测试用到同一个类时静态方法不会自动还原。Mockery官方说Mockery::close()会清理,但你在tearDown()必须显示调用。Laravel的TestCase基类默认做了,但如果你用的是纯PHPUnit类(不继承TestCase),一定要手动加:

protected function tearDown(): void
{
    \Mockery::close();
    parent::tearDown();
}

尤其注意:一个测试文件里多个测试方法共享同一个mock类的静态属性,第二个测试会莫名其妙失败,甚至报错"Call to a member function on null"。

总结

用Mockery做单元测试隔离后,我维护的订单模块测试从268秒降到3.2秒,代码覆盖率从68%升到91%。主要工作是重新梳理了依赖注入边界,把new出来的第三方客户端都换成了构造器注入。整个改动没用任何黑科技,就是把外部依赖"挡在门外"。

不同场景的选型参考:

  • 单测(本地毫秒级验证逻辑):优先Mockery,无网络无数据库
  • 集成测试(验证SQL/事务/真实SDK):用真实MySQL沙箱,数量控制在10个以内,只为关键路径写
  • 契约测试(验证第三方接口字段变更):用录制回放或VCR,不依赖真网