Skip to content

模拟

介绍

在测试 Laravel 应用程序时,您可能希望“模拟”应用程序的某些方面,以便在给定测试期间不实际执行它们。例如,在测试调度事件的控制器时,您可能希望模拟事件监听器,以便在测试期间不实际执行它们。这使您可以仅测试控制器的 HTTP 响应,而无需担心事件监听器的执行,因为事件监听器可以在自己的测试用例中进行测试。

Laravel 提供了用于模拟事件、作业和其他 facades 的有用方法。这些助手主要提供了一个便利层,覆盖了 Mockery,因此您不必手动进行复杂的 Mockery 方法调用。

模拟对象

当模拟一个将通过 Laravel 的服务容器注入到应用程序中的对象时,您需要将模拟实例绑定到容器中作为 instance 绑定。这将指示容器使用对象的模拟实例,而不是自行构造对象:

php
use App\Service;
use Mockery;
use Mockery\MockInterface;

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

为了使这更方便,您可以使用 Laravel 基础测试用例类提供的 mock 方法。例如,以下示例等同于上面的示例:

php
use App\Service;
use Mockery\MockInterface;

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

当您只需要模拟对象的几个方法时,可以使用 partialMock 方法。未模拟的方法在调用时将正常执行:

php
use App\Service;
use Mockery\MockInterface;

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

同样,如果您想间谍一个对象,Laravel 的基础测试用例类提供了一个 spy 方法,作为 Mockery::spy 方法的便捷包装。间谍类似于模拟;然而,间谍记录间谍与被测试代码之间的任何交互,允许您在代码执行后进行断言:

php
use App\Service;

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

// ...

$spy->shouldHaveReceived('process');

模拟 Facades

与传统的静态方法调用不同,facades(包括实时 facades)可以被模拟。这提供了比传统静态方法更大的优势,并为您提供了与使用传统依赖注入相同的可测试性。在测试时,您可能经常希望模拟对 Laravel facade 的调用,这些调用发生在您的控制器之一中。例如,考虑以下控制器操作:

php
<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Cache;

class UserController extends Controller
{
    /**
     * 检索应用程序的所有用户列表。
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $value = Cache::get('key');

        //
    }
}

我们可以使用 shouldReceive 方法模拟对 Cache facade 的调用,该方法将返回一个 Mockery 模拟实例。由于 facades 实际上是由 Laravel 服务容器解析和管理的,因此它们比典型的静态类具有更高的可测试性。例如,让我们模拟对 Cache facade 的 get 方法的调用:

php
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;

class UserControllerTest extends TestCase
{
    public function testGetIndex()
    {
        Cache::shouldReceive('get')
                    ->once()
                    ->with('key')
                    ->andReturn('value');

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

        // ...
    }
}
exclamation

您不应模拟 Request facade。相反,在运行测试时,将所需的输入传递给 HTTP 测试方法,如 getpost。同样,不要模拟 Config facade,而是在测试中调用 Config::set 方法。

Facade 间谍

如果您想间谍一个 facade,您可以在相应的 facade 上调用 spy 方法。间谍类似于模拟;然而,间谍记录间谍与被测试代码之间的任何交互,允许您在代码执行后进行断言:

php
use Illuminate\Support\Facades\Cache;

public function test_values_are_be_stored_in_cache()
{
    Cache::spy();

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

    $response->assertStatus(200);

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

Bus Fake

在测试调度作业的代码时,您通常希望断言某个作业已被调度,但不实际将其排队或执行。这是因为作业的执行通常可以在单独的测试类中进行测试。

您可以使用 Bus facade 的 fake 方法来防止作业被调度到队列中。然后,在执行被测试的代码后,您可以使用 assertDispatchedassertNotDispatched 方法检查应用程序尝试调度了哪些作业:

php
<?php

namespace Tests\Feature;

use App\Jobs\ShipOrder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    public function test_orders_can_be_shipped()
    {
        Bus::fake();

        // 执行订单发货...

        // 断言作业已被调度...
        Bus::assertDispatched(ShipOrder::class);

        // 断言作业未被调度...
        Bus::assertNotDispatched(AnotherJob::class);

        // 断言作业已同步调度...
        Bus::assertDispatchedSync(AnotherJob::class);

        // 断言作业未同步调度...
        Bus::assertNotDispatchedSync(AnotherJob::class);

        // 断言作业在响应发送后被调度...
        Bus::assertDispatchedAfterResponse(AnotherJob::class);

        // 断言作业在响应发送后未被调度...
        Bus::assertNotDispatchedAfterResponse(AnotherJob::class);

        // 断言没有作业被调度...
        Bus::assertNothingDispatched();
    }
}

您可以将闭包传递给可用的方法,以断言调度了通过给定“真值测试”的作业。如果至少有一个作业通过了给定的真值测试,则断言将成功。例如,您可能希望断言为特定订单调度了作业:

php
Bus::assertDispatched(function (ShipOrder $job) use ($order) {
    return $job->order->id === $order->id;
});

模拟一部分作业

如果您只想防止某些作业被调度,可以将应被模拟的作业传递给 fake 方法:

php
/**
 * 测试订单处理。
 */
public function test_orders_can_be_shipped()
{
    Bus::fake([
        ShipOrder::class,
    ]);

    // ...
}

您可以使用 except 方法模拟除指定作业之外的所有作业:

php
Bus::fake()->except([
    ShipOrder::class,
]);

作业链

Bus facade 的 assertChained 方法可用于断言调度了作业链assertChained 方法接受一个作业链数组作为其第一个参数:

php
use App\Jobs\RecordShipment;
use App\Jobs\ShipOrder;
use App\Jobs\UpdateInventory;
use Illuminate\Support\Facades\Bus;

Bus::assertChained([
    ShipOrder::class,
    RecordShipment::class,
    UpdateInventory::class
]);

如上例所示,作业链数组可以是作业类名的数组。但是,您也可以提供实际作业实例的数组。这样做时,Laravel 将确保作业实例与应用程序调度的作业链的类相同,并具有相同的属性值:

php
Bus::assertChained([
    new ShipOrder,
    new RecordShipment,
    new UpdateInventory,
]);

作业批次

Bus facade 的 assertBatched 方法可用于断言调度了作业批次。传递给 assertBatched 方法的闭包接收一个 Illuminate\Bus\PendingBatch 实例,可用于检查批次中的作业:

php
use Illuminate\Bus\PendingBatch;
use Illuminate\Support\Facades\Bus;

Bus::assertBatched(function (PendingBatch $batch) {
    return $batch->name == 'import-csv' &&
           $batch->jobs->count() === 10;
});

测试作业/批次交互

此外,您可能偶尔需要测试单个作业与其底层批次的交互。例如,您可能需要测试作业是否取消了其批次的进一步处理。为此,您需要通过 withFakeBatch 方法为作业分配一个假批次。withFakeBatch 方法返回一个包含作业实例和假批次的元组:

php
[$job, $batch] = (new ShipOrder)->withFakeBatch();

$job->handle();

$this->assertTrue($batch->cancelled());
$this->assertEmpty($batch->added);

事件 Fake

在测试调度事件的代码时,您可能希望指示 Laravel 不实际执行事件的监听器。使用 Event facade 的 fake 方法,您可以防止监听器执行,执行被测试的代码,然后使用 assertDispatchedassertNotDispatchedassertNothingDispatched 方法断言应用程序调度了哪些事件:

php
<?php

namespace Tests\Feature;

use App\Events\OrderFailedToShip;
use App\Events\OrderShipped;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    /**
     * 测试订单发货。
     */
    public function test_orders_can_be_shipped()
    {
        Event::fake();

        // 执行订单发货...

        // 断言事件已被调度...
        Event::assertDispatched(OrderShipped::class);

        // 断言事件被调度了两次...
        Event::assertDispatched(OrderShipped::class, 2);

        // 断言事件未被调度...
        Event::assertNotDispatched(OrderFailedToShip::class);

        // 断言没有事件被调度...
        Event::assertNothingDispatched();
    }
}

您可以将闭包传递给 assertDispatchedassertNotDispatched 方法,以断言调度了通过给定“真值测试”的事件。如果至少有一个事件通过了给定的真值测试,则断言将成功:

php
Event::assertDispatched(function (OrderShipped $event) use ($order) {
    return $event->order->id === $order->id;
});

如果您只想断言事件监听器正在监听给定事件,可以使用 assertListening 方法:

php
Event::assertListening(
    OrderShipped::class,
    SendShipmentNotification::class
);
exclamation

调用 Event::fake() 后,不会执行任何事件监听器。因此,如果您的测试使用依赖于事件的模型工厂,例如在模型的 creating 事件期间创建 UUID,您应该在使用工厂后调用 Event::fake()

模拟一部分事件

如果您只想为特定事件集模拟事件监听器,可以将它们传递给 fakefakeFor 方法:

php
/**
 * 测试订单处理。
 */
public function test_orders_can_be_processed()
{
    Event::fake([
        OrderCreated::class,
    ]);

    $order = Order::factory()->create();

    Event::assertDispatched(OrderCreated::class);

    // 其他事件正常调度...
    $order->update([...]);
}

您可以使用 except 方法模拟除指定事件之外的所有事件:

php
Event::fake()->except([
    OrderCreated::class,
]);

范围事件 Fake

如果您只想为测试的一部分模拟事件监听器,可以使用 fakeFor 方法:

php
<?php

namespace Tests\Feature;

use App\Events\OrderCreated;
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    /**
     * 测试订单处理。
     */
    public function test_orders_can_be_processed()
    {
        $order = Event::fakeFor(function () {
            $order = Order::factory()->create();

            Event::assertDispatched(OrderCreated::class);

            return $order;
        });

        // 事件正常调度,观察者将运行...
        $order->update([...]);
    }
}

HTTP Fake

Http facade 的 fake 方法允许您指示 HTTP 客户端在请求时返回模拟/虚拟响应。有关模拟传出 HTTP 请求的更多信息,请查阅 HTTP 客户端测试文档

邮件 Fake

您可以使用 Mail facade 的 fake 方法来防止邮件发送。通常,发送邮件与您实际测试的代码无关。最有可能的是,仅仅断言 Laravel 被指示发送给定的邮件就足够了。

在调用 Mail facade 的 fake 方法后,您可以断言邮件被指示发送给用户,甚至检查邮件接收到的数据:

php
<?php

namespace Tests\Feature;

use App\Mail\OrderShipped;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    public function test_orders_can_be_shipped()
    {
        Mail::fake();

        // 执行订单发货...

        // 断言没有邮件被发送...
        Mail::assertNothingSent();

        // 断言邮件已被发送...
        Mail::assertSent(OrderShipped::class);

        // 断言邮件被发送了两次...
        Mail::assertSent(OrderShipped::class, 2);

        // 断言邮件未被发送...
        Mail::assertNotSent(AnotherMailable::class);
    }
}

如果您将邮件排队以在后台发送,您应该使用 assertQueued 方法而不是 assertSent

php
Mail::assertQueued(OrderShipped::class);

Mail::assertNotQueued(OrderShipped::class);

Mail::assertNothingQueued();

您可以将闭包传递给 assertSentassertNotSentassertQueuedassertNotQueued 方法,以断言发送了通过给定“真值测试”的邮件。如果至少有一封邮件通过了给定的真值测试,则断言将成功:

php
Mail::assertSent(function (OrderShipped $mail) use ($order) {
    return $mail->order->id === $order->id;
});

在调用 Mail facade 的断言方法时,提供的闭包接受的邮件实例提供了检查邮件的有用方法:

php
Mail::assertSent(OrderShipped::class, function ($mail) use ($user) {
    return $mail->hasTo($user->email) &&
           $mail->hasCc('...') &&
           $mail->hasBcc('...') &&
           $mail->hasReplyTo('...') &&
           $mail->hasFrom('...') &&
           $mail->hasSubject('...');
});

邮件实例还包括几个用于检查邮件附件的有用方法:

php
use Illuminate\Mail\Mailables\Attachment;

Mail::assertSent(OrderShipped::class, function ($mail) {
    return $mail->hasAttachment(
        Attachment::fromPath('/path/to/file')
                ->as('name.pdf')
                ->withMime('application/pdf')
    );
});

Mail::assertSent(OrderShipped::class, function ($mail) {
    return $mail->hasAttachment(
        Attachment::fromStorageDisk('s3', '/path/to/file')
    );
});

Mail::assertSent(OrderShipped::class, function ($mail) use ($pdfData) {
    return $mail->hasAttachment(
        Attachment::fromData(fn () => $pdfData, 'name.pdf')
    );
});

您可能已经注意到,有两种方法可以断言邮件未被发送:assertNotSentassertNotQueued。有时您可能希望断言没有邮件被发送排队。为此,您可以使用 assertNothingOutgoingassertNotOutgoing 方法:

php
Mail::assertNothingOutgoing();

Mail::assertNotOutgoing(function (OrderShipped $mail) use ($order) {
    return $mail->order->id === $order->id;
});

测试邮件内容

我们建议将邮件内容的测试与断言给定邮件已“发送”给特定用户的测试分开。要了解如何测试邮件的内容,请查看我们的测试邮件文档。

通知 Fake

您可以使用 Notification facade 的 fake 方法来防止通知发送。通常,发送通知与您实际测试的代码无关。最有可能的是,仅仅断言 Laravel 被指示发送给定的通知就足够了。

在调用 Notification facade 的 fake 方法后,您可以断言通知被指示发送给用户,甚至检查通知接收到的数据:

php
<?php

namespace Tests\Feature;

use App\Notifications\OrderShipped;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    public function test_orders_can_be_shipped()
    {
        Notification::fake();

        // 执行订单发货...

        // 断言没有通知被发送...
        Notification::assertNothingSent();

        // 断言通知已发送给指定用户...
        Notification::assertSentTo(
            [$user], OrderShipped::class
        );

        // 断言通知未被发送...
        Notification::assertNotSentTo(
            [$user], AnotherNotification::class
        );

        // 断言发送了给定数量的通知...
        Notification::assertCount(3);
    }
}

您可以将闭包传递给 assertSentToassertNotSentTo 方法,以断言发送了通过给定“真值测试”的通知。如果至少有一个通知通过了给定的真值测试,则断言将成功:

php
Notification::assertSentTo(
    $user,
    function (OrderShipped $notification, $channels) use ($order) {
        return $notification->order->id === $order->id;
    }
);

按需通知

如果您正在测试的代码发送按需通知,您可以通过 assertSentOnDemand 方法测试按需通知是否已发送:

php
Notification::assertSentOnDemand(OrderShipped::class);

通过将闭包作为 assertSentOnDemand 方法的第二个参数传递,您可以确定按需通知是否发送到正确的“路由”地址:

php
Notification::assertSentOnDemand(
    OrderShipped::class,
    function ($notification, $channels, $notifiable) use ($user) {
        return $notifiable->routes['mail'] === $user->email;
    }
);

队列 Fake

您可以使用 Queue facade 的 fake 方法来防止排队作业被推送到队列中。最有可能的是,仅仅断言 Laravel 被指示将给定作业推送到队列中就足够了,因为排队作业本身可以在另一个测试类中进行测试。

在调用 Queue facade 的 fake 方法后,您可以断言应用程序尝试将作业推送到队列中:

php
<?php

namespace Tests\Feature;

use App\Jobs\AnotherJob;
use App\Jobs\FinalJob;
use App\Jobs\ShipOrder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    public function test_orders_can_be_shipped()
    {
        Queue::fake();

        // 执行订单发货...

        // 断言没有作业被推送...
        Queue::assertNothingPushed();

        // 断言作业被推送到给定队列...
        Queue::assertPushedOn('queue-name', ShipOrder::class);

        // 断言作业被推送了两次...
        Queue::assertPushed(ShipOrder::class, 2);

        // 断言作业未被推送...
        Queue::assertNotPushed(AnotherJob::class);
    }
}

您可以将闭包传递给 assertPushedassertNotPushed 方法,以断言推送了通过给定“真值测试”的作业。如果至少有一个作业通过了给定的真值测试,则断言将成功:

php
Queue::assertPushed(function (ShipOrder $job) use ($order) {
    return $job->order->id === $order->id;
});

如果您只需要模拟特定作业,同时允许其他作业正常执行,可以将应被模拟的作业类名传递给 fake 方法:

php
public function test_orders_can_be_shipped()
{
    Queue::fake([
        ShipOrder::class,
    ]);
    
    // 执行订单发货...

    // 断言作业被推送了两次...
    Queue::assertPushed(ShipOrder::class, 2);
}

作业链

Queue facade 的 assertPushedWithChainassertPushedWithoutChain 方法可用于检查推送作业的作业链。assertPushedWithChain 方法接受主作业作为其第一个参数,作业链数组作为其第二个参数:

php
use App\Jobs\RecordShipment;
use App\Jobs\ShipOrder;
use App\Jobs\UpdateInventory;
use Illuminate\Support\Facades\Queue;

Queue::assertPushedWithChain(ShipOrder::class, [
    RecordShipment::class,
    UpdateInventory::class
]);

如上例所示,作业链数组可以是作业类名的数组。但是,您也可以提供实际作业实例的数组。这样做时,Laravel 将确保作业实例与应用程序调度的作业链的类相同,并具有相同的属性值:

php
Queue::assertPushedWithChain(ShipOrder::class, [
    new RecordShipment,
    new UpdateInventory,
]);

您可以使用 assertPushedWithoutChain 方法断言作业被推送而没有作业链:

php
Queue::assertPushedWithoutChain(ShipOrder::class);

存储 Fake

Storage facade 的 fake 方法允许您轻松生成一个假磁盘,结合 Illuminate\Http\UploadedFile 类的文件生成工具,大大简化了文件上传的测试。例如:

php
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    public function test_albums_can_be_uploaded()
    {
        Storage::fake('photos');

        $response = $this->json('POST', '/photos', [
            UploadedFile::fake()->image('photo1.jpg'),
            UploadedFile::fake()->image('photo2.jpg')
        ]);

        // 断言一个或多个文件已存储...
        Storage::disk('photos')->assertExists('photo1.jpg');
        Storage::disk('photos')->assertExists(['photo1.jpg', 'photo2.jpg']);

        // 断言一个或多个文件未存储...
        Storage::disk('photos')->assertMissing('missing.jpg');
        Storage::disk('photos')->assertMissing(['missing.jpg', 'non-existing.jpg']);

        // 断言给定目录为空...
        Storage::disk('photos')->assertDirectoryEmpty('/wallpapers');
    }
}

默认情况下,fake 方法将删除其临时目录中的所有文件。如果您希望保留这些文件,可以使用“persistentFake”方法。有关测试文件上传的更多信息,您可以查阅 HTTP 测试文档中的文件上传信息

exclamation

image 方法需要 GD 扩展

与时间交互

在测试时,您可能偶尔需要修改 nowIlluminate\Support\Carbon::now() 等助手返回的时间。幸运的是,Laravel 的基础功能测试类包括允许您操控当前时间的助手:

php
use Illuminate\Support\Carbon;

public function testTimeCanBeManipulated()
{
    // 旅行到未来...
    $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->freezeTime(function (Carbon $time) {
        // ...
    });

    // 旅行到过去...
    $this->travel(-5)->hours();

    // 旅行到明确的时间...
    $this->travelTo(now()->subHours(6));

    // 返回到当前时间...
    $this->travelBack();
}