Продолжаем погружение в проектирование и разработку. В прошлой статье про проектирование доменных сущностей мы сочинили полноценную сущность-агрегат предметной области
Employee со своей собственной бизнес-логикой для описания объектов сотрудников. Теперь нужно как-то работать с ней из контроллера, сохранять в базу данных и доставать обратно. Но наш Employee не содержит ни одной строки по работе с базой данных, поэтому сам сохраняться не умеет. Что же с этим делать?
Обычно в данном случае создают внешний объект хранилища (Repository), который будет управлять сохранением сущностей примерно такими методами:
$employee = $employeeRepository->get($id); $employeeRepository->add($employee); $employeeRepository->save($employee); $employeeRepository->remove($employee);
И внутри себя он уже будет содержать SQL-код, сохраняющий весь агрегат с его статусами, телефонами и прочими внутренностями.
И именно этот
EmployeeRepository сможем использовать в нужных нам местах. Например, в сервисах приложения. Что это за сервисы и что они делают?Сервис приложения
Весь код записывать в контроллере неудобно. Причины этого озвучим ниже. Вместо этого есть популярная в узких кругах практика код с логикой из контроллера извлекать в отдельные классы, называемые сервисами уровня приложения (или прикладные сервисы) и уже из контроллеров обращаться к ним. Особо не будем вдаваться в терминологию, а просто посмотрим, что это такое.
В нашем случае для взаимодействия с контроллерами браузера или API нам понадобится некий прикладной сервис
EmployeeService, который используя хранилище EmployeeRepository и диспетчер событий EventDispatcher осуществлял бы все операции с сущностью сотрудника:namespace app\services; use app\services\dto\AddressDto; use app\services\dto\EmployeeArchiveDto; use app\services\dto\EmployeeCreateDto; use app\services\dto\EmployeeReinstateDto; use app\services\dto\NameDto; use app\services\dto\PhoneDto; use app\dispatchers\EventDispatcher; use app\entities\Employee\Address; use app\entities\Employee\Employee; use app\entities\Employee\EmployeeId; use app\entities\Employee\Name; use app\entities\Employee\Phone; use app\repositories\EmployeeRepository; class EmployeeService { private $employees; private $dispatcher; public function __construct(EmployeeRepository $employees, EventDispatcher $dispatcher) { $this->employees = $employees; $this->dispatcher = $dispatcher; } public function create(EmployeeCreateDto $dto): void { $employee = new Employee( $this->employees->nextId(), new Name( $dto->name->last, $dto->name->first, $dto->name->middle ), new Address( $dto->address->country, $dto->address->region, $dto->address->city, $dto->address->street, $dto->address->house ), array_map(function (PhoneDto $phone) { return new Phone( $phone->country, $phone->code, $phone->number ); }, $dto->phones) ); $this->employees->add($employee); $this->dispatcher->dispatch($employee->releaseEvents()); } public function rename(EmployeeId $id, NameDto $dto): void { $employee = $this->employees->get($id); $employee->rename(new Name( $dto->last, $dto->first, $dto->middle )); $this->employees->save($employee); $this->dispatcher->dispatch($employee->releaseEvents()); } public function changeAddress(EmployeeId $id, AddressDto $dto): void { $employee = $this->employees->get($id); $employee->changeAddress(new Address( $dto->country, $dto->region, $dto->city, $dto->street, $dto->house )); $this->employees->save($employee); $this->dispatcher->dispatch($employee->releaseEvents()); } public function addPhone(EmployeeId $id, PhoneDto $dto): void { $employee = $this->employees->get($id); $employee->addPhone(new Phone( $dto->country, $dto->code, $dto->number )); $this->employees->save($employee); $this->dispatcher->dispatch($employee->releaseEvents()); } public function removePhone(EmployeeId $id, $index): void { $employee = $this->employees->get($id); $employee->removePhone($index); $this->employees->save($employee); $this->dispatcher->dispatch($employee->releaseEvents()); } public function archive(EmployeeId $id, EmployeeArchiveDto $dto): void { $employee = $this->employees->get($id); $employee->archive($dto->date); $this->employees->save($employee); $this->dispatcher->dispatch($employee->releaseEvents()); } public function reinstate(EmployeeId $id, EmployeeReinstateDto $dto): void { $employee = $this->employees->get($id); $employee->reinstate($dto->date); $this->employees->save($employee); $this->dispatcher->dispatch($employee->releaseEvents()); } public function remove(EmployeeId $id): void { $employee = $this->employees->get($id); $employee->remove(); $this->employees->remove($employee); $this->dispatcher->dispatch($employee->releaseEvents()); } }
Мы можем передавать большое число параметров каждому методу:
public function changeAddress(EmployeeId $id, $country, $region, $city, $street, $house): void { $employee = $this->employees->get($id); $employee->changeAddress(new Address( $country, $region, $city, $street, $house )); $this->employees->save($employee); $this->dispatcher->dispatch($employee->releaseEvents()); }
Но, чтобы не путаться в их порядке и количестве, эти наборы мы собрали в отдельные типизированные структуры передачи данных (Data Transfer Object) вроде
AddressDto:class AddressDto { public $country; public $region; public $city; public $street; public $house; }
и передаём уже их:
public function changeAddress(EmployeeId $id, AddressDto $dto): void { $employee = $this->employees->get($id); $employee->changeAddress(new Address( $dto->country, $dto->region, $dto->city, $dto->street, $dto->house )); $this->employees->save($employee); $this->dispatcher->dispatch($employee->releaseEvents()); }
При желании мы можем и сам
$id поместить внутрь DTO в поле $dto->id. Это на любителя...
Здесь сервис не особо большой и его действия совпадают с методами сущности. Но могут быть и сервисы, оперирующие несколькими агрегатами. Например, метод отправки этого сотрудника в командировку может не только модифицировать сущность сотрудника, но и одновременно создавать сущность приказа и заполнять сущность командировочного листа.
Можете посмотреть код нашего сервиса и его DTO на GitHub.
Если ваш проект содержит какой-либо построитель форм поверх объектов (либо если используете компонент Symfony/Forms или подобный), то можете использовать его для генерации форм ввода на основе этих структур. Если же ваш фреймворк не такой умный и не умеет этого делать, то можем в качестве DTO использовать саму модель формы
AddressForm вместо AddressDto:class AddressForm extends \yii\base\Model { public $country; public $region; public $city; public $street; public $house; public function rules(): array { ... } }
public function changeAddress(EmployeeId $id, AddressForm $form) { ... $employee->changeAddress(new Address( $form->country, ... )); ... }
Но этот подход привяжет код проекта к конкретному фреймворку. Это критично, если вы хотите разработать модуль, который бы в совместно с плагинами-адаптерами интегрировался в разные фреймворки и CMS. Но такие модули пишут весьма редко.
Если всё же хотите осуществить полную фреймворконезависимость, то можете оставить
AddressDto и производить ручную конвертацию данных из формы в этот объект:class AddressForm extends Model { public $country; public $region; public $city; public $street; public $house; public function rules(): array { ... } public function getDto(): AddressDto { $dto = new AddressDto(); $dto->country = $this->country; ... return $dto; } }
Здесь набор и формат полей повторяет поля DTO и создание отдельных классов форм кажется избыточным. Но у некоторых форм могут быть отличия.
Например, если хотите заполнять дату в форме в виде трёх полей, а в DTO записать уже скомбинированный объект класса
DateTimeImmutable, то это будет очень кстати:class ReinstateForm extends Model { public $year; public $mounth; public $day; public function rules(): array { ... } public function getDto(): ReinstateDto { $dto = new ReinstateDto(); $dto->date = DateTimeImmutable::createFromFormat('Y-m-d', $this->year . '-' . $this->mounth . '-' . $this->day); return $dto; } }
Так мы становимся полностью отвязанными от специфики реализации форм фреймворка.
Диспетчер событий
Далее нашему сервису понадобится некий диспетчер событий:
namespace app\dispatchers; interface EventDispatcher { public function dispatch(array $events): void; }
Это штука, в которую мы будем передавать все сгенерированные в нашем ядре события:
$this->dispatcher->dispatch($employee->releaseEvents());
а он уже внутри себя будет что-то с ними делать. В простейшем случае можно сделать простую логирующую заглушку:
namespace app\dispatchers; class DummyEventDispatcher implements EventDispatcher { public function dispatch(array $events) { foreach ($events as $event) { \Yii::info('Dispatch event ' . \get_class($event)); } } }
и впоследствии подменить её на полноценный компонент, через который можно будет на конкретные события навешивать обработчики. А обработчики уже будут отправлять письма, чистить кеши, обновлять поисковые индексы... То есть делать всю техническиую рутину. А потом можем заменить его на
QueueEventDispatcher, который складывал бы их в очередь и исполнял где-то в фоне.
Заглушку можно пока зарегистрировать в DI-контейнере вашего фреймворка. В Yii, например, это можно сделать прямо добавив секцию
container в конфигурационные файлы web.php, console.php и test.php (если работать с yii2-app-basic) или в common/config/main.php (в yii2-app-advanced):$config = [ 'id' => 'basic', 'basePath' => dirname(__DIR__), 'bootstrap' => ['log'], 'container' => [ 'singletons' => [ 'app\dispatchers\EventDispatcher' => ['app\dispatchers\DummyEventDispatcher'], ], ] 'components' => [ ... ], ],
Но дабы это не копипастить, можно сделать отдельный загрузочный класс с настройками контейнера:
namespace app\bootstrap; use app\dispatchers\EventDispatcher; use app\dispatchers\DummyEventDispatcher; use yii\base\BootstrapInterface; class Bootstrap implements BootstrapInterface { public function bootstrap($app) { $container = \Yii::$container; $container->setSingleton(EventDispatcher::class, DummyEventDispatcher::class); } }
и в вышеуказанных конфигурационных файлах указать его в секции
bootstrap:$config = [ 'id' => 'basic', 'basePath' => dirname(__DIR__), 'bootstrap' => [ 'log', 'app\bootstrap\Bootstrap', ], 'components' => [ ... ], ],
Так этот класс загрузится в момент запуска приложения и сконфигурирует контейнер внедрения зависимостей на внедрение именно объекта заглушки всем сервисам, которые будут требовать интерфейс диспетчера. И с отдельным классом мы не сильно засоряем конфигурационные файлы обилием конструкций
use.Контроллеры
И сейчас, когда разобрались с формами, можно соорудить «браузерный» контроллер:
namespace app\controllers; use app\forms\EmployeeCreateForm; use app\services\EmployeeService; use yii\web\Controller; use Yii class EmployeeController extends Controller { private $employeeService; public function __construct($id, $module, EmployeeService $employeeService, $config = []) { $this->employeeService = $employeeService; parent::__construct($id, $module, $config); } public function actionCreate() { $form = new EmployeeCreateForm(); if ($form->load(\Yii::$app->request->post()) && $form->validate()) { try { $this->employeeService->create($form->getDto()); Yii::$app->session->setFlash('success', 'Employee is created.'); return $this->redirect(['index']); } catch (\DomainException $e) { Yii::$app->errorHandler->logException($e); Yii::$app->session->setFlash('error', Yii::t('errors', $e->getMessage())); } } return $this->render('create', [ 'form' => $form, ]); } }
На этом шаге часто возникает вопрос вроде «зачем создавать отдельный класс
EmployeeService, если его код можно поместить прямо в контроллере«? На него есть несколько ответов.
Во-первых, помимо обычного веб-интерфейса у сайта может быть некое API. И оно будет содержать похожий контроллер:
namespace app\controllers\api; use app\forms\EmployeeCreateForm; use app\services\EmployeeService; use yii\rest\Controller; class EmployeeController extends Controller { private $employeeService; public function __construct($id, $module, EmployeeService $employeeService, $config = []) { $this->employeeService = $employeeService; parent::__construct($id, $module, $config); } public function actionCreate() { $form = new EmployeeCreateForm(); $form->load(\Yii::$app->request->getBodyParams(), ''); if (!$form->validate()) { return $form; } try { $this->employeeService->create($form->getDto()); Yii::$app->response->setStatusCode(201); } catch (\DomainException $e) { throw new BadRequestHttpException($e->getMessage(), 0, $e); } } }
И помимо этих двух у нас может появиться консольный контроллер для управления сотрудниками из командной строки (или Ratchet-демон). Если бы у нас не было класса
EmployeeService, то код его методов вроде create пришлось бы копировать во все три-четыре контроллера.
Во-вторых, весьма сложно протестировать модульными тестами фрагмент кода внутри контроллера. Пришлось бы имитировать
Yii::$app->request и парсить результирующий отрендеренный HTML-ответ. Его бы целиком пришлось проверять только внешними функциональными тестами. А в нашем подходе (когда весь основной код вынесен в отдельные классы) при необходимости можно легко протестировать сам класс EmployeeService также, как мы тестировали Employee.
В-третьих, даже наш класс
EmployeeService в варианте с DTO уже нигде не использует классы фреймворка, поэтому его можно спокойно перенести без изменений хоть на WordPress.Выносом кода в отдельный класс мы избавились от копирования и облегчили тестирование. Контроллеры у нас оказались практически пустыми. Они только заполняют модели форм данными запроса и вызывают методы сервиса. Вся логика из контроллеров перекочевала в доменную модель (где термин «доменная модель» объединяет вместе все сервисы и сущности). Именно это и имеет в виду принцип «тонкий контроллер и толстая модель» в частности и паттерн «Контроллер» из GRASP в общем, а не «пихайте всё в ActiveRecord».
А то у меня постоянно происходит диалог адептами Yii:
— Куда впихнуть эту груду кода? В ActiveRecord или в контроллер?
— А в чём проблема?
— Говорят, что и толстый контроллер – это плохо, и жирная модель тоже плохо.
— Ни туда, ни туда. Напиши отдельный класс.
— В каком смысле? А от чего его наследовать? От Component? От Model?
— Ни от чего не наследуй. Просто класс.
— А что... так можно?
— Да, во фреймворках можно делать просто классы :)
— Ух-ты... А мужики-то не знают... © Какой-то «Толстяк».
— А в чём проблема?
— Говорят, что и толстый контроллер – это плохо, и жирная модель тоже плохо.
— Ни туда, ни туда. Напиши отдельный класс.
— В каком смысле? А от чего его наследовать? От Component? От Model?
— Ни от чего не наследуй. Просто класс.
— А что... так можно?
— Да, во фреймворках можно делать просто классы :)
— Ух-ты... А мужики-то не знают... © Какой-то «Толстяк».
С контроллерами примерно разобрались. Подробнее их изучим в следующих статьях. А пока вернёмся к хранилищу.
Хранилище
Судя по исходному коду, нашему
EmployeeService нужен некий объект хранилища EmployeeRepositoryс таким интерфейсом:namespace app\repositories; use app\entities\Employee\Employee; use app\entities\Employee\EmployeeId; interface EmployeeRepository { /** * @param EmployeeId $id * @return Employee * @throws NotFoundException */ public function get(EmployeeId $id): Employee; public function add(Employee $employee): void; public function save(Employee $employee): void; public function remove(Employee $employee): void; public function nextId(): EmployeeId; }
В этих методах мы уже можем производить необходимые SQL-запросы и заполнять объекты.
Стоит ещё заметить, что мы придумали некий метод
nextId, который бы возвращал следующий идентификатор для нашей сущности. Мы его используем именно при создании сотрудника:class EmployeeService { private $employees; private $dispatcher; public function __construct(EmployeeRepository $employees, EventDispatcher $dispatcher) { $this->employees = $employees; $this->dispatcher = $dispatcher; } public function create(EmployeeCreateDto $dto): void { $employee = new Employee( $this->employees->nextId(), new Name( $dto->name->last, $dto->name->first, $dto->name->middle ), ... ); $this->employees->add($employee); $this->dispatcher->dispatch($employee->releaseEvents()); } }
А как же с этим быть, если первичные ключи в базе автоинкрементные и мы текущий идентификатор заранее на знаем?
Работать с заранее известным идентификатором вместо автоинкремента удобнее, так как его можно присваивать, передавать в объекты событий вродеnew EmployeeCreated($this->id)в конструкторе классаEmployee, использовать в путях загрузки файлов или куда-то записывать уже ДО сохранения объекта в базу данных. Если же значение заранее не известно, то так сделать мы не сможем. Да и не все хранилища поддерживают автоинктементную генерацию первичных ключей.Поэтому вместо обычных автоинкрементов чаще используют генерируемый вручную UUID или заводят в базе данных отдельную таблицу (или секвенцию в PostgreSQL), из которой потом отдельным запростом вnextId()извлекают свежие идентификаторы.
Остальные методы у нас стандартные:
get, add, save и remove. Но в некоторых сервисах нужно осуществлять дополнительные проверки. Например, в методе регистрации пользователя и смены его данных нужно проверять бизнес-требование уникальности имени и адреса электронной почты. Такую проверку в сущность User не поместишь, поэтому её добавим в сам сервис:class UserService { ... public function requestSignup($username, $email, $password): void { $this->guardUsernameIsUnique($username); $this->guardEmailIsUnique($email); $user = User::requestSignup( $username, $email, $this->passwordHasher->hash($password), $this->authTokenizer->generate(), ); $this->userRepository->add($user); } public function changeEmail($userId, $email): void { $user = $this->userRepository->get($userId); $this->guardEmailIsUnique($email, $user->getId()); $user->changeEmail($email); $this->userRepository->save($user); } public function confirmSignup($token): void { $user = $this->userRepository->getByEmailConfirmToken($token); $user->confirmSignup(); $this->userRepository->save($user); } ... private function guardUsernameIsUnique($username, $exceptId = null): void { if ($this->userRepository->existsByUsername($username, $exceptId)) { throw new \DomainException('Username already exists'); } } private function guardEmailIsUnique($email, $exceptId = null): void { if ($this->userRepository->existsByEmail($email, $exceptId)) { throw new \DomainException('Email already exists'); } } }
Помимо использования вспомогательных доменных сервисов
PasswordHasher и AuthTokenizer этот прикладной сервис UserService вызывает у репозитория методы existsByUsername и existsByEmail, чтобы проверить, нет ли там уже других пользователей (исключая текущего) с такими же данными. И может потребовать ещё и реализацию методов getByEmail, getByEmailConfirmToken и подобных. Поэтому классы репозиториев могут быть более обширными.
Пока остановимся на нужном нам базовом наборе методов:
namespace app\repositories; ... interface EmployeeRepository { /** * @param EmployeeId $id * @return Employee * @throws NotFoundException */ public function get(EmployeeId $id): Employee; public function add(Employee $employee): void; public function save(Employee $employee): void; public function remove(Employee $employee): void; public function nextId(): EmployeeId; }
а любые другие можно добавить при необходимости.
Метод
get нам должен либо возвращать запрошенный из базы данных объект, либо кидать исключение, если не нашёл. Создадим сразу класс этого исключения:namespace app\repositories; class NotFoundException extends \LogicException { }
и так и оставим его пустым.
Сейчас сделаем примитивный
MemoryEmployeeRepository, который будет сохранять записи в приватный массив $items. В следующих частях рассмотрим несколько реализаций репозиториев, поэтому для чистоты эксперимента подготовим для них максимально подробные тесты.Репозитории будут разными. Кто-то будет хранить всё в MySQL скалярными полями или в JSON, кто-то – в другой БД. Можно было бы написать тест, который вызывает метод add() и проверяет все поля в базе и действительно ли там всё записалось в JSON, TIMESTAMP или DATETIME. В итоге на каждый репозиторий будут сотни строк тестов и потом получим проблемы с переписыванием тестов при каждом переименовании поля в базе.Но не всё ли нам равно, как мы там храним? Мы придумали репозиторий для того, чтобы сохранять и извлекать объекты. Поэтому для нас главное – это записать объект и проверить, что он вернулся оттуда таким же, каким был.
Поэтому вместо создания отдельных низкоуровневых тестовых наборов создадим один высокоуровневый и оформим его абстрактным базовым классом:
namespace tests\unit\repositories; use app\entities\Employee\EmployeeId; use app\entities\Employee\Name; use app\entities\Employee\Phone; use app\entities\Employee\Status; use app\repositories\EmployeeRepository; use app\repositories\NotFoundException; use tests\unit\entities\Employee\EmployeeBuilder; use Codeception\Test\Unit; abstract class BaseRepositoryTest extends Unit { /** * @var EmployeeRepository */ protected $repository; public function testGet(): void { $this->repository->add($employee = EmployeeBuilder::instance()->build()); $found = $this->repository->get($employee->getId()); $this->assertNotNull($found); $this->assertEquals($employee->getId(), $found->getId()); } public function testGetNotFound(): void { $this->expectException(NotFoundException::class); $this->repository->get(new EmployeeId(25)); } public function testAdd(): void { $employee = EmployeeBuilder::instance() ->withPhones([ new Phone(7, '888', '00000001'), new Phone(7, '888', '00000002'), ]) ->build(); $this->repository->add($employee); $found = $this->repository->get($employee->getId()); $this->assertEquals($employee->getId(), $found->getId()); $this->assertEquals($employee->getName(), $found->getName()); $this->assertEquals($employee->getAddress(), $found->getAddress()); $this->assertEquals( $employee->getCreateDate()->getTimestamp(), $found->getCreateDate()->getTimestamp() ); $this->checkPhones($employee->getPhones(), $found->getPhones()); $this->checkStatuses($employee->getStatuses(), $found->getStatuses()); } public function testSave(): void { $employee = EmployeeBuilder::instance() ->withPhones([ new Phone(7, '888', '00000001'), new Phone(7, '888', '00000002'), ]) ->build(); $this->repository->add($employee); $edit = $this->repository->get($employee->getId()); $edit->rename($name = new Name('New', 'Test', 'Name')); $edit->addPhone($phone = new Phone(7, '888', '00000003')); $edit->archive(new \DateTimeImmutable()); $this->repository->save($edit); $found = $this->repository->get($employee->getId()); $this->assertTrue($found->isArchived()); $this->assertEquals($name, $found->getName()); $this->checkPhones($edit->getPhones(), $found->getPhones()); $this->checkStatuses($edit->getStatuses(), $found->getStatuses()); } public function testRemove(): void { $employee = EmployeeBuilder::instance()->withId(5)->build(); $this->repository->add($employee); $this->repository->remove($employee); $this->expectException(NotFoundException::class); $this->repository->get(new EmployeeId(5)); } private function checkPhones(array $expected, array $actual): void { $phoneData = function (Phone $phone) { return $phone->getFull(); }; $this->assertEquals( array_map($phoneData, $expected), array_map($phoneData, $actual) ); } private function checkStatuses(array $expected, array $actual): void { $statusData = function (Status $status) { return [ 'value' => $status->getValue(), 'date' => $status->getDate()->getTimestamp(), ]; }; $this->assertEquals( array_map($statusData, $expected), array_map($statusData, $actual) ); } }
Здесь мы просто сохраняем записи и считываем их обратно, проверяя, совпадают ли их поля, статусы и телефоны. И такому тесту теперь не важно, с каким объектом он будет работать.
Теперь для нашего тренировочного
MemoryEmployeeRepository напишем тест-наследник, который будет подставлять нужный объект в родительское поле $this->repository:namespace tests\unit\repositories; use app\repositories\MemoryEmployeeRepository; class MemoryEmployeeRepositoryTest extends BaseRepositoryTest { /** * @var \UnitTester */ public $tester; public function _before(): void { $this->repository = new MemoryEmployeeRepository(); } }
Тесты готовы. Пора приступать к написанию репозиториев. В нашем
MemoryEmployeeRepository мы можем столкнуться с проблемой генерации уникальных идентификаторов в методе nextId(). Мы могли бы везде пользоваться PHP-функцией uniquid():namespace app\repositories; class MemoryEmployeeRepository implements EmployeeRepository { ... public function nextId(): EmployeeId { return uniquid('', true); } }
но эта функция не всегда выдаёт уникальные значения. И нам бы хотелось генерировать идентификаторы общепринятого UUID-формата. В PHP7 для этих целей можно использовать любой алгоритм вроде этого на основе функции
random_bytes, но если не хотите сочинять свои функции и вручную реализовывать совместимость с PHP5, то можете использовать готовую библиотеку Ramsey/Uuid. Её мы себе и установим:composer require ramsey/uuid
Теперь можем написать класс репозитория с использованием этой библиотеки в
nextId:namespace app\repositories; use app\entities\Employee\Employee; use app\entities\Employee\EmployeeId; use Ramsey\Uuid\Uuid; class MemoryEmployeeRepository implements EmployeeRepository { private $items = []; public function get(EmployeeId $id): Employee { if (!isset($this->items[$id->getId()])) { throw new NotFoundException('Employee not found.'); } return clone $this->items[$id->getId()]; } public function add(Employee $employee): void { $this->items[$employee->getId()->getId()] = $employee; } public function save(Employee $employee): void { $this->items[$employee->getId()->getId()] = $employee; } public function remove(Employee $employee): void { if ($this->items[$employee->getId()->getId()]) { unset($this->items[$employee->getId()->getId()]); } } public function nextId(): EmployeeId { return new EmployeeId(Uuid::uuid4()->toString()); } }
Здесь мы, как и договорились, вместо базы данных сохраняем сущности в приватный массив.
Запустим наши тесты:
vendor/bin/codecept run unit repositories
Unit Tests (5) --------------------------------------------- ✔ MemoryEmployeeRepositoryTest: Get (0.01s) ✔ MemoryEmployeeRepositoryTest: Get not found (0.00s) ✔ MemoryEmployeeRepositoryTest: Add (0.00s) ✔ MemoryEmployeeRepositoryTest: Save (0.00s) ✔ MemoryEmployeeRepositoryTest: Remove (0.00s) ------------------------------------------------------------ Time: 146 ms, Memory: 6.00MB OK (5 tests, 14 assertions)
Объект ведёт себя корректно.
В реальной жизни использовать заглушку
MemoryEmployeeRepository мы не будем, но она нам может помочь при необходимости протестировать тот же сервис EmployeeService без использования моков.
В следующих статьях напишем три реальные реализации
EmployeeRepository:SqlEmployeeRepositoryдля работы с SQL-запросами вручную;DoctrineEmployeeRepositoryдля сохранения с использованием Doctrine ORM;AREmployeeRepositoryдля интеграции сущностей с ActiveRecord.
и будем их проверять этими же универсальными тестами.
Комментариев нет:
Отправить комментарий