Привет, Artisans (англ. Мастера; прогр. Artisan — интерфейс командной строки, входящий в состав Laravel), меня зовут Альберто Росас (Alberto Rosas), я пользуюсь Laravel уже много лет, и одна из самых полезных и стоящих вещей, которые я узнал, — это создание правильных тестовых сьютов для своих приложений. Очень приятно видеть, что тестирование все чаще практикуется в сообществе Laravel, поэтому в этой статье мы начнем с основ TDD (Test Driven Development. Разработка через тестирование) в Laravel и продолжим тему в других статьях. Вот что нам предстоит освоить: Создание API с нуля с упором на базовые фичи CRUD. Внедрим TDD с самого начала, чтобы проиллюстрировать, как создавать тестируемые приложения Laravel. Задача данной статьи — показать, что TDD не обязательно должен быть трудным или мучительным, чтобы сделать его частью рабочего процесса для вас или вашей команды. Вам просто нужно понять, с чего начать и что тестировать. Мне не потребовалось много времени, чтобы превратить TDD в привычку и найти подходящий для меня рабочий процесс. Важно иметь стандартный рабочий процесс; знание того, что вы будете делать в первую очередь в типичном проекте, может помочь вам создать свою собственную структуру, организовать все таким образом, чтобы вы знали, где что находится в вашем приложении, и немного стандартизировать ваши личные/клиентские проекты. Другими словами, TDD помогает вам кодить быстрее и увереннее. Я упоминал ранее о понимании того, что тестировать в первую очередь. Но на самом деле это не имеет значения, мы сами направим вас к следующим тестам, поэтому единственное, что вам нужно действительно знать, это фича, которую вы хотите протестировать. Базовые знания о фреймворке. Свежий проект Laravel. В общем, давайте просто начнем. В этой статье и, возможно, в серии статей я буду создавать API для приложения по недвижимости. Таблица properties: id (идентификатор): первичный ключ; type (тип): строка (позже мы, вероятно, создадим связь с 'property_types'); price (цена): положительное целое число; description (описание): текст. При работе с Laravel мы обычно следуем стандарту/соглашению, которое заключается в организации действий контроллера в 5 методах API: index (индекс); store (хранение); update (обновление); delete (удаление). Мы последовательно проработаем весь наш список и объясним, какие вещи наиболее интересны для тестирования. Метод Index обычно используется для возврата определенной Коллекции для Модели. У нас есть именованный эндпойнт API для получения коллекции ресурсов. Проверим, что ответ приходит в виде коллекции. Давайте начнем с создания теста, откроем терминал внутри вашего проекта Laravel и запустим его: Это создаст файл теста в /tests/Features/Api/PropertiesTest.php Внутри этого файла мы добавим наш первый тест, который будет проверять, что мы попали в маршрут API index и получили обратно коллекцию свойств (Properties), которых на данный момент у нас нет, но я позволю тесту самому порулить. Конечно, здесь возникнет ошибка, мы еще не создали модель Property, поэтому после запуска ./vendor/bin/phpunit --testdox в каталоге нашего проекта мы получим: Давайте сделаем это: php artisan make:model Property -mf Приведенная выше команда выполнит: Создаст модель, расположенную в app/Models/Property.php -m создаст миграцию в /database/migrations/ -f создаст класс Factory (фабрика) в /database/factories/PropertyFactory, который мы будем использовать для мокинга "Property" и его атрибутов. Применяя TDD, действуем последовательно; после создания нашей модели, миграции и фабрики давайте запустим тест снова (вы получите ту же ошибку, если не импортируете определение модели Property поверх вашего теста): Давайте продолжим и создадим необходимый эндпойнт в файле /routes/api.php: Далее мы запустим его и получим ошибку; PropertyController не существует: ReflectionException: Class PropertyController does not exist Откроем терминал и создадим наш контроллер через artisan: Контроллер будет расположен в /app/Http/Controllers/Api/PropertyController.php . Откройте ранее созданный файл и запустите тест снова: Мы получаем еще одну ошибку, указывающую на то, что метод index не существует, давайте создадим его, чтобы, наконец, добиться прохождения теста: Запускаем тест, и он проходит, но... мы не выполнили фактическую логику, чтобы проверить, что он возвращает коллекцию, давайте сделаем это, обновив наш тест, чтобы утверждать, что мы получаем JSON. И, конечно же, мы получаем: Invalid JSON was returned from the route. (с маршрута был возвращен недопустимый JSON), ну... мы на самом деле ничего не возвращаем из метода index, так что давайте сделаем это: Причина, по которой мы возвращаем массив data и внутри него коллекцию, заключается в стандарте ответов API, где содержимое должно быть внутри этого массива data. Мы получаем еще одну ошибку, которая заключается в утверждении, что мы получаем атрибуты Property, возвращенные из ответа, но атрибуты являются null, можете себе представить, почему? Вы угадали! Мы не обновили наш класс PropertyFactory, чтобы он имел запланированные нами атрибуты: Мы начнем получать ошибки, связанные с "неизвестными столбцами...". потому что наша миграция не содержит столбцов, которые, по нашему утверждению, существуют. Давайте обновим миграцию, добавив необходимые столбцы: Если мы запустим тест снова, то заметим, что он снова прошел, и на этот раз окончательно. Теперь, когда мы создали протестированный метод index, давайте продолжим с методом Store, который потребует немного больше работы. Этот тест мы начнем с создания второго теста в том же файле tests/Feature/Api/PropertiesTest.php Вначале кратко резюмируем этот тест: Мы создаем неперсистентную модель Property для использования в качестве запроса пользователя с помощью метода Factory (фабрика): make. Мы делаем post-запрос через API к маршруту route('api.properties.store') с данными запроса. Затем подтверждаем, что получаем в ответ статус-код 201: Resource Created (Ресурс создан) Утверждаем, что мы получили хотя бы один из новых ключей, чтобы проверить, что он пришел в правильном формате. И, наконец, мы утверждаем, что таблица properties содержит новую модель Property. Запустив тест, мы получаем ошибку: Что означает именно это; не существует маршрута API с именем, подобным вышеуказанному маршруту. В /routes/api.php: Конечно, мы не создали метод store, мы делаем это следующим образом: Это фактически единственное, что нам надо сделать. Но наша следующая ошибка связана с Mass Assignment (массовое назначение), и по сути нам нужно создать свойство protected $fillable = [];, которое содержит имена столбцов, которые вы хотите массово присвоить. Оно выглядит следующим образом: Отлично, теперь мы в результате получили зеленый. Конечно, нам все еще нужно проверить эти свойства при создании, поэтому давайте сделаем это правильно. Начну с создания "Юнит" теста (или так я его назвал), поскольку меня интересует только утверждение, что я получаю ошибку в результате "создания" или "обновления" Property и намеренно делаю их неудачными; таким образом вы проверяете, что FormRequest внедряется в методы store и update в вашем контроллере. Создание FormRequest с помощью artisan выглядит следующим образом: В результате будет создан файл /app/Http/Requests/PropertyRequest.php, откройте его и вы получите этот класс: Здесь вы заметите метод под названием authorize, который возвращает false, вы можете проверить доступ к методу, возвращая true/false, когда установлено определенное правило; если оно возвращает false, метод в контроллере вернет код состояния 403: unauthorized. Давайте продолжим и создадим наш PropertyRequestTest для проверки правил, которые мы применяем к нашему методу store и, впоследствии, и к update. Я обычно размещаю свои тесты валидации в tests/Unit/Http/Requests/, чтобы сымитировать расположение контроллера, где он используется. Откройте новый тест: Если заметили, то я изменил одну деталь, прежде чем продолжить, и это определение TestCase, импортируемое по умолчанию. Определение PHPUnit\Framework\TestCase входит по умолчанию в модульные тесты и является  по умолчанию классом PHPUnit, нам нужно заменить его на Laravel's TestCase, расположенный в каталоге tests, чтобы иметь в своем распоряжении утверждения и хелперы Laravel. Итак, давайте начнем с первого теста, который проверяет, выполняем ли мы правило required: Итак, это мой способ стандартизации тестов валидации. Мы создаем общий способ репликации правил и просто обновляем переменную $brokenRule, чтобы она содержала значение, которое нарушит правило. Поэтому можно утверждать, что результаты JSON-валидации содержат ошибку. Таким образом, я могу просто копипастить тот же тест и только изменить $validatedField и $brokenRule для новой проверки. Вот что здесь происходит: Сначала мы создаем переменную валидированного поля, чтобы повторно использовать имя столбца и заменять его в других тестах. Мы создали переменную нарушенного правила, содержащую значение, которое будет инициировать запрос формы. Затем мы создаем неперсистентную модель со значениями, которые нарушат валидацию. Мы делаем POST-запрос, чтобы попытаться создать новое Property (свойство). Мы сразу же утверждаем, что пакет ошибок JSON-валидации включает в себя проверенное поле, подтверждая наличие ошибки, следовательно, наш запрос выполняет свою работу. Получаем ошибку после выполнения теста: Это означает, что тест не обнаружил ошибку валидации в ответе POST-запроса, который мы сделали, очевидно, что мы не имплементировали FormRequest в PropertyController, давайте поменяем Illuminate\Http\Request на наш PropertyRequest, чтобы метод store выглядел следующим образом: Если мы запустим тест, то получим ту же ошибку, но в действительности она некорректна, я объясню это через секунду. Давайте перейдем к нашему тесту type_is_required и добавим метод под названием withoutExceptionHandling, как показано ниже: По умолчанию Laravel защищает нас от некоторых исключений, модифицируя ответ на "дружественное" сообщение о такой ошибке. В действительности наш тест не работает, потому что мы реализовали PropertyRequest, который содержит метод authorize, возвращающий false. Этот метод может быть использован для имплементации валидации на права, роли или другое условие, которое, в случае true, позволяет запросу перейти к правилам валидации, если нет, то он выбрасывает код состояния 403, который unauthorized, как описано ранее. Чтобы исправить это, давайте изменим false на true, так как мы не проверяем здесь никаких прав или условий: После этого мы должны удалить наш метод withoutExceptionHandling, чтобы получить ожидаемое сообщение об исключении, а затем начнем добавлять правила, которые мы тестируем в нашей FormRequest. Первое правило валидации — required, и поскольку мы тестируем колонку type, это будет наша первая валидация: Теперь, если мы запустим тест, то в результате получим зеленый, и наш тест проходит, подтверждая это: Метод store требует, чтобы значение type присутствовало в запросе. В нашем методе store используется FormRequest. Мы немного стандартизировали наш тест проверки, поэтому следующий набор тестов будет проще реализовать. Теперь я просто последовательно добавлю остальные тесты, поскольку их реализация очень похожа; после этого я покажу и объясню правила валидации, реализованные в FormRequest. Поскольку мы указали длину столбца type максимум 20 символов, я продолжу и добавлю ту валидацию. Как видите, мы сделали копипаст предыдущего теста и изменили значение $brokenRule на то, которое приведет к сбою, поскольку именно это нам и нужно. $brokenRule становится строкой случайных букв, содержащей 21 символ, что вызывает ошибку валидации. Поскольку мы не добавили правило в FormRequest, тест еще не пройден. Мы добавляем правило max, чтобы указать максимальное значение, которое мы ожидаем от поля (оно в данном случае равно 20), и получаем зеленый! Переходим к следующему тесту, который предназначен для price. Просто скопируем предыдущие тесты и продолжим: Мы просто изменили название теста, сопровождаемого значением $validatedField, в виде столбца, который мы валидируем. Как видите, валидация превратилась в лёгкую задачу, поскольку мы просто копипастим наши предыдущие тесты. Добавляем правило для Price (цена): Тесты пройдут, поскольку работа уже сделана. Давайте просто быстро выполним недостающие тесты: Правило валидации: В результате — зеленый. Мы могли бы даже избежать этого теста, добавив Attribute Casting (кастинг атрибутов) для столбца price как integer, но я предпочитаю пока держать валидации вместе. Поскольку поле description является типом столбца text, я не уверен, что нам нужно это проверять, так как поле text может содержать до 65,535 байт, что вполне достаточно для того, чтобы пользователь мог набрать все, что захочет. На этом наш метод store протестирован вместе с его валидацией. Увидимся в следующем тесте. Мы прошли половину пути. Продолжим с методом Update. Как видно из предыдущих тестов, через некоторое время все становится довольно просто. Давайте начнем с добавления нашего теста в класс PropertiesTest: Запуская наши тесты, мы знаем, что наша первая ошибка связана с несуществующим маршрутом: api.properties.update; давайте быстро добавим его в наш файл routes/api.php: Мы уже знаем, что следующая ошибка напомнит нам о том, что метод update не существует: Метод update принимает Request (запрос) в качестве первого параметра (обратите внимание, я не использовал PropertyRequest, так как мы не реализовали тест) и Property, которое мы обновляем, приходящее из эндпойнт с помощью Route Implicit Binding (неявная привязка маршрута) от Laravel. Мы также можем отметить метод tap, который, по сути, возвращает элемент, который вы ему передаете, при этом имея возможность связывать с ним методы; в конце он вернет модель после обновления, в отличие от альтернативного варианта: и который тоже является валидным, он просто требует дополнительной строки. На данном этапе мы должны получить прохождение теста. Нам просто не хватает тестов валидации, давайте сделаем это, вернувшись к нашему файлу tests\Unit\Http\Requests\PropertyRequestTest.php и рассмотрим, как включить выполнение update в наши существующие тесты: В блоке утверждения Update мы: Создали существующее Property (свойство), которое мы хотим обновить. Создали неперсистентное фабричное Property с проверяемым полем и значением, которое нарушит тест (в данном случае null). Затем мы сделали PUT-запрос к маршруту api.properties.update, передав существующее Property в качестве параметра, поскольку именно этого ожидает наш маршрут. И мы сделали утверждение для этого запроса, подтверждающее, что мы получили JSON-ошибку . Единственное, чего не хватает, это повторить тесты для остальных полей, заменив $validatedField и $brokenRule соответственно, и внедрить PropertyRequest в наш новый метод update вместо класса Laravel's Request следующим образом: И у нас есть пройденный тест. Отлично, переходим к финальному тесту. Это будет самый короткий тест, сделанный в этой статье, так как мы просто проверим, что при достижении эндпойнт delete удалим модель Property, а также вернем ответ со статус-кодом 204: No Content (нет контента). Если мы запустим его, то получим ожидаемую ошибку, которая заключается в том, что у нас нет указанного маршрута, давайте добавим его: И запустите его снова, чтобы обнаружить, что метод destroy не существует; я добавлю его в наш PropertyController ниже метода update: Держу пари, это пройдет, верно? Метод destroy довольно прост; мы получаем ожидаемое свойство из Route (маршрута) и, поскольку неявная привязка определила, какую модель Property мы хотим удалить, мы просто запускаем метод delete() на нем, а затем возвращаем ответ. Примечание: я стандартизировал ответы для этой статьи, но вы можете возвращать то, что вам нужно. Хотя я постарался сделать этот пример максимально приближенным к реальности, есть кое-что, что следовало бы сделать по-другому, будь это реальное приложение: Вместо возврата JSON-ответов, как мы делали, я бы использовал API Resources (ресурсы API). Причина в том, что возможно, мне захочется внести несколько изменений в коллекцию/модель, которую необходимо вернуть, а ресурс API позволит это сделать. Дополнительные примеры можно найти в документации. Почти каждая сквозная фича, которую вам нужно будет протестировать, имеет структуру, схожую с вышеупомянутыми тестами, поскольку мы делаем запросы к действию и ожидаем от него результата, из которого вы можете легко подстроить тест под свои нужды. Некоторые из причин, по которым команда не внедряет тесты, по моему опыту, заключается в убеждении, что тестирование отнимет время. Как мы видим, вы можете копипастить большинство тестов и адаптировать их к новому сценарию очень быстро, а если вы стандартизируете свой рабочий процесс и код, это будет еще быстрее. Конечно, копипаст будет возможен не в каждой фиче, но как только вы начнете серьезно относиться к тестированию, в вашей голове все встанет на свои места, и вы обнаружите закономерность в его проведении. Я предлагаю начать с тестирования небольших фич и следовать подходу TDD как можно чаще. Возможно, вскоре вы решите, что вам нравится сначала кодить, а потом тестировать; на самом деле это не имеет значения, пока вы тестируете свои фичи. Мы только начинаем изучать TDD, поэтому ожидайте новых статей, подобных этой, в которых будут раскрыты новые темы. "Laravel 9" вышла в феврале этого года. Релиз содержит достаточно много нововведений, среди которых: поддержка компонентов Symfony 6, Symfony Mailer, Flysystem 3, улучшенный вывод route:list, драйвера Laravel Scout, новый синтаксис аксессор/мутатор Eloquent и различные другие исправления ошибок и улучшения для удобства использования фреймворка. Приглашаем на открытое занятие, на котором постараемся затронуть самые важные из них. Регистрация для всех желающих по ссылке.