Как создать REST API на Laravel с помощью Test-Driven Development

от автора

Салют, хабровчане! Мы продолжаем делиться полезным материалом в преддверии старта курса «Framework Laravel». Поехали.


Как однажды сказал Джеймс Гренинг, один из пионеров TDD и методологии разработки Agile:

«Если вы не занимаетесь разработкой через тестирование, то позже вы займетесь разработкой через отладку»

– Джеймс Гренинг

Сегодня мы отправимся в путешествие по разработке через тестирование с Laravel. Мы создадим REST API на Laravel с полным функционалом аутентификации и CRUD не открывая Postman или браузер.

Примечание: Сегодняшнее пошаговое руководство предполагает, что вы знакомы с основными понятиями в Laravel и PHPUnit. Если все в порядке, тогда поехали!

Настройка проекта

Начнем мы с создания нового проекта Laravel с помощью composer create-project --prefer-dist laravel/laravel tdd-journey.

Далее, чтобы запустить скаффолдер аутентификации, который нам понадобится, выполните php artisan make:auth, а затем php artisan migrate.

На самом деле мы не будем пользоваться сгенерированными путями и представлениями. Для этого проекта мы используем jwt-auth, поэтому добавьте его в свое приложение.

Примечание: Если у вас возникли ошибки в команде generate JWT, вы можете использовать этот фикс, пока не добьетесь стабильной работы.

Наконец, вы можете удалить ExampleTest в папках tests/Unit и tests/Feature, чтобы ничто не мешало получить результаты тестирования, и можно продолжать!

Пишем код

1. Начнем с настройки конфигурации auth, чтобы использовать драйвер JWT по умолчанию:

<?php  // config/auth.php file 'defaults' => [     'guard' => 'api',     'passwords' => 'users', ],  'guards' => [     ...     'api' => [         'driver' => 'jwt',         'provider' => 'users',     ], ], view rawauth.php hosted with  by GitHub

Затем добавьте следующее в файл routes/api.php:

<?php Route::group(['middleware' => 'api', 'prefix' => 'auth'], function () {      Route::post('authenticate', 'AuthController@authenticate')->name('api.authenticate');     Route::post('register', 'AuthController@register')->name('api.register'); }); view rawapi1.php hosted with  by GitHub

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

<?php ... class User extends Authenticatable implements JWTSubject {     ...      //Get the identifier that will be stored in the subject claim of the JWT.     public function getJWTIdentifier()     {         return $this->getKey();     }     // Return a key value array, containing any custom claims to be           added to the JWT.     public function getJWTCustomClaims()     {         return [];     } } view rawUser1.php hosted with  by GitHub

Здесь мы реализовали JWTSubject и добавили требуемые методы.

3. Наконец настало время добавить методы аутентификации в контроллер.
Выполните php artisan make:controller AuthController И добавьте следующие методы:

<?php ... class AuthController extends Controller {          public function authenticate(Request $request){         //Validate fields         $this->validate($request,['email' => 'required|email','password'=> 'required']);         //Attempt validation         $credentials = $request->only(['email','password']);         if (! $token = auth()->attempt($credentials)) {             return response()->json(['error' => 'Incorrect credentials'], 401);         }         return response()->json(compact('token'));     }     public function register(Request $request){         //Validate fields         $this->validate($request,[             'email' => 'required|email|max:255|unique:users',             'name' => 'required|max:255',             'password' => 'required|min:8|confirmed',         ]);         //Create user, generate token and return         $user =  User::create([             'name' => $request->input('name'),             'email' => $request->input('email'),             'password' => Hash::make($request->input('password')),         ]);         $token = JWTAuth::fromUser($user);         return response()->json(compact('token'));     } } view rawAuthcontroller1.php hosted with  by GitHub

На этом шаге все довольно просто, поскольку все, что мы делаем, это добавляем методы authenticate и register к нашему контроллеру. В методе authenticate мы проверяем входные данные, пытаемся залогиниться и вернуть токен при успешном выполнении. В методе register мы проверяем входные данные, создаем нового пользователя с входными данными и генерируем токен для пользователя на их основе.

4. Теперь перейдем к приятной части. Протестируем то, что мы только что написали. Сгенерируйте тестовые классы с помощью php artisan make:test AuthTest. В новый файл tests/Feature/AuthTest добавьте следующие методы:

<?php  /**  * @test   * Test registration  */ public function testRegister(){     //User's data     $data = [         'email' => 'test@gmail.com',         'name' => 'Test',         'password' => 'secret1234',         'password_confirmation' => 'secret1234',     ];     //Send post request     $response = $this->json('POST',route('api.register'),$data);     //Assert it was successful     $response->assertStatus(200);     //Assert we received a token     $this->assertArrayHasKey('token',$response->json());     //Delete data     User::where('email','test@gmail.com')->delete(); } /**  * @test  * Test login  */ public function testLogin() {     //Create user     User::create([         'name' => 'test',         'email'=>'test@gmail.com',         'password' => bcrypt('secret1234')     ]);     //attempt login     $response = $this->json('POST',route('api.authenticate'),[         'email' => 'test@gmail.com',         'password' => 'secret1234',     ]);     //Assert it was successful and a token was received     $response->assertStatus(200);     $this->assertArrayHasKey('token',$response->json());     //Delete the user     User::where('email','test@gmail.com')->delete(); } view rawAuthTest1.php hosted with  by GitHub

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

Теперь выполните $vendor/bin/phpunit или $phpunit, если у вас глобальная установка. У вас должны появиться успешные утверждения. Если у вас не получилось, вы можете посмотреть логи, исправить то, что пошло не так и запустить тесты снова. Так выглядит хороший цикл TDD.

5. Теперь, когда наша аутентификация работает, давайте добавим элемент для CRUD. В нашем примере мы будем использовать рецепты блюд в качестве элементов CRUD, потому что, почему бы и нет?

Начните с создания миграции php artisan make:migration create_recipes_table и добавьте следующее:

<?php  ... public function up() {     Schema::create('recipes', function (Blueprint $table) {         $table->increments('id');         $table->string('title');         $table->text('procedure')->nullable();         $table->tinyInteger('publisher_id')->nullable();         $table->timestamps();     }); }  public function down() {     Schema::dropIfExists('recipes'); } view rawmigration.php hosted with  by GitHub

Теперь выполните миграцию. После этого добавьте модель с помощью php artisan make:model Recipe и добавьте ее к нашей модели.

<?php  ... protected $fillable = ['title','procedure'];  /**  * The owner of this delicious recipe  * @return \Illuminate\Database\Eloquent\Relations\BelongsTo  */ public function publisher(){     return $this->belongsTo(User::class); } view rawrecipe1.php hosted with  by GitHub

Затем добавьте этот метод в модель user.

<?php ...   /**  * Get all recipes  * @return \Illuminate\Database\Eloquent\Relations\HasMany  */ public function recipes(){     return $this->hasMany(Recipe::class); } view rawuser2.php hosted with  by GitHub

6. Теперь нам нужны конечные точки для обработки наших рецептов. Для начала мы создадим контроллер php artisan make:controller RecipeController. Затем, отредактируем файл routes/api.php и добавим туда конечную точку create.

<?php  ...   Route::group(['middleware' => ['api','auth'],'prefix' => 'recipe'],function (){     Route::post('create','RecipeController@create')->name('recipe.create'); }); view rawroutes2.php hosted with  by GitHub

Также добавим метод create в контроллер:

<?php  ...   public function create(Request $request){     //Validate     $this->validate($request,['title' => 'required','procedure' => 'required|min:8']);     //Create recipe and attach to user     $user = Auth::user();     $recipe = Recipe::create($request->only(['title','procedure']));     $user->recipes()->save($recipe);     //Return json of recipe     return $recipe->toJson(); } view rawRecipeController.php hosted with  by GitHub

Создайте тест с помощью php artisan make:test RecipeTest и отредактируйте содержимое как показано ниже:

<?php  ... class RecipeTest extends TestCase {     use RefreshDatabase;     ...     //Create user and authenticate the user     protected function authenticate(){         $user = User::create([             'name' => 'test',             'email' => 'test@gmail.com',             'password' => Hash::make('secret1234'),         ]);         $token = JWTAuth::fromUser($user);         return $token;     }         public function testCreate()     {         //Get token         $token = $this->authenticate();          $response = $this->withHeaders([             'Authorization' => 'Bearer '. $token,         ])->json('POST',route('recipe.create'),[             'title' => 'Jollof Rice',             'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!'         ]);         $response->assertStatus(200);     } } view rawRecipeTest.php hosted with  by GitHub

Этот код тоже говорит сам за себя. Все, что мы делаем, — это создаем метод, который обрабатывает регистрацию пользователя и генерацию токена, а затем использует этот токен в методе testCreate(). Обратите внимание на использование RefreshDatabase, которая является удобным способом сброса базы данных после каждого теста в Laravel, что идеально подходит для нашего маленького проекта.

Итак, пока все, что нам нужно – это статус ответа, идите дальше и выполните $vendor/bin/phpunit.

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

There was 1 failure: 1) Tests\Feature\RecipeTest::testCreate Expected status code 200 but received 500. Failed asserting that false is true. /home/user/sites/tdd-journey/vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestResponse.php:133 /home/user/sites/tdd-journey/tests/Feature/RecipeTest.php:49 FAILURES! Tests: 3, Assertions: 5, Failures: 1.

Взглянув на логи, мы поймем, что виновником ошибки является отношение издателя и рецептов в классах Recipe и User. Laravel пытается найти в таблице столбец user_id и использовать его в качестве внешнего ключа, однако в своей миграции мы установили publisher_id как внешний ключ. Теперь перепишите несколько строк, как показано ниже:

//Recipe file public function publisher(){     return $this->belongsTo(User::class,'publisher_id'); } //User file public function recipes(){     return $this->hasMany(Recipe::class,'publisher_id'); }

А затем перезапустите тесты. Если все в порядке, то тесты завершатся успешно!

...                                                                 3 / 3 (100%) ... OK (3 tests, 5 assertions)

Однако нам все еще надо протестировать функцию создания рецепта. Для этого нужно утверждать количество рецептов пользователя. Обновите метод testCreate, как показано ниже:

<?php ... //Get token $token = $this->authenticate();  $response = $this->withHeaders([     'Authorization' => 'Bearer '. $token, ])->json('POST',route('recipe.create'),[     'title' => 'Jollof Rice',     'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!' ]); $response->assertStatus(200); //Get count and assert $count = User::where('email','test@gmail.com')->first()->recipes()->count(); $this->assertEquals(1,$count); view rawRecipeTest.php hosted with  by GitHub

Теперь мы можем продвинуться дальше и заполнить остальные методы. Настало время кое-что поменять. Для начала, routes/api.php:

<?php ... Route::group(['middleware' => ['api','auth'],'prefix' => 'recipe'],function (){     Route::post('create','RecipeController@create')->name('recipe.create');     Route::get('all','RecipeController@all')->name('recipe.all');     Route::post('update/{recipe}','RecipeController@update')->name('recipe.update');     Route::get('show/{recipe}','RecipeController@show')->name('recipe.show');     Route::post('delete/{recipe}','RecipeController@delete')->name('recipe.delete'); }); view rawapi.php hosted with  by GitHub

Затем мы добавим методы к контроллеру. Перепишите класс RecipeController следующим образом:

<?php  .... //Create recipe public function create(Request $request){     //Validate     $this->validate($request,['title' => 'required','procedure' => 'required|min:8']);      //Create recipe and attach to user     $user = Auth::user();     $recipe = Recipe::create($request->only(['title','procedure']));     $user->recipes()->save($recipe);      //Return json of recipe     return $recipe->toJson(); }  //Get all recipes public function all(){     return Auth::user()->recipes; } //Update a recipe public function update(Request $request, Recipe $recipe){     //Check is user is the owner of the recipe     if($recipe->publisher_id != Auth::id()){         abort(404);         return;     }     //Update and return     $recipe->update($request->only('title','procedure'));     return $recipe->toJson(); } //Show a single recipe's details public function show(Recipe $recipe){     if($recipe->publisher_id != Auth::id()){         abort(404);         return;     }     return $recipe->toJson(); } //Delete a recipe public function delete(Recipe $recipe){     if($recipe->publisher_id != Auth::id()){         abort(404);         return;     }     $recipe->delete(); } view rawRecipeController.php hosted with  by GitHub

Комментарии в коде помогут вам понять написанное.
Ну и наконец test/Feature/RecipeTest:

<?php ...   use RefreshDatabase;  protected $user;  //Create a user and authenticate him protected function authenticate(){     $user = User::create([         'name' => 'test',         'email' => 'test@gmail.com',         'password' => Hash::make('secret1234'),     ]);     $this->user = $user;     $token = JWTAuth::fromUser($user);     return $token; } //Test the create route public function testCreate() {     //Get token     $token = $this->authenticate();      $response = $this->withHeaders([         'Authorization' => 'Bearer '. $token,     ])->json('POST',route('recipe.create'),[         'title' => 'Jollof Rice',         'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!'     ]);     $response->assertStatus(200);     //Get count and assert     $count = $this->user->recipes()->count();     $this->assertEquals(1,$count); } //Test the display all routes public function testAll(){     //Authenticate and attach recipe to user     $token = $this->authenticate();     $recipe = Recipe::create([         'title' => 'Jollof Rice',         'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!'     ]);     $this->user->recipes()->save($recipe);      //call route and assert response     $response = $this->withHeaders([         'Authorization' => 'Bearer '. $token,     ])->json('GET',route('recipe.all'));     $response->assertStatus(200);      //Assert the count is 1 and the title of the first item correlates     $this->assertEquals(1,count($response->json()));     $this->assertEquals('Jollof Rice',$response->json()[0]['title']); } //Test the update route public function testUpdate(){     $token = $this->authenticate();     $recipe = Recipe::create([         'title' => 'Jollof Rice',         'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!'     ]);     $this->user->recipes()->save($recipe);      //call route and assert response     $response = $this->withHeaders([         'Authorization' => 'Bearer '. $token,     ])->json('POST',route('recipe.update',['recipe' => $recipe->id]),[         'title' => 'Rice',     ]);     $response->assertStatus(200);      //Assert title is the new title     $this->assertEquals('Rice',$this->user->recipes()->first()->title); } //Test the single show route public function testShow(){     $token = $this->authenticate();     $recipe = Recipe::create([         'title' => 'Jollof Rice',         'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!'     ]);     $this->user->recipes()->save($recipe);     $response = $this->withHeaders([         'Authorization' => 'Bearer '. $token,     ])->json('GET',route('recipe.show',['recipe' => $recipe->id]));     $response->assertStatus(200);      //Assert title is correct     $this->assertEquals('Jollof Rice',$response->json()['title']); } //Test the delete route public function testDelete(){     $token = $this->authenticate();     $recipe = Recipe::create([         'title' => 'Jollof Rice',         'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!'     ]);     $this->user->recipes()->save($recipe);      $response = $this->withHeaders([         'Authorization' => 'Bearer '. $token,     ])->json('POST',route('recipe.delete',['recipe' => $recipe->id]));     $response->assertStatus(200);      //Assert there are no recipes     $this->assertEquals(0,$this->user->recipes()->count()); } view rawRecipeTest.php hosted with  by GitHub

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

Теперь запустите $ vendor/bin/phpunit и все тесты у вас выполнятся успешно в случае, если, конечно же, вы все сделали правильно.

Заключение

Надеюсь, эта статья дала вам представление о том, как TDD работает в Laravel. Конечно же, это гораздо боле широкое понятие, чем привязка к определенному методу.

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

Весь код этой статьи вы можете найти на GitHub. Не стесняйтесь играться с ним. Удачи!

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

ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/485066/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *