Для начала давайте изучим метод testExample в файле CreateNewsTest.php, который мы создали в прошлой части урока. Внутри метода всего одна строка:

$this->assertTrue(true);

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

CRUD новостей по TDD

Наш первый тест - создание новостей

Приступим к написанию нашего первого реального теста (удалите метод testExample перед этим).

/** @test */
public function authenticated_users_can_create_new_news()
{
    //
}

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

Для полной уверенности в самом начале теста давайте убедимся, что таблица БД с новостями пустая (содержит 0 записей). Это делать не обязательно, ведь можно просто предположить, что она пустая, тем более в нашем случае мы точно знаем, что мы еще не создавали никаких новостей. Но все таки лучше всегда быть на 100% уверенным.

/** @test */
public function authenticated_users_can_create_new_news()
{
    $this->assertEquals(0, News::count());
}

assertEquals() проверяет, равны ли два любых значения. Всегда передавайте ожидаемое значение в качестве первого параметра. В нашем случае мы просто проверяем равно ли количество записей в таблице новостей нулю (0). Также, мы знаем, что у каждой новости есть заголовок, текст, и иногда еще и изображение. Давайте подготовим данные - пока что без картинки.

$data = [
    'title' => 'Some Title.',
    'body' => 'News Article.'
];

Теперь нам нужно отправить на сервер запрос, чтобы сохранить эти данные. Мне нравится использовать именные маршруты, поэтому именно они и будут использованы в коде. Мы создаем новую запись в БД, поэтому метод запрос должен быть POST. Делать внутренние (API) запросы в тестах Laravel очень просто:

$this->postJson(route('news.store'), $data)
    ->assertStatus(201);

Метод postJson() (в отличие от просто post()) говорит серверу о том, что мы ожидаем ответ в формате JSON. Используя подобные методы проще проверить то, что сервер возвращает в ответе только что созданный объект. Также JSON методы используются для тестирования API. Вы можете связывать несколько методов в одну цепочку. Так метод assertStatus(201), специфичный именно для Laravel, убеждается в том, что код ответа сервера именно такой, какой мы ожидаем. Этот метод можно вызывать только из объекта ответа Response, который возвращается методом postJson() и подобными.

Следующим шагом нам нужно убедиться, что новость была сохранена в БД. Изначально в нашей таблице было 0 записей, соответственно сейчас там должна быть ровно одна запись.

$this->assertEquals(1, News::count());

$news = News::first();

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

/** @test */
public function authenticated_users_can_create_new_news()
{
    $this->assertEquals(0, News::count());

    $data = [
        'title' => 'Some Title',
        'body' => 'News Article.'
    ];

    $this->postJson(route('news.store'), $data)
        ->assertStatus(201);

    $this->assertEquals(1, News::count());

    $news = News::first();

    $this->assertEquals($data['title'], $news->title);
    $this->assertEquals($data['body'], $news->body);
}

Теперь мы можем начать итеративно писать непосредственно код. Выполните наш тест с помощью команды phpunit --filter authenticated_users_can_create_new_news и, конечно же, вы увидите ошибку. В вашем терминале вы должны увидеть следующее:

InvalidArgumentException: Route [news.store] not defined

"Маршрут [news.store] не определен" - и действительно, у нас в проекте нет такого маршрута. Откройте файл routes/web.php и определите его следующим образом:

Route::post('/news', 'NewsController@store')->name('news.store');

Выполните тест снова и вы увидите другую ошибку:

Expected status code 201 but received 500.
Failed asserting that false is true.

"Ожидался код ответа 201, но получили код 500. Не удалось убедиться, что false это true." - из этого не очень понятно, в чем, собственно, проблема, не так ли? Код 500 это такой код, который может быть вызван множеством причин. Здесь срабатывает механизм обработки исключений Laravel, который скрывает от нас истинную причину ошибки. Давайте это исправим. Откройте родительский класс tests/TestCase.php. Мы переопределим метод setUp(), который выполняется ПЕРЕД КАЖДЫМ тестовым методом. Добавьте следующий код:

protected function setUp()
{
    parent::setUp();

    $this->withoutExceptionHandling();
}

Заметьте, что обязательно нужно вызвать parent::setUp(); в начале метода. Последующая строчка просто отключает обработку исключений для всех тестовых методов. Выполните тест снова:

ReflectionException: Class App\Http\Controllers\NewsController does not exist

"Класс App\Http\Controllers\NewsController не существует" - ага! Оказывается, нам нужно создать NewsController. Выполните команду php artisan make:controller NewsController и запустите тест еще раз. Теперь ошибка будет следующей:

BadMethodCallException: Method App\Http\Controllers\NewsController::store does not exist.

"Метод App\Http\Controllers\NewsController::store не существует.". Создайте этот метод в контроллере NewsController:

public function store()
{
    //
}

Кстати, вот именно так и происходит разработка backend'а по методу TDD - мы пишем код в том порядке, который нам диктует наш тест. Выполните тест еще раз.

Expected status code 201 but received 200.

"Ожидали ответ с кодом 201, а получили 200.". Стандартный код ответа контроллера всегда 200. Давайте напишем костыль на скорую руку, чтобы исправить это.

public function store()
{
    return request()->wantsJson()
        ? response()->json([], 201)
        : null;
}

Если запрос ожидает ответ в формате JSON (если вы помните, мы выполняем запрос с помощью метода postJson()) - возвращаем JSON ответ со статусом 201. Иначе возвращаем что-нибудь другое, вероятнее всего редирект или view - это вы уже сами решите, когда будете работать над frontend'ом. На данном этапе это не важно. Снова выполните тест:

Failed asserting that 0 matches expected 1.

"Не удалось подтвердить, что 0 равно ожидаемому 1.". Пока что мы еще ничего не записываем в нашу БД, поэтому проверка $this->assertEquals(1, News::count()); не проходит. Давайте создадим новую новость в контроллере и вернем объект в ответе.

$news = News::create([
    'title' => request('title'),
    'body' => request('body'),
]);

return request()->wantsJson()
    ? response()->json($news, 201)
    : null;

Не забудьте добавить строчку ниже в модель News.php, чтобы разрешить массовое заполнение полей:

protected $guarded = [];

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

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

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

request()->validate([
    'title' => 'required|string|max:255',
    'body' => 'required|string',
    'image' => 'mimes:jpeg,png,gif|nullable'
]);

Это стандартный код Laravel, поэтому предположим, что он всегда работает так, как нам надо. Тест по-прежнему должен выполняться успешно, так как данные в нашем тесте изначально корректные.

Аутентификация

Первый тест готов. Теперь давайте займемся аутентификацией. Мы создадим еще один тестовый метод ("неавторизированные пользователи не могут создавать новости"), который будет очень простым. Мы просто будем отправлять запрос на тот же самый маршрут и ожидать ответ с кодом 401. Нам даже не нужно посылать никакие данные в запросе, так как до валидации данных дело не должно доходить.

/** @test */
public function unauthenticated_users_cant_create_new_news()
{
    $this->postJson(route('news.store'))
        ->assertStatus(401);
}

Если вы выполните тест сейчас, то получити в ответ ошибку:

Illuminate\Validation\ValidationException: The given data was invalid.

Ошибка говорит о том, что данные из запроса не прошли валидацию. Механизм проверки аутентификации не сработал и началось выполнение метода из контроллера. Это очень легко исправить. Просто оберните маршрут в файле routes/web.php в группу с middleware auth:

Route::middleware('auth')->group(function () {
    Route::post('/news', 'NewsController@store')->name('news.store');
});

Однако, если вы снова выполните тест, он опять провалится. На этот раз ошибка будет следующей:

Illuminate\Auth\AuthenticationException: Unauthenticated.

Вместо нормального ответа от сервера мы получаем исключение AuthenticationException, потому что мы отключили обработку исключений. Здесь возможны два пути. Первый - это включить обработку исключений для конкретного теста:

/** @test */
public function unauthenticated_users_cant_create_new_news()
{
    $this->withExceptionHandling();

    $this->postJson(route('news.store'))
        ->assertStatus(401);
}

Другой - сказать PHPUnit, чтобы он ожидал конкретное исключение. В этом случае можно даже убрать строчку c assertStatus. Лично я обычно использую первый вариант, но выбирать вам.

/** @test */
public function unauthenticated_users_cant_create_new_news()
{
    $this->expectException(\Illuminate\Auth\AuthenticationException::class);

    $this->postJson(route('news.store'));
}

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

protected function setUp()
{
    parent::setUp();

    $this->user = factory('App\User')->create();
}

Местный метод setUp наследует setUp из класса TestCase, то есть оригинальное содержимое метода будет дополнено, а не перезаписано. Здесь мы используем фабрику (factory) для генерации пользователя со случайными данными. Если вы никогда не пользовались фабриками, взгляните на файл database/factories/UserFactory.php. По сути данная фабрика создает объект класса App\User с указанными данными (значения большинства полей генерируются случайно). Фабрики нужны для того, чтобы по-быстрому сгенерировать тестовые данные в БД.

Теперь вернемся к тестовому методу authenticated_users_can_create_new_news. Отредактируйте запрос на сервер следующим образом, чтобы он отправлялся от имени пользователя, которого мы только что создали:

$this->actingAs($this->user)
    ->postJson(route('news.store'), $data)
    ->assertStatus(201);

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