Части
перейти к части...
Давайте продолжим разработку нашего модуля новостей.
Загрузка изображений
Мы закончили создание простых новостей, но как вы помните, у новости может также быть и изображение. Создадим новый тестовый метод для тестирования загрузки изображений.
/** @test */
public function authenticated_users_can_create_news_with_images()
{
//
}
Не удивительно, если у вас возникнут вопросы вроде "Откуда мы возьмем изображение для загрузки во время тестов?" и "Нам придется удалять загруженные изображения вручную каждый раз после выполнения теста?". К счастью, в Laravel уже есть готовое решение всех наших проблем.
Первая концепция, которую мы рассмотрим, называется "пародирование" или "имитирование" (mocking). У некоторых классов Laravel есть методы, которые позволяют имитировать некоторые объекты классов и их действия. Поскольку мы будем работать с файлами, мы будем использовать Storage Fake ("имитировать" работу класса Storage). Storage fake создает подставной диск для загрузки в него файлов. Диск будет находится в папке storage/framework/testing
(реальные файлы загружаются в абсолютно другую папку). Добавьте следующую строчку в начале тестового метода, чтобы создать имитацию диска public
:
Storage::fake('public');
С этого момента все операции со Storage будут проходить над "фейковым" диском public
, а не над реальным. Далее нам нужно подготовить данные запроса, которые теперь будут включать файл изображения для загрузки. Где нам взять этот файл? Как вы, возможно, догадались, файлы будут тоже ненастоящие. У класса UploadedFile также есть метод fake()
, который позволяет сгенерировать ненастоящий документ или изображение.
$data = [
'title' => 'Some Title',
'body' => 'News Article.',
// Файл изображения с названием 'image.jpg', шириной = 1px и высотой = 1px
'image' => $file = UploadedFile::fake()->image('image.jpg', 1, 1)
];
Мы можем выполнить запрос с новыми данными и вытащить только что созданную новость из БД.
$this->actingAs($this->user)
->postJson(route('news.store'), $data)
->assertStatus(201);
$this->assertEquals(1, News::count());
$news = News::first();
На этот раз мы не будем проверять правильность сохранения текстовых данных или факт того, что в таблице news
в БД всего одна запись - мы уже делаем это в другом тесту. Вместо этого, мы удостоверимся, что файл изображения был успешно загружен и путь к файлу был записан в поле image_path
объекта новости. Файлы должны будут загружаться в папку news
на диске public
. Заметьте, что у класса Storage есть свои методы для выполнения проверок в тестах.
$imagePath = 'news/' . $file->hashName();
$this->assertEquals($imagePath, $news->image_path);
Storage::disk('public')->assertExists($imagePath);
И вот тестовый метод целиком:
/** @test */
public function authenticated_users_can_create_news_with_images()
{
Storage::fake('public');
$data = [
'title' => 'Some Title',
'body' => 'News Article.',
// Файл изображения с названием 'image.jpg', шириной = 1px и высотой = 1px
'image' => $file = UploadedFile::fake()->image('image.jpg', 1, 1)
];
$this->actingAs($this->user)
->postJson(route('news.store'), $data)
->assertStatus(201);
$news = News::first();
$imagePath = 'news/' . $file->hashName();
$this->assertEquals($imagePath, $news->image_path);
Storage::assertExists($imagePath);
}
Как и должны быть, тест провалится, так как у нас нет кода для загрузки изображений на стороне backend'а. Возвращаемся в NewsController
. Нам нужно внести некоторые изменения. Для начала, давайте вытащим данные после валидации в отдельный массив. Просто задайте результат валидации в качестве значения для переменной $data
. Метод validate()
возвращает массив полей, которые успешно прошли проверку.
$data = request()->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
'image' => 'mimes:jpeg,png,gif|nullable'
]);
Затем передайте этот массив в качестве параметра в метод News::create()
:
$news = News::create($data);
Мы займемся обработкой загружаемого изображения прямо над этой строчкой кода. Нам нужно загрузить изображение на сервер (диск), если оно было передано вместе с запросом, а потом задать путь к загруженному файлу в качестве значения для ключа image_path
в массиве $data
.
if (isset($data['image'])) {
$data['image_path'] = $data['image']->store('news');
}
В результате вы наткнетесь на ошибку SQL, говорящую о том, что поле image
не существует в таблице news
. Очевидно, что поле image
было возвращено методом validate()
. Этот метод, к тому же, возвращает любые поля, для которых не были заданы правила валидации, считая их верно заполненными по-умолчанию. Если вы не хотите вручную задавать поля для метода News::create()
(как мы это делали раньше), а это напрягает при большом количестве полей, сделайте следующее.
Откройте файл модели News.php
и удалите строчку protected $guarded = [];
, которую мы добавили ранее. На её место добавьте:
protected $fillable = [
'title',
'body',
'image_path'
];
Этим мы говорим Laravel: "Когда данные сохраняются в БД, возьми только эти поля из входных данных и игнорируй все остальные". Если какие-то из полей не должны быть заполняемыми пользователем (например счетчик просмотров новости) - их нужно либо удалить из этого массива, либо написать соответствующие проверки в коде и обработать поле вручную.
Теперь наш тест должен выполняться с успехом. Поздравляю! Итак, у нас есть код backend'а, который подкреплен тестами. Остается только сделать страницу с формой, которая будет отправлять данные на существующий маршрут. Данный же урок не об этом. Следующим шагом займемся редактированием (обновлением) существующих новостей.
Редактирование (обновление) новостей
Создайте новый функциональный тест:
php artisan make:test EditNewsTest
Как и прежде, добавьте use RefreshDatabase;
вверху класса. Точно так же удалите метод testExample
. Также я осознал, что здесь нам тоже понадобится пользователь для выполнения запросов. Мы создали пользователя в методе setUp
в классе CreateNewsTest
. Давайте вырежем строчку $this->user = factory('App\User')->create();
и вставим её в конец метода setUp
в класс TestCase
. Затем удалите метод setUp
из класса CreateNewsTest
. Чтобы убедиться, что все по прежнему работает как и раньше (и это хорошая практика), выполните весь набор имеющихся тестов - они должны выполниться успешно.
Отлично, теперь перейдем к нашему новому тесту. Некоторые моменты вам будут уже знакомы, поэтому мы будем двигаться немного быстрее. Первый тест будет подтверждать, что неавторизированные пользователи не могут редактировать существующие новости. Поэтому, нам придется создать новость прежде, чем мы попытаемся ее отредактировать. В помощь нам придут фабрики. Давайте создадим новую фабрику.
php artisan make:factory NewsFactory
Откройте файл database/factories/NewsFactory.php
. Измените текст Model::class
на класс модели новостей, а именно App\News::class
. Затем заполните массив случайными данными с помощью объекта класса Faker
.
$factory->define(App\News::class, function (Faker $faker) {
return [
// 3 = массив из 3х случайных слов
// true = объединить массив в строку, в которой элементы будут разделены пробелом
'title' => $faker->words(3, true),
'body' => $faker->paragraph,
];
});
Теперь вернемся к тестовому методу. Он будет очень похожим на метод из класса CreateNewsTest
.
/** @test */
public function unauthenticated_users_cant_update_existing_news()
{
$this->withExceptionHandling();
$news = factory('App\News')->create();
$this->postJson(route('news.update', $news))
->assertStatus(401);
}
Выполните тест... Route [news.update] not defined.
. Откройте файл маршрутов и добавьте новый маршрут в группу с middleware auth
.
Route::post('/news/{news}', 'NewsController@update')->name('news.update');
Выполните тест снова и он должен пройти даже несмотря на то, что у нас еще нет соответствующего метода в контроллере - middleware прерывает выполнение приложения и дело не доходит до метода в контроллере. Следующий тест будет подтверждать, что авторизированные пользователи могут редактировать данные существующих новостей. Заметьте, что данные, предоставляемые нами, будут всегда отличаться от данных, сгенерированных с помощью Faker
.
/** @test */
public function authenticated_users_can_update_existing_news()
{
$news = factory('App\News')->create();
$data = [
'title' => 'Updated Title',
'body' => 'Updated body.',
];
$this->actingAs($this->user)
->postJson(route('news.update', $news), $data)
->assertStatus(200);
$news = $news->fresh();
$this->assertEquals($data['title'], $news->title);
$this->assertEquals($data['body'], $news->body);
}
Мы просто создаем случайную новость с помощь NewsFactory
и затем обновляем (редактируем) её. Единственная новая концепция здесь - строка $news = $news->fresh();
. Мы будем обновлять запись в БД с помощью метода update
в контроллере NewsController
. Метод fresh()
вытаскивает "свежие" данные из БД, так как значения свойств объекта $news
не обновляются автоматически при обновлении записи в БД.
Сразу же напишем тест, связанный с загрузкой изображений. Пользователи должны иметь возможность заменить изображение у существующий новости в процессе обновления записи. В то же время нам нужно убедиться, что старый файл удаляется. Обратите внимание на пояснения в комментариях.
/** @test */
public function authenticated_users_can_replace_the_image_when_updating_existing_news()
{
Storage::fake('public');
// Выполним реальный запрос для создания новости, чтобы иметь на
// руках новость с загруженным изображением. Фабрика генерирует
// новости без реальных файлов изображений.
$data = [
'title' => 'Some Title',
'body' => 'News Article.',
// An image called 'image.jpg' with width = 1px and height = 1px
'image' => UploadedFile::fake()->image('image.jpg', 1, 1)
];
// Вы помните, что мы возвращаем JSON ответ при создании новой новости
// в методе `create`? Мы извлекем этот JSON ответ. Учтите, что данные
// будут в виде массива.
$news = $this->actingAs($this->user)
->postJson(route('news.store'), $data)
->assertStatus(201)
->json();
// Заменяем изображение на новое
$data['image'] = $newFile = UploadedFile::fake()->image('new_image.jpg', 1, 1);
// Обновляем новость
$this->actingAs($this->user)
->postJson(route('news.update', $news['id']), $data)
->assertStatus(200);
// Убеждаемся в том, что старый файл был удален
Storage::assertMissing($news['image_path']);
// Мы не можем вызвать метод `fresh()` из массива. Вместо этого мы заново
// запросим данные из БД вручную.
$news = News::find($news['id']);
// Убедимся, что новый файл изображения был загружен на сервер и значение
// поля 'image_path' было задано верно.
$newImagePath = 'news/' . $newFile->hashName();
$this->assertEquals($newImagePath, $news->image_path);
Storage::assertExists($newImagePath);
}
Выполните оба новых теста - они провалятся. Теперь давайте займемся методом контроллера.
public function update(News $news)
{
$data = request()->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
'image' => 'mimes:jpeg,png,gif|nullable'
]);
if (isset($data['image'])) {
$data['image_path'] = $data['image']->store('news');
// Удаляем старый файл изображения, если таковой имеется
if ($news->image_path && Storage::disk('local')->exists($news->image_path)) {
Storage::disk('local')->delete($news->image_path);
}
}
$news->update($data);
return request()->wantsJson()
? response()->json($news, 200)
: null;
}
Выполните тесты снова - все должно работать. Вы наверняка заметили, что методы create
and update
практически идентичны. Это значит, что мы можем и должны (по-хорошему) сделать рефакторинг, выделив повторяющийся код отдельно. Ниже код контроллера NewsController
целиком после рефакторинга. Это типичный код Laravel и, надеюсь, там все понятно без лишних объяснений.
<?php
namespace App\Http\Controllers;
use App\News;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Storage;
class NewsController extends Controller
{
public function store()
{
$this->validator(request()->all())->validate();
$news = $this->createOrUpdate(request()->all());
return request()->wantsJson()
? response()->json($news, 201)
: null;
}
public function update(News $news)
{
$this->validator(request()->all())->validate();
$news = $this->createOrUpdate(request()->all(), $news);
return request()->wantsJson()
? response()->json($news, 200)
: null;
}
public function validator($data)
{
return Validator::make($data, [
'title' => 'required|string|max:255',
'body' => 'required|string',
'image' => 'mimes:jpeg,png,gif|nullable'
]);
}
public function createOrUpdate($data, $news = null)
{
if (isset($data['image'])) {
// Загружаем новое изображение
$data['image_path'] = $data['image']->store('news');
// Удаляем старый файл изображения, если таковой имеется
if (optional($news)->image_path && Storage::disk('local')->exists($news->image_path)) {
Storage::disk('local')->delete($news->image_path);
}
}
if ($news) {
$news->update($data);
} else {
$news = News::create($data);
}
return $news;
}
}
После рефакторинга абсолютно все наши тесты должны проходить успешно.
Заключение
На этой ноте мы и закончим наш урок. Мы рассмотрели основы метода разработки через тестирование и теперь вы готовы к тому, что применять его самостоятельно. Надеюсь с сегодняшнего дня вы будете использовать TDD в своих проектах.
Однако, еще многое можно сказать о тестировании. Советую ознакомиться со списком существующих проверок PHPUnit и документацией Laravel (не забывайте, что Laravel расширяет стандартный функционал PHPUnit).
Все материалы на сайте voerro абсолютно бесплатны и написаны автором в свободное от основной работы время. Если уроки сайта оказались для вас полезными, пожалуйста, помогите проекту. Спасибо!