How to create a REST API on Laravel using Test-Driven Development
As once said James Groening, one of the pioneers of TDD and Agile development methodology:
“If you are not doing development through testing, then later you will do development through debugging”
– James Groening
Today we will embark on a development journey through testing with Laravel. We will create a REST API on Laravel with full authentication functionality and CRUD without opening Postman or a browser.
Note: Today’s walkthrough assumes that you are familiar with the basic concepts in Laravel and Phpunit. If everything is all right, then let’s go!
Project setup
We’ll start by creating a new Laravel project with composer create-project --prefer-dist laravel/laravel tdd-journey
.
Next, to run the authentication scaffolder we need, do php artisan make:auth
, and then php artisan migrate
.
In fact, we will not use the generated paths and views. For this project we use jwt-auth, so add it to your application.
Note: If you encounter errors in the generate JWT command, you can use this fixuntil you get a stable job.
Finally, you can remove ExampleTest in folders tests / unit and tests / featureso that nothing prevents getting the test results, and you can continue!
Writing a code
1. Let’s start by configuring auth
to use the default JWT driver:
[
'guard' => 'api',
'passwords' => 'users',
],
'guards' => [
...
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
],
view rawauth.php hosted with by GitHub
Then add the following to the file routes/api.php
:
'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. Now that we have configured the driver, we need to configure the user model in the same way:
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
Here we have implemented JWTSubject
and added the required methods.
3. Finally, it is time to add authentication methods to the controller.
Run php artisan make:controller AuthController
And add the following methods:
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
At this step, everything is quite simple, since all we do is add methods authenticate and register to our controller. In method authenticate we check the input data, try to log in and return the token if successful. In method register we check the input data, create a new user with the input data and generate a token for the user based on it.
4. Now let’s move on to the nice part. Testing what we just wrote. Generate test classes with php artisan make:test AuthTest
. To new file tests/Feature/AuthTest
add the following methods:
'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
The comments in the code above explain pretty much everything. You need to pay attention to how we create and delete a user in each test. The whole point of tests is that they should be independent of each other and, ideally, of the state of the database.
Now do $vendor/bin/phpunit
or $phpunit
if you have a global installation. You must have successful approvals. If you didn’t succeed, you can look at the logs, fix what went wrong and run the tests again. This is what a good TDD cycle looks like.
5. Now that our authentication works, let’s add an element for CRUD. In our example, we will use the recipes as CRUD elements, because, why not?
Start by creating a migration. php artisan make:migration create_recipes_table
and add the following:
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
Now do the migration. After that add the model with php artisan make:model Recipe
and add it to our model.
belongsTo(User::class);
}
view rawrecipe1.php hosted with by GitHub
Then add this method to the model user
.
hasMany(Recipe::class);
}
view rawuser2.php hosted with by GitHub
6. Now we need endpoints to process our recipes. First, we will create a controller php artisan make:controller RecipeController
. Then, edit the file routes/api.php
and add the end point there create
.
['api','auth'],'prefix' => 'recipe'],function (){
Route::post('create','RecipeController@create')->name('recipe.create');
});
view rawroutes2.php hosted with by GitHub
Also add a method create to controller:
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
Create a test with php artisan make:test RecipeTest
and edit the contents as shown below:
'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
This code also speaks for itself. All we do is create a method that processes user registration and token generation, and then uses that token in the method testCreate()
. Pay attention to use RefreshDatabase
, which is a convenient way to reset the database after each test in Laravel, which is ideal for our small project.
So, for now all we need is the status of the response, go ahead and do $vendor/bin/phpunit
.
If everything goes according to plan, then you will get an error
There was 1 failure:
1) TestsFeatureRecipeTest::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 logs, we will understand that the culprit of the error is the attitude of the publisher and recipes in the classes Recipe and User. Laravel is trying to find a column in a table user_id
and use it as a foreign key, however in our migration we set publisher_id
as a foreign key. Now rewrite a few lines as shown below:
//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 restart the tests. If everything is in order, then the tests will complete successfully!
... 3 / 3 (100%)
...
OK (3 tests, 5 assertions)
However, we still need to test the recipe creation function. To do this, you need to approve the number of user recipes. Update method testCreate
as below:
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
Now we can move on and fill in the rest of the methods. It’s time to change something. To start, routes/api.php
:
['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
Then we add the methods to the controller. Rewrite class RecipeController
in the following way:
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
Comments in the code will help you understand what is written.
And finally test/Feature/RecipeTest
:
'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
In addition to the additional test, the only difference was the addition of a custom file for the entire class. So the method authenticate
not only generates a token, but also configures the user file for subsequent operations.
Now run $ vendor/bin/phpunit
and all the tests you run will be successful if, of course, you did everything right.
Conclusion
I hope this article has given you an idea of how TDD works in Laravel. Of course, this is a much broader concept than binding to a particular method.
Although this development method may seem longer than the usual debugging procedure after writing code, it is ideal for early detection of problems. Of course, there are cases when you can do without an approach to development through testing, but in any case, it is worth getting used to and developing the skill to use it.
You can find all the code for this article on Github. Feel free to play with it. Good luck
On this the translation has come to an end, and we invite you to free webinarwithin which we will tell how create a Telegram bot for ordering coffee at the institution and paying online.