模拟测试

简介

在测试 Laravel 应用时,可能希望「模拟」应用的某些功能,从而避免在给定测试中真正执行。例如,在测试分发事件的控制器时,可能希望模拟事件监听器,从而在测试时不真正执行它们。这允许您只测试控制器的 HTTP 响应,而不用担心执行事件监听器,因为事件监听器可以在它们自己的测试用例中测试。

Laravel 为模拟事件,任务和开箱即用的 Facades 提供了辅助方法。这些辅助方法主要在 Mockery 之上提供了方便的封装,因此您无需手动调用复杂的 Mockery 方法。当然,可以自由使用 Mockery 或 PHPUnit 创建自己的模拟实现。

任务模拟

作为替代的模拟方法,可以使用 Bus Facade 的 fake 方法防止任务被分发。使用模拟时,可以在测试代码执行后进行断言:

namespace Tests\Feature;

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

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

        // 执行订单配送

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

        // 断言任务没有被分发
        Bus::assertNotDispatched(AnotherJob::class);
    }
}

事件模拟

作为替代的模拟方法,可以使用 Event Facade 的 fake 方法防止事件监听器执行。然后断言事件被分发了并且事件检查了它们接收的数据。使用模拟时,可以在测试代码执行后进行断言:

namespace Tests\Feature;

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

class ExampleTest extends TestCase
{
    /**
     * 测试订单配送
     */
    public function testOrderShipping()
    {
        Event::fake();

        // 执行订单配送

        Event::assertDispatched(OrderShipped::class, function ($e) use ($order) {
            return $e->order->id === $order->id;
        });

        // 断言事件被分发了两次
        Event::assertDispatched(OrderShipped::class, 2);

        // 断言事件没有被分发
        Event::assertNotDispatched(OrderFailedToShip::class);
    }
}

调用 Event::fake() 后,没有事件监听器会被执行。因此,如果测试时使用的模型工厂依赖于事件,例如在触发模型的 creating 事件时创建 UUID,应该在使用工厂调用 Event::fake()

模拟一组事件

如果要为指定的一组事件模拟事件监听器,可以将其传递给 fakefakeFor 方法:

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

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

    Event::assertDispatched(OrderCreated::class);

    // 其它事件仍和平时一样被分发
    $order->update([...]);
}

带作用域的事件模拟

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

namespace Tests\Feature;

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

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

            Event::assertDispatched(OrderCreated::class);

            return $order;
        });

        // 其它事件仍和平时一样被分发并运行观察者
        $order->update([...]);
    }
}

邮件模拟

可以使用 Mail Facade 的 fake 方法防止邮件被发送。然后断言 邮件 被发送给用户并且事件检查了它们接收的数据。使用模拟时,可以在测试代码执行后进行断言:

namespace Tests\Feature;

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

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

        // 执行订单配送

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

        // 断言信息被发送给指定用户
        Mail::assertSent(OrderShipped::class, function ($mail) use ($user) {
            return $mail->hasTo($user->email) &&
                   $mail->hasCc('...') &&
                   $mail->hasBcc('...');
        });

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

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

如果使用后台发送的队列邮件,应该使用 assertQueued方法而不是 assertSent

Mail::assertQueued(...);
Mail::assertNotQueued(...);

通知模拟

可以使用 Notification Facade 的 fake 方法防止通知被发送。然后断言 通知 被发送给用户并且事件检查了它们接收的数据。使用模拟时,可以在测试代码执行后进行断言:

namespace Tests\Feature;

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

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

        // 执行订单配送

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

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

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

        // 断言通知通过 Notification::route() 方法被发送
        Notification::assertSentTo(
            new AnonymousNotifiable, OrderShipped::class
        );            
    }
}

队列模拟

作为替代的模拟方法,可以使用 Queue Facade 的 fake 方法防止任务被加入到队列。然后断言任务被添加到队列并且事件检查了它们接收的数据。使用模拟时,可以在测试代码执行后进行断言:

namespace Tests\Feature;

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

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

        // 执行订单配送

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

        // 断言任务被添加到了给定队列
        Queue::assertPushedOn('queue-name', ShipOrder::class);

        // 断言任务添加了两次
        Queue::assertPushed(ShipOrder::class, 2);

        // 断言任务没有被添加
        Queue::assertNotPushed(AnotherJob::class);

        // 断言任务按指定任务链被添加
        Queue::assertPushedWithChain(ShipOrder::class, [
            AnotherJob::class,
            FinalJob::class
        ]);
    }
}

存储模拟

Storage Facade 的 fake 方法允许您轻松生成模拟磁盘,结合使用 UploadedFile 类的文件生成,极大地简化了文件上传的测试。例如:

namespace Tests\Feature;

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

class ExampleTest extends TestCase
{
    public function testAvatarUpload()
    {
        Storage::fake('avatars');

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

        // 断言文件已存储
        Storage::disk('avatars')->assertExists('avatar.jpg');

        // 断言文件不存在
        Storage::disk('avatars')->assertMissing('missing.jpg');
    }
}

默认情况下,fake 方法会删除其临时文件夹中所有的文件。如果想要保留这些文件,可以使用 persistentFake 方法代替。

Facades

不像传统的静态方法调用, Facades 也可以被模拟。与传统的静态方法相比,这提供了很大的优势,并且如果您使用依赖注入,可以获得相同的可测试性。在测试时,您可能经常想在一个控制器中模拟对 Laravel Facade 的调用。例如,考虑以下控制器操作:

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Cache;

class UserController extends Controller
{
    /**
     * 展示应用的所有用户列表
     *
     * @return Response
     */
    public function index()
    {
        $value = Cache::get('key');

        //
    }
}

我们可以通过使用 shouldReceive 方法模拟对 Cache Facade 的调用,它会返回一个 Mockery 模拟的实例。由于 Facades 实际上是由 Laravel 的 服务容器 解析和管理的,因此它们比通常的静态类有更多可测试性。例如,我们模拟调用 Cache Facade 的 get 方法:

namespace Tests\Feature;

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

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

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

        // ...
    }
}

不要模拟 Request Facade。而是在运行测试时传递想要的输入到 HTTP 辅助方法中,例如 getpost。同样,应该在测试时调用 Config::set 方法,而不是模拟 Config Facade。