Laravel Фасады — Пишем тестируемый код

Источник: «Laravel Facades — Write Testable Code»
Laravel в значительной степени опирается на фасады. Кто-то может подумать, что это антипаттерны. Но я считаю, что при правильном использовании они могут привести к чистому и тестируемому коду. Давайте посмотрим, как это сделать.

По тем или иным причинам Фасады Laravel не пользуются особой любовью. Я часто читаю, что они не являются настоящей реализацией фасадов, и позвольте мне сказать, это не так. Они больше похожи на прокси, чем на фасады. Если задуматься, они просто переадресуют вызовы своим классам, фактически перехватывая запрос. Когда вы начинаете смотреть на них с этой стороны, вы понимаете, что фасады, если их правильно использовать, могут привести к чистому и тестируемому коду. Итак, не зацикливайтесь на именовании, ведь какая разница, как они называются? И давайте посмотрим, как их можно использовать.

Такие же, но другие... Но всё равно одинаковые

При написании сервисных классов я не люблю использовать статические методы, они затрудняют тестирование зависимых классов. Однако мне нравятся чистые вызовы, которые они предлагают, например Service::action(). Благодаря фасадам Laravel реального времени мы можем этого достичь.

Рассмотрим пример

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use App\Exceptions\CouldNotFetchRates;
use App\Entities\ExchangeRate;

class ECBExchangeRateService
{
public static function getRatesFromApi(): ExchangeRate
{
$response = Http::get('ecb_url'); // Избегайте жёсткого кодирования URL-адресов, это просто пример

throw_if($response->failed(), CouldNotFetchRates::apiTimeout('ecb'));

return ExchangeRate::from($response->body());
}
}

У нас имеется сверх упрощённый сервисный класс, пытающийся получить курсы валют из API и возвращает DTO (Entity, или что вам больше нравится), если всё прошло успешно.

Теперь мы можем использовать этот сервис следующим образом

<?php

namespace App\Classes;

use App\Services\ECBExchangeRateService;

class AnotherClass
{
public function action(): void
{
$rates = ECBExchangeRateService::getRatesFromApi();

// Делаем что-то со ставками
}
}

Код может выглядеть чистым, но он не годится, его нельзя протестировать. Мы можем написать функциональные тесты или интеграционные тесты, но когда дело доходит до модульного тестирования этого класса, мы не можем. Нет способа имитировать ECBExchangeRateService::getRatesFromApi(), а юнит-тесты не должны иметь зависимостей (взаимодействий с различными классами или системами).

Поскольку мы обсуждаем модульные тесты, хочу подчеркнуть, что "не следует" не означает "не нужно". Иногда имеет смысл использовать взаимодействие с базой данных в модульных тестах, например, проверить, загружены ли отношения или нет 🤷. Не следуйте слепо правилам: иногда они имеют смысл, иногда — нет.

Итак, чтобы исправить ситуацию, нужно выполнить несколько шагов:

  1. Преобразовать статическую функцию getRatesFromApi() в обычную;
  2. Создать новый интерфейс, определяющий, какие методы должны быть реализованы в ECBExchangeRateService (опционально);
  3. Привязать только что определённый интерфейс к сервисному классу в провайдере услуг Laravel (опционально);
  4. Использовать инъекцию зависимостей, через конструктор или непосредственно в методе, в зависимости от того, как вы хотите, чтобы выглядел ваш API.

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

С фасадами реального времени мы можем превратить 4 шага в 2:

  1. Конвертировать статическую функцию getRatesFromApi() в обычную (просто убираем ключевое слово static);
  2. Добавьте в импорт ключевое слово Facades.

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

<?php

namespace App\Classes;

use Facades\App\Services\ECBExchangeRateService; // Единственное изменение, которое нам нужно.

class AnotherClass
{
public function action(): void
{
$rates = ECBExchangeRateService::getRatesFromApi();

// Делаем что-то со ставками
}
}

Это всё, что было нужно сделать! Убрать 1 ключевое слово и добавить ещё одно. Вы не сможете это превзойти!

Вот как мы можем протестировать наш код теперь

it('does something with the rates', function () {
ECBExchangeRateService::shouldReceive('getRatesFromApi')->once();

(new AnotherClass)->action();
});

ECBExchangeRateService будет разрешён из контейнера, как и в 4 шагах выше, без необходимости создавать дополнительные интерфейсы или добавлять дополнительный код. Мы сохраняем чистый, простой подход и обеспечиваем тестируемость. И я знаю, что некоторые с этим не согласятся, отвергнув это как тёмную магию. Ну, это не совсем магия, если она есть в документации; читайте документацию, детки!

Возможность горячей замены

Помните, я говорил, что фасады — это прокси? Давайте объясню.

При использовании Laravel Queues мы отправляем задания в наш код. Когда тестируете этот код, вас не интересует, работает ли реальное задание так, как ожидалось, или нет; это можно проверить отдельно. Вместо этого интересует, было ли задание отправлено, сколько раз оно было отправлено, какая полезная нагрузка использовалась и т. д. Итак, чтобы добиться этого, нам понадобятся две реализации, верно? Dispatcher и DispatcherFake — один, действительно отправляющий задание на Redis, MySQL или то, для чего вы его задали, и второй, ничего не отправляющий, а просто перехватывающий эти события.

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

Сначала определим интерфейс

<?php

namespace App\Contracts;

interface Dispatcher
{
public function dispatch(mixed $job, mixed $handler): mixed
}

Затем возможны две реализации

<?php

namespace App\Bus;

use PHPUnit\Framework\Assert;
use App\Contracts\Dispatcher as DispatcherContract;

class Dispatcher implements DispatcherContract
{
public function dispatch(mixed $job, mixed $handler): mixed
{
// На самом деле отправьте это в DB или любой другой установленный драйвер
}
}

class DispatcherFake implements DispatcherContract
{
protected $jobs = [];

public function dispatch(mixed $job, mixed $handler): mixed
{
// Мы просто записываем отправления здесь
$this->jobs[$job] = $handler;
}

// Можем добавить тестирующие хелперы
public function assertDispatched(mixed $job)
{
Assert::assertTrue(count($this->jobs[$job]) > 0);
}

public function assertDispatchedTimes(mixed $job, int $times = 1)
{
Assert::assertTrue(count($this->jobs[$job]) === $times);
}

// ... и другие методы
}

Теперь, вместо того чтобы вручную разрешать реализацию и связывать несколько реализаций, можно воспользоваться фасадами. Они перехватывают вызов, и можно выбрать, куда его перенаправить!

<?php

namespace App\Facades;

use App\Bus\DispatcherFake;
use Illuminate\Support\Facades\Facade;

class Dispatcher extends Facade
{
protected static function fake()
{
return tap(new DispatcherFake(), function ($fake) {
// Это установит $resolvedInstance в фейковый экземпляр
// Поэтому каждый раз, когда мы пытаемся получить доступ
// к основной реализации, будет возвращаться фейковый объект
static::swap($fake);
});
}

protected static function getFacadeAccessor()
{
return 'dispatcher';
}
}

Хотите узнать, как фасады работают под капотом? Я написал об этом статью Laravel под капотом: Facades.

Теперь можно просто привязать наш диспетчер к контейнеру приложения.

<?php

namespace App\Providers;

use App\Bus\Dispatcher;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind('dispatcher', function ($app) {
return new Dispatcher;
});
}

// ...
}

И всё! Теперь можно элегантно переключаться между реализациями, а код можно тестировать, используя чистые вызовы, без инъекций (но с тем же эффектом).

use App\Facades\Dispatcher; // импорт фасада

it('does dispatches a job', function () {
// Это позволит установить фейковую реализацию в качестве разрешённого объекта
Dispatcher::fake();

// Экшен, отправляющий задание `Dispatcher::dispatch(Job::class, Handler::class);
(new Action)->handle();

// Теперь можно утверждать, что оно было отправлено
Dispatcher::assertDispatched(Job::class);
});

Это гипер упрощённый пример, чтобы взглянуть на вещи с новой стороны. Интересно, что именно так ваш любимый фреймворк тестирует вещи внутри себя. Так что, возможно, фасады — не такой уж и антипаттерн, как вы думаете. Они могут называться неправильно, но вы можете увидеть, как они всё упрощают.

Заключение

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

Дополнительные материалы

Предыдущая Статья

Свободно экспериментируйте над кодом с Git worktree

Следующая Статья

Введение в Flexbox