跳转到主要内容

为什么使用 Mock

在编写测试时,有些处理是我们实际上并不希望真正执行的,比如发送邮件、读写缓存、调用外部 API 等。 用「装作真实存在的假对象」替换这些处理,就叫做 Mock(模拟)。 使用 Mock 的好处:
  • 测试更快 — 不执行外部服务或耗时处理,测试很快完成
  • 测试更稳定 — 不受外部服务状态影响,结果始终一致
  • 测试范围更清晰 — 可以只验证某一个类或方法
Laravel 为事件、任务、门面等提供了即用型的 Mock 辅助方法。 内部使用 Mockery,无需复杂设置即可使用。

Mock 对象

要 mock 通过服务容器注入的对象,可以将 mock 实例绑定到容器上。 这样容器就会使用 mock 实例,而不是新建对象。
use App\Service;
use Mockery;
use Mockery\MockInterface;

test('something can be mocked', function () {
    $this->instance(
        Service::class,
        Mockery::mock(Service::class, function (MockInterface $mock) {
            $mock->expects('process');
        })
    );
});
use App\Service;
use Mockery;
use Mockery\MockInterface;

public function test_something_can_be_mocked(): void
{
    $this->instance(
        Service::class,
        Mockery::mock(Service::class, function (MockInterface $mock) {
            $mock->expects('process');
        })
    );
}

mock() 方法

使用 Laravel 测试基类提供的 mock() 方法,可以更简洁地写出等效逻辑。
use App\Service;
use Mockery\MockInterface;

$mock = $this->mock(Service::class, function (MockInterface $mock) {
    $mock->expects('process');
});

partialMock() 方法

如果只想 mock 对象的部分方法,可以使用 partialMock()。 未被 mock 的方法会照常执行。
use App\Service;
use Mockery\MockInterface;

$mock = $this->partialMock(Service::class, function (MockInterface $mock) {
    $mock->expects('process');
});

spy() 方法

Spy(间谍对象)与 mock 类似,不同之处在于它是在代码执行之后再验证交互。 Mock 在事前设置「这个方法应当被调用」,而 spy 是在事后检查「这个方法是否被调用了」。
use App\Service;

$spy = $this->spy(Service::class);

// ...执行被测代码...

$spy->shouldHaveReceived('process');

Mock 门面

与普通的静态方法调用不同,门面是可以被 mock 的。 它拥有与依赖注入相当的可测试性,同时保留了简洁的书写方式。 以下面这个使用缓存的控制器为例:
<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Cache;

class UserController extends Controller
{
    /**
     * 返回应用中的全部用户列表
     */
    public function index(): array
    {
        $value = Cache::get('key');

        return [
            // ...
        ];
    }
}
要 mock Cache 门面的 get 方法,请使用 expects()
<?php

use Illuminate\Support\Facades\Cache;

test('get index', function () {
    Cache::expects('get')
        ->with('key')
        ->andReturn('value');

    $response = $this->get('/users');

    // ...
});
<?php

namespace Tests\Feature;

use Illuminate\Support\Facades\Cache;
use Tests\TestCase;

class UserControllerTest extends TestCase
{
    public function test_get_index(): void
    {
        Cache::expects('get')
            ->with('key')
            ->andReturn('value');

        $response = $this->get('/users');

        // ...
    }
}
请勿 mock Request 门面。作为替代,请将输入值传给 getpost 等 HTTP 测试方法。同样,请不要 mock Config 门面,而是在测试内调用 Config::set()

门面 Spy

要用 spy 监视门面,请调用对应门面的 spy() 方法。 Spy 适合在代码执行后再验证交互的场景。
<?php

use Illuminate\Support\Facades\Cache;

test('values are stored in cache', function () {
    Cache::spy();

    $response = $this->get('/');

    $response->assertStatus(200);

    Cache::shouldHaveReceived('put')->with('name', 'Taylor', 10);
});
use Illuminate\Support\Facades\Cache;

public function test_values_are_stored_in_cache(): void
{
    Cache::spy();

    $response = $this->get('/');

    $response->assertStatus(200);

    Cache::shouldHaveReceived('put')->with('name', 'Taylor', 10);
}

操作时间

在测试与时间相关的逻辑时,能够修改 now()Carbon::now() 返回的时刻非常方便。 Laravel 的功能测试基类提供了操作时间的辅助方法。

travel() — 移动时间

test('time can be manipulated', function () {
    // 前进到未来
    $this->travel(5)->milliseconds();
    $this->travel(5)->seconds();
    $this->travel(5)->minutes();
    $this->travel(5)->hours();
    $this->travel(5)->days();
    $this->travel(5)->weeks();
    $this->travel(5)->years();

    // 回到过去
    $this->travel(-5)->hours();

    // 移动到某个具体时刻
    $this->travelTo(now()->subHours(6));

    // 回到当前时刻
    $this->travelBack();
});
public function test_time_can_be_manipulated(): void
{
    // 前进到未来
    $this->travel(5)->milliseconds();
    $this->travel(5)->seconds();
    $this->travel(5)->minutes();
    $this->travel(5)->hours();
    $this->travel(5)->days();
    $this->travel(5)->weeks();
    $this->travel(5)->years();

    // 回到过去
    $this->travel(-5)->hours();

    // 移动到某个具体时刻
    $this->travelTo(now()->subHours(6));

    // 回到当前时刻
    $this->travelBack();
}

使用闭包进行时间移动

向时间移动方法传入闭包时,会在指定时刻将时间冻结并执行闭包,闭包执行完毕后再恢复到原来的时间。
$this->travel(5)->days(function () {
    // 在 5 天后的状态下进行测试
});

$this->travelTo(now()->subDays(10), function () {
    // 在特定时刻下进行测试
});

freezeTime() — 冻结时间

freezeTime() 会冻结当前时间。freezeSecond() 则会把时间冻结到当前秒的开头。
use Illuminate\Support\Carbon;

// 冻结时间执行闭包,结束后再恢复
$this->freezeTime(function (Carbon $time) {
    // ...
});

// 在当前秒的开头冻结时间并执行闭包
$this->freezeSecond(function (Carbon $time) {
    // ...
});

实用示例:非活跃线程被锁定

时间操作非常适合测试类似论坛「一段时间无操作后帖子被锁定」这样的功能。
use App\Models\Thread;

test('forum threads lock after one week of inactivity', function () {
    $thread = Thread::factory()->create();

    $this->travel(1)->week();

    expect($thread->isLockedByInactivity())->toBeTrue();
});
use App\Models\Thread;

public function test_forum_threads_lock_after_one_week_of_inactivity(): void
{
    $thread = Thread::factory()->create();

    $this->travel(1)->week();

    $this->assertTrue($thread->isLockedByInactivity());
}
travel() 等时间操作方法只能在功能测试(继承 Tests\TestCase 的类)中使用。在 PHPUnit 的基类 TestCase 中无法使用。

方法一览

Mock 相关

方法说明
$this->mock(Class::class, fn)创建完整 mock 并注册到容器
$this->partialMock(Class::class, fn)仅 mock 部分方法
$this->spy(Class::class)创建 spy 并注册到容器
$this->instance(Class::class, $mock)将任意 mock 实例注册到容器
Facade::expects('method')Mock 门面的方法
Facade::spy()用 spy 监视门面
$spy->shouldHaveReceived('method')校验 spy 上某方法是否被调用

时间操作相关

方法说明
$this->travel(n)->unit()按指定单位移动时间
$this->travelTo(Carbon)移动到特定时刻
$this->travelBack()回到当前时刻
$this->freezeTime(fn)冻结时间并执行闭包
$this->freezeSecond(fn)在秒开头冻结时间并执行闭包
最后修改于 2026年7月13日