Привет, 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

Метод Index обычно используется для возврата определенной Коллекции для Модели.

Проведем тестирование:

  • У нас есть именованный эндпойнт API для получения коллекции ресурсов.

  • Проверим, что ответ приходит в виде коллекции.

Давайте начнем с создания теста, откроем терминал внутри вашего проекта Laravel и запустим его:

php artisan make:test Api/PropertiesTest

Это создаст файл теста в /tests/Features/Api/PropertiesTest.php

Внутри этого файла мы добавим наш первый тест, который будет проверять, что мы попали в маршрут API index и получили обратно коллекцию свойств (Properties), которых на данный момент у нас нет, но я позволю тесту самому порулить.

<?php  
  
namespace Tests\Feature\Api;  
  
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;  
  
class PropertiesTest extends TestCase  
{
	use RefreshDatabase;
	
	/** @test */
	public function can_get_all_properties()
	{
		// Create Property so that the response returns it.
		$property = Property::factory()->create();
		
		$response = $this->getJson(route('api.properties.index'));
		// We will only assert that the response returns a 200 status for now.
		$response->assertOk(); 
	}
}

Конечно, здесь возникнет ошибка, мы еще не создали модель Property, поэтому после запуска ./vendor/bin/phpunit --testdox в каталоге нашего проекта мы получим:

Tests\Feature\Api\PropertiesTest::can_get_all_properties
Error: Class 'Tests\Feature\Api\Property' not found

Давайте сделаем это:

php artisan make:model Property -mf

Приведенная выше команда выполнит:

  • Создаст модель, расположенную в app/Models/Property.php

  • -m создаст миграцию в /database/migrations/

  • -f создаст класс Factory (фабрика) в /database/factories/PropertyFactory, который мы будем использовать для мокинга "Property" и его атрибутов.

Применяя TDD, действуем последовательно; после создания нашей модели, миграции и фабрики давайте запустим тест снова (вы получите ту же ошибку, если не импортируете определение модели Property поверх вашего теста):

Tests\Feature\Api\PropertiesTest::can_get_all_properties
Symfony\Component\Routing\Exception\RouteNotFoundException: 
Route [api.properties.index] not defined.

Давайте продолжим и создадим необходимый эндпойнт в файле /routes/api.php:

use App\Http\Controllers\Api\PropertyController;

Route::get(
	'properties', 
	[PropertyController::class, 'index']
)->name('api.properties.index');

Далее мы запустим его и получим ошибку; PropertyController не существует:

ReflectionException: Class PropertyController does not exist

Откроем терминал и создадим наш контроллер через artisan:

php artisan make:controller Api/PropertyController

Контроллер будет расположен в /app/Http/Controllers/Api/PropertyController.php .

Откройте ранее созданный файл и запустите тест снова:

<?php  
  
namespace App\Http\Controllers\Api;  
  
use Illuminate\Http\Request;  
use App\Http\Controllers\Controller;  
  
class PropertyController extends Controller  
{  
   
}

Мы получаем еще одну ошибку, указывающую на то, что метод index не существует, давайте создадим его, чтобы, наконец, добиться прохождения теста:

<?php  
  
namespace App\Http\Controllers\Api;  
  
use Illuminate\Http\Request;  
use App\Http\Controllers\Controller;  
  
class PropertyController extends Controller  
{  
	public function index()
	{
	}
}

Запускаем тест, и он проходит, но... мы не выполнили фактическую логику, чтобы проверить, что он возвращает коллекцию, давайте сделаем это, обновив наш тест, чтобы утверждать, что мы получаем JSON.

<?php  
  
namespace Tests\Feature\Api;  
  
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;  
  
class PropertiesTest extends TestCase  
{
	use RefreshDatabase;
	
	/** @test */
	public function can_get_all_properties()
	{
		// Create Property so that the response returns it.
		$property = Property::factory()->create();
		
		$response = $this->getJson(route('api.properties.index'));
		// We will only assert that the response returns a 200 
		// status for now.
		$response->assertOk(); 
		
		// Add the assertion that will prove that we receive what we need 
		// from the response.
		$response->assertJson([
			'data' => [
				[
					'id' => $property->id,
					'type' => $property->type,  
					'price' => $property->price,  
					'description' => $property->description,
				]
			]
		]);
	}
}

И, конечно же, мы получаем:

Invalid JSON was returned from the route. (с маршрута был возвращен недопустимый JSON), ну... мы на самом деле ничего не возвращаем из метода index, так что давайте сделаем это:

<?php  
  
namespace App\Http\Controllers\Api;  
  
use App\Models\Property;
use Illuminate\Http\Request;  
use App\Http\Controllers\Controller;  
  
class PropertyController extends Controller  
{  
	public function index()
	{
		return response()->json([  
		    'data' => Property::all()  
		]);
	}
}

Причина, по которой мы возвращаем массив data и внутри него коллекцию, заключается в стандарте ответов API, где содержимое должно быть внутри этого массива data.

Мы получаем еще одну ошибку, которая заключается в утверждении, что мы получаем атрибуты Property, возвращенные из ответа, но атрибуты являются null, можете себе представить, почему?

Unable to find JSON: 

[{
    "data": [
        {
            "id": 1,
            "type": null,
            "price": null,
            "description": null
        }
    ]
}]

within response JSON:

[{
    "data": [
        {
            "id": 1,
            "created_at": "2021-10-15T14:44:21.000000Z",
            "updated_at": "2021-10-15T14:44:21.000000Z"
        }
    ]
}]

Вы угадали! Мы не обновили наш класс PropertyFactory, чтобы он имел запланированные нами атрибуты:

use App\Models\Property;  
use Illuminate\Database\Eloquent\Factories\Factory;

class PropertyFactory extends Factory  
{  
	 /**  
	 * The name of the factory's corresponding model. * * @var string  
	 */  
	 protected $model = Property::class;  

	 /**  
	 * Define the model's default state. 
     * @return array  
	 */  
	 public function definition()  
	 {
		 return [  
			'type' => $this->faker->word,  
			'price' => $this->faker->randomNumber(6),  
			'description' => $this->faker->paragraph,
		 ];  
	 }
 }

Мы начнем получать ошибки, связанные с "неизвестными столбцами...". потому что наша миграция не содержит столбцов, которые, по нашему утверждению, существуют.

Давайте обновим миграцию, добавив необходимые столбцы:

Schema::create('properties', function (Blueprint $table) {  
	$table->id();  
	$table->string('type', 20);
	$table->unsignedInteger('price');
	$table->text('description');
	$table->timestamps();  
});

Если мы запустим тест снова, то заметим, что он снова прошел, и на этот раз окончательно.

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

Тестирование метода Store

Этот тест мы начнем с создания второго теста в том же файле tests/Feature/Api/PropertiesTest.php

<?php  
  
namespace Tests\Feature\Api;  
  
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;  
  
class PropertiesTest extends TestCase  
{
	use RefreshDatabase;
	
	/** @test */  
	public function can_get_all_properties(){...}
	
	/** @test */
	/** @test */
    public function can_store_a_property()
    {
        // Build a non-persisted Property factory model.
        $newProperty = Property::factory()->make();

        $response = $this->postJson(
            route('api.properties.store'),
            $newProperty->toArray()
        );
        // We assert that we get back a status 201:
        // Resource Created for now.
        $response->assertCreated();
        // Assert that at least one column gets returned from the response
        // in the format we need .
        $response->assertJson([
             'data' => ['type' => $newProperty->type]
         ]);
        // Assert the table properties contains the factory we made.
        $this->assertDatabaseHas(
             'properties', 
             $newProperty->toArray()
         );
    }
}

Вначале кратко резюмируем этот тест:

  • Мы создаем неперсистентную модель Property для использования в качестве запроса пользователя с помощью метода Factory (фабрика): make.

  • Мы делаем post-запрос через API к маршруту route('api.properties.store') с данными запроса.

  • Затем подтверждаем, что получаем в ответ статус-код 201: Resource Created (Ресурс создан)

  • Утверждаем, что мы получили хотя бы один из новых ключей, чтобы проверить, что он пришел в правильном формате.

  • И, наконец, мы утверждаем, что таблица properties содержит новую модель Property.

Запустив тест, мы получаем ошибку:

Symfony\Component\Routing\Exception\RouteNotFoundException : Route [api.properties.store] not defined.

Что означает именно это; не существует маршрута API с именем, подобным вышеуказанному маршруту.

В /routes/api.php:

Route::post(
	'properties', 
	[PropertyController::class, 'store']
)->name('api.properties.store');

Конечно, мы не создали метод store, мы делаем это следующим образом:

<?php  
  
namespace App\Http\Controllers\Api;  
  
use App\Models\Property;  
use Illuminate\Http\Request;  
use Illuminate\Http\JsonResponse;  
use App\Http\Controllers\Controller;  
  
class PropertyController extends Controller  
{  
	public function index() : JsonResponse {...}  

	public function store(Request $request)  
	{ 
		return response()->json([
			'data' => Property::create($request->all())
		], 201); 
	}
}

Это фактически единственное, что нам надо сделать. Но наша следующая ошибка связана с Mass Assignment (массовое назначение), и по сути нам нужно создать свойство protected $fillable = [];, которое содержит имена столбцов, которые вы хотите массово присвоить. Оно выглядит следующим образом:

<?php  
  
namespace App\Models;  
  
use Illuminate\Database\Eloquent\Model;  
use Illuminate\Database\Eloquent\Factories\HasFactory;  
  
class Property extends Model  
{  
 use HasFactory;  
  
 protected $fillable = ['type', 'price', 'description'];  
}

Отлично, теперь мы в результате получили зеленый. Конечно, нам все еще нужно проверить эти свойства при создании, поэтому давайте сделаем это правильно.

Начну с создания "Юнит" теста (или так я его назвал), поскольку меня интересует только утверждение, что я получаю ошибку в результате "создания" или "обновления" Property и намеренно делаю их неудачными; таким образом вы проверяете, что FormRequest внедряется в методы store и update в вашем контроллере.

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

php artisan make:request PropertyRequest

В результате будет создан файл /app/Http/Requests/PropertyRequest.php, откройте его и вы получите этот класс:

<?php  
  
namespace App\Http\Requests;  
  
use Illuminate\Foundation\Http\FormRequest;  
  
class PropertyRequest extends FormRequest  
{  
	 public function authorize()  
	 { 
	 	// Change this to: true
	 	return false;  
	 }  
	 
	 public function rules()  
	 { 
	 	return [  
		 //  
		 ];  
	 }
 }

Здесь вы заметите метод под названием authorize, который возвращает false, вы можете проверить доступ к методу, возвращая true/false, когда установлено определенное правило; если оно возвращает false, метод в контроллере вернет код состояния 403: unauthorized.

Давайте продолжим и создадим наш PropertyRequestTest для проверки правил, которые мы применяем к нашему методу store и, впоследствии, и к update.

php artisan make:test Http/Requests/PropertyRequestTest --unit

Я обычно размещаю свои тесты валидации в tests/Unit/Http/Requests/, чтобы сымитировать расположение контроллера, где он используется. Откройте новый тест:

<?php  
  
namespace Tests\Unit\Http\Requests;  
  
use Tests\TestCase;
  
class PropertyRequestTest extends TestCase  
{  
}

Если заметили, то я изменил одну деталь, прежде чем продолжить, и это определение TestCase, импортируемое по умолчанию. Определение PHPUnit\Framework\TestCase входит по умолчанию в модульные тесты и является  по умолчанию классом PHPUnit, нам нужно заменить его на Laravel's TestCase, расположенный в каталоге tests, чтобы иметь в своем распоряжении утверждения и хелперы Laravel.

Итак, давайте начнем с первого теста, который проверяет, выполняем ли мы правило required:

use RefreshDatabase;  
  
private string $routePrefix = 'api.properties.';  
  
/**  
 * @test  
 * @throws \Throwable  
 */  
public function type_is_required()  
{  
	 $validatedField = 'type';  
	 $brokenRule = null;  
	 
	 $property = Property::factory()->make([  
	 	$validatedField => $brokenRule  
	 ]);  

	 $this->postJson(  
		 route($this->routePrefix . 'store'),  
		 $property->toArray()  
	 )->assertJsonValidationErrors($validatedField);  
}

Итак, это мой способ стандартизации тестов валидации. Мы создаем общий способ репликации правил и просто обновляем переменную $brokenRule, чтобы она содержала значение, которое нарушит правило. Поэтому можно утверждать, что результаты JSON-валидации содержат ошибку. Таким образом, я могу просто копипастить тот же тест и только изменить $validatedField и $brokenRule для новой проверки.

Вот что здесь происходит:

  • Сначала мы создаем переменную валидированного поля, чтобы повторно использовать имя столбца и заменять его в других тестах.

  • Мы создали переменную нарушенного правила, содержащую значение, которое будет инициировать запрос формы.

  • Затем мы создаем неперсистентную модель со значениями, которые нарушат валидацию.

  • Мы делаем POST-запрос, чтобы попытаться создать новое Property (свойство).

  • Мы сразу же утверждаем, что пакет ошибок JSON-валидации включает в себя проверенное поле, подтверждая наличие ошибки, следовательно, наш запрос выполняет свою работу.

Получаем ошибку после выполнения теста:

Failed to find a validation error in the response for key: 'type'

Это означает, что тест не обнаружил ошибку валидации в ответе POST-запроса, который мы сделали, очевидно, что мы не имплементировали FormRequest в PropertyController, давайте поменяем Illuminate\Http\Request на наш PropertyRequest, чтобы метод store выглядел следующим образом:

public function store(PropertyRequest $request) : JsonResponse {...}

Если мы запустим тест, то получим ту же ошибку, но в действительности она некорректна, я объясню это через секунду. Давайте перейдем к нашему тесту type_is_required и добавим метод под названием withoutExceptionHandling, как показано ниже:

/**  
 * @test  
 * @throws \Throwable  
 */  
public function type_is_required()  
{  
 	$this->withoutExceptionHandling();
	
	...
 }

По умолчанию Laravel защищает нас от некоторых исключений, модифицируя ответ на "дружественное" сообщение о такой ошибке. В действительности наш тест не работает, потому что мы реализовали PropertyRequest, который содержит метод authorize, возвращающий false. Этот метод может быть использован для имплементации валидации на права, роли или другое условие, которое, в случае true, позволяет запросу перейти к правилам валидации, если нет, то он выбрасывает код состояния 403, который unauthorized, как описано ранее.

Чтобы исправить это, давайте изменим false на true, так как мы не проверяем здесь никаких прав или условий:

<?php  
  
namespace App\Http\Requests;  
  
use Illuminate\Foundation\Http\FormRequest;  
  
class PropertyRequest extends FormRequest  
{  
	 /**  
	 * Determine if the user is authorized to make this request. * * @return bool  
	 */  
	 public function authorize()  
	 { 
	 	return true;   
	 }
	 
	 ...

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

Первое правило валидации — required, и поскольку мы тестируем колонку type, это будет наша первая валидация:

<?php  
  
namespace App\Http\Requests;  
  
use Illuminate\Foundation\Http\FormRequest;  
  
class PropertyRequest extends FormRequest  
{  
	 public function authorize()  
	 { 
	 	return true;  
	 }  
	 
	 public function rules()  
	 { 
		 return [  
		 	  'type' => ['required'],
		 ];  
	 }
 }

Теперь, если мы запустим тест, то в результате получим зеленый, и наш тест проходит, подтверждая это:

  • Метод store требует, чтобы значение type присутствовало в запросе.

  • В нашем методе store используется FormRequest.

  • Мы немного стандартизировали наш тест проверки, поэтому следующий набор тестов будет проще реализовать.

Теперь я просто последовательно добавлю остальные тесты, поскольку их реализация очень похожа; после этого я покажу и объясню правила валидации, реализованные в FormRequest.

Поскольку мы указали длину столбца type максимум 20 символов, я продолжу и добавлю ту валидацию.

/**  
 * @test  
 * @throws \Throwable  
 */  
public function type_is_required() {...}


/**  
 * @test  
 */  
public function type_must_not_exceed_20_characters()  
{  
	 $validatedField = 'type';  
	 $brokenRule = Str::random(21);  
	 
	 $property = Property::factory()->make([  
	 	$validatedField => $brokenRule  
	 ]);  

	 $this->postJson(  
		 route($this->routePrefix . 'store'),  
		 $property->toArray()  
	 )->assertJsonValidationErrors($validatedField);  
}

Как видите, мы сделали копипаст предыдущего теста и изменили значение $brokenRule на то, которое приведет к сбою, поскольку именно это нам и нужно. $brokenRule становится строкой случайных букв, содержащей 21 символ, что вызывает ошибку валидации. Поскольку мы не добавили правило в FormRequest, тест еще не пройден.

public function rules()  
{  
	 return [  
	 	'type' => ['required', 'max:20']  
	 ];
 }

Мы добавляем правило max, чтобы указать максимальное значение, которое мы ожидаем от поля (оно в данном случае равно 20), и получаем зеленый!

Переходим к следующему тесту, который предназначен для price. Просто скопируем предыдущие тесты и продолжим:

/**  
 * @test  
 * @throws \Throwable  
 */  
public function price_is_required()  
{  
	 $validatedField = 'price';  
	 $brokenRule = null;  
	 
	 $property = Property::factory()->make([  
	 	$validatedField => $brokenRule  
	 ]);  
	 $this->postJson(  
		 route($this->routePrefix . 'store'),  
		 $property->toArray()  
	 )->assertJsonValidationErrors($validatedField);  
}

Мы просто изменили название теста, сопровождаемого значением $validatedField, в виде столбца, который мы валидируем. Как видите, валидация превратилась в лёгкую задачу, поскольку мы просто копипастим наши предыдущие тесты.

Добавляем правило для Price (цена):

public function rules()  
{  
	 return [  
	 	'type' => ['required', 'max:20'],
		'price' => ['required'],
	 ];
 }

Тесты пройдут, поскольку работа уже сделана. Давайте просто быстро выполним недостающие тесты:

/**  
 * @test  
 * @throws \Throwable  
 */  
public function price_must_be_an_integer()  
{  
	 $validatedField = 'price';  
	 $brokenRule = 'not-integer';  
	 
	 $property = Property::factory()->make([  
	 	$validatedField => $brokenRule  
	 ]);  

	 $this->postJson(  
		 route($this->routePrefix . 'store'),  
		 $property->toArray()  
	 )->assertJsonValidationErrors($validatedField);  
}

Правило валидации:

public function rules()  
{  
	 return [  
		 'type' => ['required', 'max:20'],  
		 'price' => ['required', 'integer'],  
	 ];
}

В результате — зеленый. Мы могли бы даже избежать этого теста, добавив Attribute Casting (кастинг атрибутов) для столбца price как integer, но я предпочитаю пока держать валидации вместе.

Поскольку поле description является типом столбца text, я не уверен, что нам нужно это проверять, так как поле text может содержать до 65,535 байт, что вполне достаточно для того, чтобы пользователь мог набрать все, что захочет.

На этом наш метод store протестирован вместе с его валидацией. Увидимся в следующем тесте.

Тестирование метода Update

Мы прошли половину пути. Продолжим с методом Update.

Как видно из предыдущих тестов, через некоторое время все становится довольно просто. Давайте начнем с добавления нашего теста в класс PropertiesTest:

<?php  
  
namespace Tests\Feature\Api;  
  
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;  
  
class PropertiesTest extends TestCase  
{
	use RefreshDatabase;
	
	/** @test */  
	public function can_get_all_properties() {...}
	
	/** @test */  
	public function can_store_a_property() {...}

	/** @test */
	public function can_update_a_property() 
	{
		$existingProperty = Property::factory()->create();  
		$newProperty = Property::factory()->make();  
		  
		$response = $this->putJson(  
			route($this->routePrefix . 'update', $existingProperty),  
			$newProperty->toArray() 
		);  
		$response->assertJson([  
			'data' => [  
				// We keep the ID from the existing Property.
				'id' => $existingProperty->id,  
				// But making sure the title changed.
				'title' => $newProperty->title
			]
		]);  
		  
		$this->assertDatabaseHas(  
			'properties',  
			$newProperty->toArray()  
		);
	}

Запуская наши тесты, мы знаем, что наша первая ошибка связана с несуществующим маршрутом: api.properties.update; давайте быстро добавим его в наш файл routes/api.php:

use App\Http\Controllers\Api\PropertyController; 

Route::put( 
	'properties/{property}', 
	[PropertyController::class, 'update']
)->name('api.properties.update');

Мы уже знаем, что следующая ошибка напомнит нам о том, что метод update не существует:

<?php  
  
namespace App\Http\Controllers\Api;  
  
use App\Models\Property;  
use Illuminate\Http\Request;  
use Illuminate\Http\JsonResponse;  
use App\Http\Controllers\Controller;  
use App\Http\Requests\PropertyRequest;  
  
class PropertyController extends Controller  
{  
	public function index() : JsonResponse {...}  

	public function store(PropertyRequest $request): JsonResponse {...}

	public function update(Request $request, Property $property) 
	{
		return response()->json([
			'data' => tap($property)->update($request->all())
		]);
	}

Метод update принимает Request (запрос) в качестве первого параметра (обратите внимание, я не использовал PropertyRequest, так как мы не реализовали тест) и Property, которое мы обновляем, приходящее из эндпойнт с помощью Route Implicit Binding (неявная привязка маршрута) от Laravel.

Мы также можем отметить метод tap, который, по сути, возвращает элемент, который вы ему передаете, при этом имея возможность связывать с ним методы; в конце он вернет модель после обновления, в отличие от альтернативного варианта:

public function update(Request $request, Property $property): JsonResponse
{
	$property->update($request->all());

	return response()->json([
		'data' => $property
	]);
}

и который тоже является валидным, он просто требует дополнительной строки.

На данном этапе мы должны получить прохождение теста. Нам просто не хватает тестов валидации, давайте сделаем это, вернувшись к нашему файлу tests\Unit\Http\Requests\PropertyRequestTest.php и рассмотрим, как включить выполнение update в наши существующие тесты:

use RefreshDatabase; 

private string $routePrefix = 'api.properties.'; 

/** 
 * @test 
 * @throws \Throwable 
*/ 
public function type_is_required() 
{ 
	$validatedField = 'type'; 
	$brokenRule = null; 
	$property = Property::factory()->make([ 
		$validatedField => $brokenRule 
	]); 

	$this->postJson( 
		route($this->routePrefix . 'store'), 
		$property->toArray() 
	)->assertJsonValidationErrors($validatedField); 

	// Update assertion
	$existingProperty = Property::factory()->create(); 
	$newProperty = Property::factory()->make([ 
		$validatedField => $brokenRule 
	]); 

	$this->putJson(
		route($this->routePrefix . 'update', $existingProperty),  
		$newProperty->toArray()  
	)->assertJsonValidationErrors($validatedField);
}

В блоке утверждения Update мы:

  • Создали существующее Property (свойство), которое мы хотим обновить.

  • Создали неперсистентное фабричное Property с проверяемым полем и значением, которое нарушит тест (в данном случае null).

  • Затем мы сделали PUT-запрос к маршруту api.properties.update, передав существующее Property в качестве параметра, поскольку именно этого ожидает наш маршрут.

  • И мы сделали утверждение для этого запроса, подтверждающее, что мы получили JSON-ошибку .

Единственное, чего не хватает, это повторить тесты для остальных полей, заменив $validatedField и $brokenRule соответственно, и внедрить PropertyRequest в наш новый метод update вместо класса Laravel's Request следующим образом:

public function update(PropertyRequest $request, Property $property) 

И у нас есть пройденный тест.

Отлично, переходим к финальному тесту.

Тестирование метода Destroy

Это будет самый короткий тест, сделанный в этой статье, так как мы просто проверим, что при достижении эндпойнт delete удалим модель Property, а также вернем ответ со статус-кодом 204: No Content (нет контента).

/** @test */
	public function can_update_a_property() {...}

	/** @test */
	public function can_delete_a_property() 
	{
		$existingProperty = Property::factory()->create();

		$this->deleteJson(
			route($this->routePrefix . 'destroy', $existingProperty)
		)->assertNoContent(); 
		// You can also use assertStatus(204) instead of assertNoContent() 
	    // in case you're using a Laravel version that does not have this assertion. 
        // (I believe it is available from v7.x onwards)

		// Finally we just assert the `properties` table does not contain the model that we just deleted.
		$this->assertDatabaseMissing(  
			'properties',  
			$existingProperty->toArray()  
		);
	}

Если мы запустим его, то получим ожидаемую ошибку, которая заключается в том, что у нас нет указанного маршрута, давайте добавим его:

Route::delete( 
	'properties', 
	[PropertyController::class, 'destroy'] 
)->name('api.properties.destroy');

И запустите его снова, чтобы обнаружить, что метод destroy не существует; я добавлю его в наш PropertyController ниже метода update:

public function update(PropertyRequest $request, Property $property) {...}

public function destroy(Property $property)
{
	$property->delete();

	return response([], 204);
}

Держу пари, это пройдет, верно?

Метод destroy довольно прост; мы получаем ожидаемое свойство из Route (маршрута) и, поскольку неявная привязка определила, какую модель Property мы хотим удалить, мы просто запускаем метод delete() на нем, а затем возвращаем ответ.

  • Примечание: я стандартизировал ответы для этой статьи, но вы можете возвращать то, что вам нужно.

Заключение

Хотя я постарался сделать этот пример максимально приближенным к реальности, есть кое-что, что следовало бы сделать по-другому, будь это реальное приложение:

Вместо возврата JSON-ответов, как мы делали, я бы использовал API Resources (ресурсы API). Причина в том, что возможно, мне захочется внести несколько изменений в коллекцию/модель, которую необходимо вернуть, а ресурс API позволит это сделать. Дополнительные примеры можно найти в документации.

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

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

Я предлагаю начать с тестирования небольших фич и следовать подходу TDD как можно чаще. Возможно, вскоре вы решите, что вам нравится сначала кодить, а потом тестировать; на самом деле это не имеет значения, пока вы тестируете свои фичи.

Мы только начинаем изучать TDD, поэтому ожидайте новых статей, подобных этой, в которых будут раскрыты новые темы.


"Laravel 9" вышла в феврале этого года. Релиз содержит достаточно много нововведений, среди которых: поддержка компонентов Symfony 6, Symfony Mailer, Flysystem 3, улучшенный вывод route:list, драйвера Laravel Scout, новый синтаксис аксессор/мутатор Eloquent и различные другие исправления ошибок и улучшения для удобства использования фреймворка. Приглашаем на открытое занятие, на котором постараемся затронуть самые важные из них. Регистрация для всех желающих по ссылке.

Комментарии (1)


  1. EighthMayer
    30.06.2022 13:45
    -1

    Дублировать заголовок картинкой с текстом - плохо.