Салют, хабровчане! Мы продолжаем делиться полезным материалом в преддверии старта курса «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',
    ],
],

Затем добавьте следующее в файл 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 [];
    }
}

Здесь мы реализовали 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'));
    }
}

На этом шаге все довольно просто, поскольку все, что мы делаем, это добавляем методы 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();
}

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

Теперь выполните $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');
}

Теперь выполните миграцию. После этого добавьте модель с помощью 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);
}

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

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

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');
});

Также добавим метод 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();
}

Создайте тест с помощью 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);
    }
}

Этот код тоже говорит сам за себя. Все, что мы делаем, — это создаем метод, который обрабатывает регистрацию пользователя и генерацию токена, а затем использует этот токен в методе 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);

Теперь мы можем продвинуться дальше и заполнить остальные методы. Настало время кое-что поменять. Для начала, 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');
});

Затем мы добавим методы к контроллеру. Перепишите класс 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();
}

Комментарии в коде помогут вам понять написанное.
Ну и наконец 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());
}

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

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

Заключение


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

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

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

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