为什么使用 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 门面。作为替代,请将输入值传给 get、post 等 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) | 在秒开头冻结时间并执行闭包 |