Как создать Mock-объекты в PHPUnit и Laravel

Как создать Mock-объекты в PHPUnit и Laravel

В процессе изучения и написания тестов, самой сложной темой для меня был вопрос создания фейковых объектов и поэтому зачастую я обходил этот процесс стороной и редко пользовался всеми возможностями. Но сегодня, когда я имею ясное представление об этой теме, решил написать краткую статью по созданию Mock-объектов.

В процессе изучения и написания тестов, самой сложной темой для меня был вопрос создания фейковых объектов и поэтому зачастую я обходил этот процесс стороной и редко пользовался всеми возможностями. Но сегодня, когда я имею ясное представление об этой теме, решил написать краткую статью по созданию Mock-объектов.

Что такое Mock-объекты?

В процессе тестирования нашего приложения, код может взаимодействовать с различными классами и компонентами системы. И бывают такие случаи, когда вы не хотели бы, чтобы какой-то класс в процессе запуска тестов вызывался. Например, по причине, что он парсит какой-то тяжёлый файл или отправляет Webhook. Поэтому вы можете заменить их фейковыми классами. И так как в процессе запуска тестов мы уже не взаимодействуем с оригинальными классами, мы получаем достаточную свободу в передаче каких-то параметров и возвращаемых данных.

Для чего нужны Mock-объекты?

Итак, мы примерно определились тем, что такое Mock-объекты. Теперь попробуем ответить на вопрос для чего они нам нужны.

Я вижу несколько важных причин их использования.

1. У вас нет возможности использовать оригинальный класс. Бывают случаи, когда класс может отсылать email или sms пользователям, отправлять сложный API-запрос и мы не хотим, чтобы тесты совершали эти операции.

Например, в нашем проекте есть функционал по поиску адресов, который обращается к API dadata. Этот API не бесплатный, каждый запрос стоит денег. Поэтому отправлять реальные запросы в тестах довольно плохая идея. В этом случае достаточно просто создать фейковый класс взаимодействия с dadata и сказать ему, какие данные он должен нам вернуть без реальной отправки сервису.

2. Изолированное тестирование. Лучший подход к тестированию — запускать ваш код изолированно. Если вы не хотите добавлять в ваш процесс тестирования слишком много зависимостей и сделать так, чтобы он был максимально изолированным, без мок-объектов вам не обойтись.

 

Пример работы с Mock-объектом. Тестируем приём платежа.

Ниже приведу пример работы с фейковыми объектами. Я создал простое приложение, которое проводит платёж.

Давайте представим, что у нас имеется специальный платёжный шлюз в виде следующего класса:

 

<?php

namespace App\Services;

 

use App\Models\Payment;

 

class PaymentGatewayService implements PaymentGatewayServiceInterface

{

    public function doPayment(string $token, \App\Models\User $user): int

    {

        \Stripe\Stripe::setApiKey(config('services.stripe.secret'));

 

        $payment = Payment::create([

            'amount' => $user->balance,

            'currency' => 'EUR',

            'source' => $token,

            'name' => 'Balance from ' . $user->name,

            'email' => $user->email

        ]);

        return $payment->id;

    }

}

 

Как вы видите, платёжный класс использует внешнюю зависимость и помимо создания платежа, он проводит его. И в наших тестах мы не хотели бы делать лишние API-запросы.

В нашем контроллере мы просто принимаем этот сервис как зависимость:

 

    public function store(Request $request, PaymentGatewayService $paymentGatewayService)

    {

        $payment_id = $paymentGatewayService->doPayment($request->input('token'), Auth::user());

        return redirect(route('leads.create'))->with(['message' => 'Payment created with id: ' . $payment_id]);

    }

 

В качестве примера, я добавил здесь простой вызов сервиса, но определённо в реальных проектах помимо приёма платежа добавляется дополнительная бизнес-логика. Поэтому, представим, что в нашем тесте мы не хотим совершать внешние вызовы, а просто ожидаем получить id нашего платежа. Следовательно, нам остаётся создать фейковый класс PaymentServiceGateway.

Давайте сделаем это:

 

<?php

 

namespace Tests\Feature;

 

use App\Models\User;

use App\Services\PaymentGatewayService;

use Tests\TestCase;

 

class PaymentGatewayServiceTest extends TestCase

{

    public const TEST_TOKEN = 'test_token';

 

    public function testPaymentCreatedSuccessfully(): void

    {

        $this->withoutExceptionHandling();

        $paymentService = $this->mock(PaymentGatewayService::class);

        $paymentService->shouldReceive('doPayment')->with(self::TEST_TOKEN, \Mockery::type(User::class))->andReturn('payment-id');

        $response = $this->post(route('payment.store'), [

            'amount' => 2000,

            'currency' => 'EUR',

            'token' => self::TEST_TOKEN

        ]);

        $response->assertRedirect('/leads');

    }

}

 

Итак, мы вызываем метод $this->mock(), в который мы передаём наш класс, копию которого предстоит создать. Далее мы говорим PHPUnit, что хотим конкретно для метода doPayment отправлять данные с тестовым токеном и класс User. А возвращать он нам должен id платежа.

Перед выполнением метода doPayment нашего сервиса фиктивный метод подготавливает копию объекта класса PaymentServiceGateway и заменяет его оригиналом везде, где он появляется при выполнении.

 

В случае, если в вашем классе несколько методов, например, один выполняет приём платежа, а другой сохраняет его в базу и вы хотите сделать Mock только метода по приёму платежа, а запись в базу оставить в оригинальном исполнении, вы можете использовать частичный мокинг:

 

$this->partialMock(PaymentGatewayService::class, function (MockInterface $mock) {

            $mock->shouldReceive('doPayment')->once();

        });

 

При этом мы будем только имитировать метод doPayment класса PaymentGatewayService, а для других методов он по-прежнему будет использовать исходный код метода.

 

Тестируем ответ JSON

В случае, если ваш оригинальный класс возвращает ответ JSON, вы можете очень просто протестировать это поведение через фейковые классы:

 

$this->mock(PaymentServiceGateway::class, function (MockInterface $mock) {

            $mock->shouldReceive('doPayment')

                 ->once()

                 ->andReturn(json_decode(json_encode([

                    'amount' => 5000,

                    'key' => 'test_key',

                ]))); 

        });

 

       $this->post('/payment')

            ->assertJsonFragment(['amount' => 5000]);

 

Стоит отметить, что вы не можете делать Mock объектов класса, который является final. В этом случае вам нужно работать с интерфейсами, а именно:

 

        $this->mock(PaymentGatewayServiceInterface::class, function (MockInterface $mock) {

            $mock->shouldReceive('doPayment')->once();

        });

 

Только не забудьте зарегистрировать ваш интерфейс в ServiceProvider.

А контроллер будет выглядеть следующим образом:

 

    public function store(Request $request, PaymentGatewayServiceInterface $paymentGatewayService)

    {

        $payment_id = $paymentGatewayService->doPayment($request->input('token'), Auth::user());

        return redirect(route('leads.create'))->with(['message' => 'Payment created with id: ' . $payment_id]);

    }

 

Для меня это наиболее предпочтительный способ работы, т.к. я чаще всего обозначаю классы финальными, чтобы не создавать большую цепочку наследования.

Важным моментом этого этапа тестирования является то, что мы не только создали отдельный класс для приёма платежа, мы минимизировали связь между различными компонентами фреймворка, сделали код более читаемым и легче в рефакторинге.