Архитектура чистого кода и разработка через тестирование в PHP

в 9:11, , рубрики: php, tdd, web-разработка, Блог компании Mail.Ru Group, Веб-разработка, тестирование веб-приложений, Тестирование веб-сервисов

Архитектура чистого кода и разработка через тестирование в PHP - 1

Понятие «архитектура чистого кода» (Clean Code Architecture) ввел Роберт Мартин в блоге 8light. Смысл понятия в том, чтобы создавать архитектуру, которая не зависела бы от внешнего воздействия. Ваша бизнес-логика не должна быть объединена с фреймворком, базой данных или самим вебом. Подобная независимость даёт ряд преимуществ. К примеру, при разработке вы сможете откладывать какие-то технические решения, например выбор фреймворка, движка/поставщика БД. Также вы сможете легко переключаться между разными реализациями и сравнивать их. Но самое важное преимущество такого подхода — ваши тесты будут выполняться быстрее.

Просто подумайте об этом. Вы действительно хотите пройти роутинг, подгрузить абстрактный уровень базы данных или какое-нибудь ORM-колдовство? Или просто выполнить какой-то код, чтобы проверить (assert) те или иные результаты?

Я начал изучать такую архитектуру и практиковаться в ее создании из-за моего любимого фреймворка Kohana. Его основной разработчик однажды перестал поддерживать код, поэтому мои проекты не обновлялись и не получали патчи системы безопасности. А это означало, что мне понадобилось либо довериться версии, которая разрабатывается сообществом, либо переходить на новый фреймворк и переписывать проекты целиком.

Да, я мог бы выбрать другой фреймворк. Возможно, Symfony 1 или Zend 1. Но что бы я ни выбрал, с тех пор изменился бы и этот фреймворк. Они постоянно меняются и развиваются. Composer облегчает не только установку и замену пакетов, но и их исключение (в нём есть даже возможность помечать пакеты как исключённые), так что ошибиться довольно просто.

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

Архитектура чистого кода и разработка через тестирование в PHP - 2

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

Самое интересное — в правом нижнем углу: поток управления. Схема объясняет, как фреймворк взаимодействует с бизнес-логикой. Контроллер передаёт данные на порт ввода, информацию с которого обрабатывает интерактор, а результат передаётся на порт вывода, содержащий данные для презентера.

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

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

Первый тест

Обычно мы начинаем работу с пользовательского интерфейса. Что человек ожидает увидеть в гостевой книге? Наверное, форму ввода, записи других посетителей, возможно, навигационную панель с поиском по страницам записей. Если книга пуста, может отображаться сообщение «Записей нет».

В первом тесте нам нужно проверить (assert) пустой список записей:

<?php
require_once __DIR__ . '/../../vendor/autoload.php';
class ListEntriesTest extends PHPUnit_Framework_TestCase
{
    public function testEntriesNotExists()
    {
        $request = new FakeViewEntriesRequest();
        $response = new FakeViewEntriesResponse();
        $useCase = new ViewEntriesUseCase();
        $useCase->process($request, $response);
        $this->assertEmpty($response->entries);
    }
}

Здесь я использовал немного другую нотацию по сравнению с нотацией Дяди Боба. Интеракторы — это useCase, порты ввода — request, порты вывода — response. Все useCase содержат метод, в котором есть type hint для конкретного интерфейса request и response.

Если следовать принципам разработки через тестирование (test-driven development, TDD) — красный цикл, зелёный цикл, цикл рефакторинга, — тест не будет пройден, поскольку классы не существуют. Для прохождения теста достаточно создать файлы классов, методы и свойства. Поскольку классы пусты, нам пока рано приступать к циклу рефакторинга.

Теперь нужно проверить отображение записей:

<?php
require_once __DIR__ . '/../../vendor/autoload.php';
use BlackScorpGuestBookFakeRequestFakeViewEntriesRequest;
use BlackScorpGuestBookFakeResponseFakeViewEntriesResponse;
use BlackScorpGuestBookUseCaseViewEntriesUseCase;
class ListEntriesTest extends PHPUnit_Framework_TestCase
{
    public function testEntriesNotExists()
    {
        $request = new FakeViewEntriesRequest();
        $response = new FakeViewEntriesResponse();
        $useCase = new ViewEntriesUseCase();
        $useCase->process($request, $response);
        $this->assertEmpty($response->entries);
    }
    public function testCanSeeEntries()
    {
        $request = new FakeViewEntriesRequest();
        $response = new FakeViewEntriesResponse();
        $useCase = new ViewEntriesUseCase();
        $useCase->process($request, $response);
        $this->assertNotEmpty($response->entries);
    }
}

Тест не пройден, мы находимся в красной части цикла TDD. Для прохождения нужно добавить логику в наши useCase.

Наброски логики для useCase

Но сначала воспользуемся type hint’ами в качестве параметров и создадим интерфейсы:

<?php
namespace BlackScorpGuestBookUseCase;

use BlackScorpGuestBookRequestViewEntriesRequest;
use BlackScorpGuestBookResponseViewEntriesResponse;

class ViewEntriesUseCase
{
    public function process(ViewEntriesRequest $request, ViewEntriesResponse $response){

    }
}

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

Мы же вместо форм и линий используем, например, репозитории и фабрики. Репозиторий — это абстрактный уровень для получения данных из хранилища. Хранилищем может быть база данных, файл, внешний API и даже память.

Для просмотра записей в гостевой книге нам нужно найти эти записи в репозитории, конвертировать в виды (view) и вернуть.

<?php
namespace BlackScorpGuestBookUseCase;

use BlackScorpGuestBookRequestViewEntriesRequest;
use BlackScorpGuestBookResponseViewEntriesResponse;

class ViewEntriesUseCase
{
    public function process(ViewEntriesRequest $request, ViewEntriesResponse $response){
	$entries = $this->entryRepository->findAllPaginated($request->getOffset(), $request->getLimit());

        if(!$entries){
            return;
        }

        foreach($entries as $entry){
            $entryView = $this->entryViewFactory->create($entry);
            $response->addEntry($entryView);
        }
    }
}

Наверное, вы спросите, для чего понадобилось конвертировать сущность Entry в вид? Дело в том, что сущность не должна покидать пределы уровня useCase. Мы можем найти её только с помощью репозитория, при необходимости изменить/скопировать и положить обратно в репозиторий. Когда мы начнём перемещать сущность во внешний слой, то лучше добавить дополнительные методы для улучшения взаимодействия. Однако в сущности должна присутствовать только основная бизнес-логика.

Поскольку мы пока не знаем, какой формат нужно придать сущности, пропустим этот шаг.

Теперь отвечу на ваш возможный вопрос о фабриках. Создав новый экземпляр в цикле:

$entryView = new EntryView($entry);
$response->addEntry($entryView);

мы нарушим принцип инверсии зависимостей. И если потом в той же логике useCase нам понадобится ещё один объект вида (view object), то придётся переписывать код. А с помощью фабрики можно легко внедрять разные виды с разной логикой форматирования, при этом будет использоваться один и тот же useCase.

Реализация внешних зависимостей

Нам уже известны зависимости useCase: $entryViewFactory и $entryRepository. Также известны и методы этих зависимостей. EntryViewFactory создаёт метод, который получает EntryEntity, а у EntryRepository есть метод findAll(), возвращающий массив EntryEntities. Теперь можно создать интерфейсы для методов и применить их к useCase.

EntryRepository выглядит так:

<?php
namespace BlackScorpGuestBookRepository;

interface EntryRepository {
    public function findAllPaginated($offset,$limit);
}

Тогда useCase:

<?php
namespace BlackScorpGuestBookUseCase;

use BlackScorpGuestBookRepositoryEntryRepository;
use BlackScorpGuestBookRequestViewEntriesRequest;
use BlackScorpGuestBookResponseViewEntriesResponse;
use BlackScorpGuestBookViewFactoryEntryViewFactory;

class ViewEntriesUseCase
{
    /**
     * @var EntryRepository
     */
    private $entryRepository;
    /**
     * @var EntryViewFactory
     */
    private $entryViewFactory;

    /**
     * ViewEntriesUseCase constructor.
     * @param EntryRepository $entryRepository
     * @param EntryViewFactory $entryViewFactory
     */
    public function __construct(EntryRepository $entryRepository, EntryViewFactory $entryViewFactory)
    {
        $this->entryRepository = $entryRepository;
        $this->entryViewFactory = $entryViewFactory;
    }


    public function process(ViewEntriesRequest $request, ViewEntriesResponse $response)
    {
        $entries = $this->entryRepository->findAllPaginated($request->getOffset(), $request->getLimit());
        if (!$entries) {
            return;
        }

        foreach ($entries as $entry) {
            $entryView = $this->entryViewFactory->create($entry);
            $response->addEntry($entryView);
        }
    }
}

Как видите, тесты всё ещё не проходятся, так как нет реализации зависимости. Создадим несколько фальшивых объектов:

<?php
require_once __DIR__ . '/../../vendor/autoload.php';
use BlackScorpGuestBookFakeRequestFakeViewEntriesRequest;
use BlackScorpGuestBookFakeResponseFakeViewEntriesResponse;
use BlackScorpGuestBookUseCaseViewEntriesUseCase;
use BlackScorpGuestBookEntityEntryEntity;
use BlackScorpGuestBookFakeRepositoryFakeEntryRepository;
use BlackScorpGuestBookFakeViewFactoryFakeEntryViewFactory;
class ListEntriesTest extends PHPUnit_Framework_TestCase
{
    public function testEntriesNotExists()
    {
        $entryRepository = new FakeEntryRepository();
        $entryViewFactory = new FakeEntryViewFactory();
        $request = new FakeViewEntriesRequest();
        $response = new FakeViewEntriesResponse();
        $useCase = new ViewEntriesUseCase($entryRepository, $entryViewFactory);
        $useCase->process($request, $response);
        $this->assertEmpty($response->entries);
    }
    public function testCanSeeEntries()
    {
	$entities = [];
        $entities[] = new EntryEntity();
        $entryRepository = new FakeEntryRepository($entities);
        $entryViewFactory = new FakeEntryViewFactory();
        $request = new FakeViewEntriesRequest();
        $response = new FakeViewEntriesResponse();
        $useCase = new ViewEntriesUseCase($entryRepository, $entryViewFactory);
        $useCase->process($request, $response);
        $this->assertNotEmpty($response->entries);
    }
}

Поскольку мы уже создали интерфейсы для репозитория и фабрики видов, значит, можем внедрить их в фальшивые классы, а заодно реализовать интерфейсы для request/response.

Теперь репозиторий выглядит так:

<?php
namespace BlackScorpGuestBookFakeRepository;

use BlackScorpGuestBookRepositoryEntryRepository;

class FakeEntryRepository implements EntryRepository
{
    private $entries = [];

    public function __construct(array $entries = [])
    {
        $this->entries = $entries;
    }

    public function findAllPaginated($offset, $limit)
    {
        return array_splice($this->entries, $offset, $limit);
    }
}

А фабрика видов — так:

<?php
namespace BlackScorpGuestBookFakeViewFactory;

use BlackScorpGuestBookEntityEntryEntity;
use BlackScorpGuestBookFakeViewFakeEntryView;
use BlackScorpGuestBookViewEntryView;
use BlackScorpGuestBookViewFactoryEntryViewFactory;

class FakeEntryViewFactory implements EntryViewFactory
{
    /**
     * @param EntryEntity $entity
     * @return EntryView
     */
    public function create(EntryEntity $entity)
    {

        $view = new FakeEntryView();
        $view->author = $entity->getAuthor();
        $view->text = $entity->getText();
        return $view;
    }
}

Вы спросите, почему бы просто не использовать mocking-фреймворки для создания зависимостей? Тому есть две причины:

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

Теперь тесты пройдены, можем заняться рефакторингом. По сути, в классе useCase рефакторить нечего, разве только в тестовом классе.

Рефакторинг теста

Исполняться будет так же, просто с другой настройкой (setup) и проверкой. Можем перенести инициализацию фальшивых классов и обработку useCase в частную функцию processUseCase.

Тестовый класс выглядит так:

<?php
require_once __DIR__ . '/../../vendor/autoload.php';

use BlackScorpGuestBookEntityEntryEntity;
use BlackScorpGuestBookFakeRepositoryFakeEntryRepository;
use BlackScorpGuestBookFakeViewFactoryFakeEntryViewFactory;
use BlackScorpGuestBookFakeRequestFakeViewEntriesRequest;
use BlackScorpGuestBookFakeResponseFakeViewEntriesResponse;
use BlackScorpGuestBookUseCaseViewEntriesUseCase;

class ListEntriesTest extends PHPUnit_Framework_TestCase
{

    public function testCanSeeEntries()
    {
 	$entries = [
            new EntryEntity('testAuthor','test text')
        ];
        $response = $this->processUseCase($entries);
        $this->assertNotEmpty($response->entries);
    }

    public function testEntriesNotExists()
    {
        $entities = [];
        $response = $this->processUseCase($entities);
        $this->assertEmpty($response->entries);
    }

    /**
     * @param $entities
     * @return FakeViewEntriesResponse
     */
    private function processUseCase($entities)
    {
        $entryRepository = new FakeEntryRepository($entities);
        $entryViewFactory = new FakeEntryViewFactory();
        $request = new FakeViewEntriesRequest();
        $response = new FakeViewEntriesResponse();
        $useCase = new ViewEntriesUseCase($entryRepository, $entryViewFactory);
        $useCase->process($request, $response);
        return $response;
    }
}

Независимость

Теперь мы можем, например, легко создать новые тесты с неверными сущностями, переместить репозиторий и фабрику в метод setup и прогнать тесты с настоящими реализациями.

Также мы можем внедрить в DI-контейнер готовый к использованию useCase и использовать его внутри фреймворка. При этом логика не будет зависеть от фреймворка.

Кроме того, ничто не мешает создать другую реализацию репозитория, которая будет общаться с внешним API, например, и передавать его в useCase. Логика будет независима от базы данных.

При желании можно создать CLI-объекты request/response и передавать их тому же useCase, используемому внутри контроллера. В этом случае логика не будет зависеть от платформы.

Даже можно исполнять по очереди разные useCase, каждый из которых изменяет объект response.

class MainController extends BaseController
{
    public function indexAction(Request $httpRequest)
    {
        $indexActionRequest = new IndexActionRequest($httpRequest);
        $indexActionResponse = new IndexActionResponse();
        $this->getContainer('ViewNavigation')->process($indexActionRequest, $indexActionResponse);
        $this->getContainer('ViewNewsEntries')->process($indexActionRequest, $indexActionResponse);
        $this->getContainer('ViewUserAvatar')->process($indexActionRequest, $indexActionResponse);
        $this->render($indexActionResponse);

    }
}

Разбивка на страницы

Добавим в нашу гостевую книгу разбивку на страницы. Тест может выглядеть так:

   public function testCanSeeFiveEntries(){
        $entities = [];
        for($i = 0;$i<10;$i++){
             $entities[] = new EntryEntity('Author '.$i,'Text '.$i);
        }

        $response = $this->processUseCase($entities);
        $this->assertNotEmpty($response->entries);
        $this->assertSame(5,count($response->entries));
    }

Он не будет пройден, так что нужно модифицировать метод process в useCase, а заодно переименовать метод findAll в findAllPaginated.

public function process(ViewEntriesRequest $request, ViewEntriesResponse $response){
        $entries = $this->entryRepository->findAllPaginated($request->getOffset(), $request->getLimit());
//....
    }

Теперь можно применить в интерфейсе и фальшивом репозитории новые параметры, а также добавить новые методы в интерфейс request.

В репозитории немного изменится метод findAllPaginated:

    public function findAllPaginated($offset, $limit)
    {
        return array_splice($this->entries, $offset, $limit);
    }

Нужно перенести в тесты объект request. Также для конструктора объекта request понадобится параметр ограничения (limit parameter). Таким образом, мы заставим setup создать ограничение вместе с новым экземпляром.

  public function testCanSeeFiveEntries(){
        $entities = [];
        for($i = 0;$i<10;$i++){
            $entities[] = new EntryEntity();
        }
        $request = new FakeViewEntriesRequest(5);
        $response = $this->processUseCase($request, $entities);
        $this->assertNotEmpty($response->entries);
        $this->assertSame(5,count($response->entries));
    }

Тест пройден. Но нужно ещё протестировать возможность просмотра следующих пяти записей. Для этого придётся добавить в объект request метод setPage.

<?php
namespace BlackScorpGuestBookFakeRequest;
use BlackScorpGuestBookRequestViewEntriesRequest;
class FakeViewEntriesRequest implements ViewEntriesRequest{
    private $offset = 0;
    private $limit = 0;

    /**
     * FakeViewEntriesRequest constructor.
     * @param int $limit
     */
    public function __construct($limit)
    {
        $this->limit = $limit;
    }

    public function setPage($page = 1){
        $this->offset = ($page-1) * $this->limit;
    }
    public function getOffset()
    {
        return $this->offset;
    }

    public function getLimit()
    {
        return $this->limit;
    }

}

С помощью этого метода мы можем протестировать отображение следующих пяти записей:

   public function testCanSeeFiveEntriesOnSecondPage(){
        $entities = [];
        $expectedEntries = [];
        $entryViewFactory = new FakeEntryViewFactory();
        for($i = 0;$i<10;$i++){
            $entryEntity = new EntryEntity();
            if($i >= 5){
                $expectedEntries[]=$entryViewFactory->create($entryEntity);
            }
            $entities[] =$entryEntity;
        }
        $request = new FakeViewEntriesRequest(5);
        $request->setPage(2);
        $response = $this->processUseCase($request,$entities);
        $this->assertNotEmpty($response->entries);
        $this->assertSame(5,count($response->entries));
        $this->assertEquals($expectedEntries,$response->entries);
    }

Пройдено, можем рефакторить. Перенесём FakeEntryViewFactory в метод setup, и готово. Последний тестовый класс выглядит так:

<?php
require_once __DIR__ . '/../../vendor/autoload.php';

use BlackScorpGuestBookEntityEntryEntity;
use BlackScorpGuestBookFakeRepositoryFakeEntryRepository;
use BlackScorpGuestBookFakeRequestFakeViewEntriesRequest;
use BlackScorpGuestBookFakeResponseFakeViewEntriesResponse;
use BlackScorpGuestBookFakeViewFactoryFakeEntryViewFactory;
use BlackScorpGuestBookUseCaseViewEntriesUseCase;

class ListEntriesTest extends PHPUnit_Framework_TestCase
{
    public function testEntriesNotExists()
    {
        $entries = [];
        $request = new FakeViewEntriesRequest(5);
        $response = $this->processUseCase($request, $entries);
        $this->assertEmpty($response->entries);
    }

    public function testCanSeeEntries()
    {
        $entries = [
            new EntryEntity('testAuthor', 'test text')
        ];
        $request = new FakeViewEntriesRequest(5);
        $response = $this->processUseCase($request, $entries);
        $this->assertNotEmpty($response->entries);
    }

    public function testCanSeeFiveEntries()
    {
        $entities = [];
        for ($i = 0; $i < 10; $i++) {
            $entities[] = new EntryEntity('Author ' . $i, 'Text ' . $i);
        }
        $request = new FakeViewEntriesRequest(5);
        $response = $this->processUseCase($request, $entities);
        $this->assertNotEmpty($response->entries);
        $this->assertSame(5, count($response->entries));
    }

    public function testCanSeeFiveEntriesOnSecondPage()
    {
        $entities = [];
        $expectedEntries = [];
        $entryViewFactory = new FakeEntryViewFactory();
        for ($i = 0; $i < 10; $i++) {
            $entryEntity = new EntryEntity('Author ' . $i, 'Text ' . $i);
            if ($i >= 5) {
                $expectedEntries[] = $entryViewFactory->create($entryEntity);
            }
            $entities[] = $entryEntity;
        }
        $request = new FakeViewEntriesRequest(5);
        $request->setPage(2);
        $response = $this->processUseCase($request, $entities);
        $this->assertNotEmpty($response->entries);
        $this->assertSame(5, count($response->entries));
        $this->assertEquals($expectedEntries, $response->entries);
    }

    /**
     * @param $request
     * @param $entries
     * @return FakeViewEntriesResponse
     */
    private function processUseCase($request, $entries)
    {
        $repository = new FakeEntryRepository($entries);
        $factory = new FakeEntryViewFactory();
        $response = new FakeViewEntriesResponse();
        $useCase = new ViewEntriesUseCase($repository, $factory);
        $useCase->process($request, $response);
        return $response;
    }
}

Завершение

Мы рассмотрели, как тесты привели нас к useCase, тот привёл к интерфейсам, а они привели к фальшивым реализациям. Повторюсь, что исходный код для этой публикации можно скачать с Github. Обратите внимание на тэги, обозначающие разные стадии.

Этот туториал демонстрирует, как для любого нового проекта можно легко применять разработку через тестирование и архитектуру чистого кода. Главное преимущество такого подхода — полная независимость логики. Такой подход также позволяет использовать сторонние библиотеки.

Автор: Mail.Ru Group

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js