Skip to main content

How to build a Laravel REST API with Test-Driven Development

There is a famous quote by James Grenning, one of the pioneers in TDD and Agile development methodologies:

If you’re not doing test-driven development, you’re doing debug-later development - James Grenning

Test-Driven Development

Today we’ll be going on a Laravel journey driven by tests. We’ll create a Laravel REST API complete with authentication and CRUD functionality without opening Postman or a browser. 😲

Note: This walkthrough assumes that you understand the basic concepts of Laravel and PHPUnit. If you’ve got that out of the way? Let’s drive.

Start by creating a new Laravel project with composer create-project --prefer-dist laravel/laravel tdd-journey.

Next, we need to run the authentication scaffolder that we would use, go ahead and run php artisan make:auth then php artisan migrate.

We will not actually be using the routes and views generated. For this project, we would be using jwt-auth. So go ahead and set it up in your application.

Note: If you’re having errors with JWT’s generate command, you can follow this fix till it’s been added to the stable release.

Finally, you can delete ExampleTest in both the tests/Unit and tests/Feature folders so that it doesn’t interfere with our test results and we’re good to go.

  1. Begin by setting your auth configuration to use the JWT driver as default:


// config/auth.php file

'defaults' => [

'guard' => 'api',

'passwords' => 'users',

],


'guards' => [

...

'api' => [

'driver' => 'jwt',

'provider' => 'users',

],

],

 Then add the following to your routes/api.php file:



Route::group(['middleware' => 'api', 'prefix' => 'auth'], function () {


Route::post('authenticate', 'AuthController@authenticate')->name('api.authenticate');

Route::post('register', 'AuthController@register')->name('api.register');

});

2. Now that we have our driver set up, set up your user model in the same way:





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 [];

}

}

What we did was that we just implemented the JWTSubject and added the required methods.

3. Next, we need to add our authentication methods in the controller.

Run php artisan make:controller AuthController and add the following methods:





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

}

}

This step is pretty straight forward, all we do is add the authenticate and register methods to our controller. In the authenticate method, we validate the input, attempt a login and return the token if successful. In the register method, we validate the input, create a new user with the input and generate a token for the user based on that.

4. Next, onto the good part. Testing what we just wrote. Generate the test classes using php artisan make:test AuthTest. In the new tests/Feature/AuthTest add these methods:



/**

* @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();

}

The comments in the code above pretty much describes the code. One thing you should note is how we create and delete the user in each test. The whole point of tests are that they should be independent of each other and the database state ideally.

Now run $vendor/bin/phpunit or $ phpunit if you have it globally installed. Running that should give you successful assertions. If that was not the case, you can look through the logs, fix and retest. This is the beautiful cycle of TDD.

5. Now that we have our authentication working, let’s add the item for the CRUD. For this tutorial, we’re going to use food recipes as our CRUD items, because, why not?

Start by creating our migration php artisan make:migration create_recipes_table and add the following:




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

}

Then run the migration. Now add the model using php artisan make:model Recipe and add this to our model.





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

}

Then add this method to the user model.





/**

* Get all recipes

* @return \Illuminate\Database\Eloquent\Relations\HasMany

*/

public function recipes(){

return $this->hasMany(Recipe::class);

}

6. Now we need endpoints for managing our recipes. First, we’ll create the controller php artisan make:controller RecipeController. Next, edit the routes/api.php file and add the create endpoint.





Route::group(['middleware' => ['api','auth'],'prefix' => 'recipe'],function (){

Route::post('create','RecipeController@create')->name('recipe.create');

});

In the controller, add the create method as well





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();

}

Generate the feature test with php artisan make:test RecipeTest and edit the contents as under:





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

}

}

The code is quite self-explanatory. All we do is create a method that handles the registering of a user and token generation, then we use that token in the testCreate() method. Note the use of the RefreshDatabase trait, the trait is Laravel’s convenient way of resetting your database after each test, which is perfect for our nifty little project.

OK, so for now, all we want to assert is the status of the response, go ahead and run $ vendor/bin/phpunit.

If all goes well, you should receive an error. 😆

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.

Looking at the log files, we can see the culprit is the publisher and recipes relationship in the Recipe and User classes. Laravel tries to find a user_id column in the table and use that as the foreign key, but in our migration we set publisher_id as the foreign key. Now, adjust the lines as under:

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

And then re-run the test. If all goes well we get all green tests! 👍

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

Now we still need to test the creation of the recipe. To do that we can assert the recipes count of the user. Update your testCreate method as under:





//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);

We can now go ahead and fill the rest of our methods. Time for some changes. First, our routes/api.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');

});

Next, we add the methods to the controller. Update your RecipeController class this way.





//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();

}

The code and comments already explain the logic to a good degree.

Lastly our test/Feature/RecipeTest





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());

}

Other than the additional test, the only other difference was adding a class-wide user file. That way, the authenticate method not only generates a token, but it sets the user file for subsequent operations.

Now run $ vendor/bin/phpunit and you should have all green tests if done correctly.

Hopefully, this gave you an insight into how TDD works in Laravel. It is definitely a much wider concept than this, one that is not bound to a specific method.

Though this method of development may seem longer than the usual debug later procedure, it’s perfect for catching errors early on in your code. Though there are cases where a non-TDD approach is more useful, it’s still a solid skill and habit to get used to.

Comments

Popular posts from this blog

Laravel: The hidden SetCacheHeaders Middleware

There are a lot of useful middlewares already registered inside Laravel, like the   authentication mechanisms ,   authorization , a   throttler , and even the one responsible to make   route model binding   possible. Practically, your middleware needs are covered. Except for one. There is one middleware that doesn’t get the spotlight in Laravel. It’s called   SetCacheHeaders , which is aliased as   cache.headers . And, there is no mention of it in the documentation. I’m not joking, guys: Who, when, how? A quick look around in the   api documentation   and the   source repository   says this middleware was added   added before christmas of 2017   after   a lengthy discussion , but it's a mystery why nobody has talked about it since it’s seems very…   convenient . In any case, after making this article, my   PR to the documentation   was accepted, so   you should find it now in the documentation . A ...