“The best TDD can do, is assure that code does what the programmer thinks it should do. That is pretty good BTW.” – James Grenning
Writing tests for your API means, you can automatically test all of your endpoints by running a single command, and be sure that your code does what you expect it to do. TDD (Test Driven Development) is now a norm and developers must get familiar with the techniques involved. Writing proper tests means you can be rest assured that your code is reliable and will give expected results.
Laravel comes with PHPUnit as its default testing suite, which pretty much gets the job done.
In this article, we will be building a simple Laravel API, and write tests for it. The article assumes that you have some basic knowledge with using Laravel framework and writing PHP code.
The following are tools used in this article.
Composer, Laravel, MySQL, Your attention :)
For this project, we will be simultaneously building and testing a simple API to store and retrieve books information. This is the main purpose of Test Driven Development, to build by testing. This API will contain three endpoints, two GET(s), one to get all books information, and another to get information of a single book, and a POST to store a book's information.
From the Laravel documentation, there are a couple of ways to start a new project. If you have laravel installed, you could use the
laravel new project-name
command, or via the
composer create-project project-name
with an optional flag
--prefer-dist=laravel/laravel
This tells composer that the project should be created with the latest version of Laravel available from the github repo.
For this case, I will use the composer command, so my terminal looks like,
Now, we will allow composer install all necessary files and create the project.
After the project has been created we can then move to create the database will we connect our project to. There are a number of ways you could use MySQL DB locally depending on your Operating System. I have MySQL installed on my Fedora Linux machine and I browse the DB with dbeaver or MySQL workbench. Windows users could prefer WAMP or XAMPP (which is cross-platform).
Therefore, we can now create our database for this project. I will name mine book. Keeping it simple.
Back to our project, create a .env file and rename the Database connection and logins to match yours. Make sure you have the application key set. If not you can run
php artisan key:generate
Now, we can quickly handle our migration,
For this API, we only need one migration file, you can delete the defaults. We only need a table to that will store the following information.
| id | name | author | description | published_at | created_at | updated_at
To create the migration file along with its corresponding model, we can use the artisan command,
php artisan make:model Book -m
This will generate a Book model and a create-books-table migration file.
Here is what my migration file looks like.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateBooksTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('books', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('name');
$table->string('author');
$table->text('description');
$table->dateTime('published_at');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('books');
}
}
Now you can the migration file generated to contain the fields specified above, now we can run
php artisan migrate
to have our database ready. After successful migration, you should have the books table in your database. Now, we need to populate our database with some data, which will be useful in testing our API, hence we need to have a Database seeder. To achieve this we can use the artisan command,
php artisan make:seeder BookSeeder
This command will create a new file in the App/database/seeds
folder called BookSeeder
. Edit the seeder file to contain the data you want to insert in the database.
Here is what my seeder looks like.
<?php
use App\Book;
use Illuminate\Database\Seeder;
class BookSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
$rawFile = storage_path('app/books.json');
$jsonString = file_get_contents($rawFile);
$data = json_decode($jsonString, true);
foreach ($data as $datum) {
Book::firstOrcreate([
"name" => $datum['name'],
"author" => $datum['author'],
"description" => $datum['description'],
"published_at" => $datum['published_at']
]);
}
}
}
Add this to the Storage/app folder, this is where the seeder will fetch data from.
//books.json
[
{
"name": "Harry Potter",
"author": "J.K. Rowlins",
"description": "A book about magic and a young warlock's battle for good of all mankind.",
"published_at": "1997-06-26"
},
{
"name": "Things Fall Apart",
"author": "Chinua Achebe",
"description": "Things Fall Apart is a novel written by Nigerian author Chinua Achebe. Published in 1958, its story chronicles pre-colonial life in the south-eastern part of Nigeria and the arrival of the Europeans during the late nineteenth century. ",
"published_at": "1958-1-1"
},
{
"name": "Pride and Prejudice",
"author": "Jane Austen",
"description": "The novel follows the character development of Elizabeth Bennet, the dynamic protagonist of the book, who learns about the repercussions of hasty judgments and eventually comes to appreciate the difference between superficial goodness and actual goodness. A classic piece filled with comedy, its humour lies in its honest depiction of manners, education, marriage and money during the Regency era in Great Britain.",
"published_at": "1813-1-1"
}
]
Now, you can run php artisan db:seed --class=BookSeeder
and our books table should have some data. Like this,
Now, we have our database seeded with data we can proceed to build our endpoints.
We can create our controller with, php artisan make:controller BookController
.
For our BookController, we will have the following methods for our endpoints.
//BookController.php
<?php
namespace App\Http\Controllers;
use App/Book;
use Illuminate\Http\Request;
class BookController extends Controller
{
public function get($id){} //get a single record
public function getAll(){} //get all records
public function store(Request $request){} //store a record
}
We will come back to this. Let's quickly go into our api.php
to define the endpoints.
//api.php
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
Route::get('/books', 'BookController@getAll');
Route::get('/books/{id}', 'BookController@get');
Route::post('/books', 'BookController@store');
Now, because we want tests to guide our development, we can create our test file using php artisan make:test BookTest
, we should have a new file create in, app/tests/Feature with the following content,
//BookTest.php
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\Refresh Database;
use Tests\TestCase;
class BookTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function testBasicTest()
{
$response=$this->get('/');
$response->assertStatus(200);
}
}
Each endpoint will have their own function in this test file. And each test function will begin with the word 'test'. So let's write our first test by modifying the example function.
//BookTest.php
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BookTest extends TestCase
{
use RefreshDatabase;
/**
* Test for getting all books
*
* @return void
*/
public function testCanGetAllProducts()
{
$response = $this->get('/books');
$response = $this->assertStatus(200);
}
}
Notice at the top of the class, I have included use RefreshDatabase
, that feature clears the database before running every test. This is very useful when you want to seed different data in you database before running tests. It is important to note that when you have this feature, you should have another database and another .env file, which should be named .env.testing, when phpunit runs it looks for the .env.testing file to use as its environment configuration, if absent it then uses the .env as default.
For the purpose of this article we will not create a .env.testing, we will use our regular .env with the database we have created, but in live scenario you create a .env.testing with a different database. PHPUnit will run the migrations for you.
Another way is to specify the env variable for database connection in the phpunit.xml file.
Now, Let's run the command, vendor/bin/phpunit
to run our test. Note that this command will run all test files that you have in your project. This might include some example test files.
To run the exact test file we have created we will use, vendor/bin/phpunit tests/Feature/BookTest
.
All things correct, the test above should pass. However, we are yet to have any logic in our BookController
generating this data. It passed because the API call was successful but it doesn't know whether data was actually returned or not. Hence, we need to add more assertions to ensure we receive data from that endpoint, that function then becomes,
//BookTest.php
...
public function testCanGetAllProducts()
{
$response = $this->get('/books');
$response = $this->assertStatus(200);
$responseBody = $response->decodeResponseJson();
$this->assertNotEmpty($responseBody);
}
...
We can now re-run our test and it should fail, with an error like this,
Now, let's add some controller logic to return some data.
//BookController.php
.....
public function getAll()
{
$data = Book:all(); //get all book data from the db
return $data;
}
.......
Now, Let's re-run our test, we should get another failure,
Our controller logic is working and returns data, but its empty because we have no data in our test database. Remember, the use RefreshDatabase
clears up the database for every test.
Hence, to get this to work, we will make an artisan call within the test case to seed our db. So let's modify our testcase.
//BookTest.php
...
public function testCanGetAllProducts()
{
Artisan::call('db:seed --class=BookSeeder'); //seed the db before with hit the endpoint
$response = $this->get('/books');
$response = $this->assertStatus(200);
$responseBody = $response->decodeResponseJson();
$this->assertNotEmpty($responseBody);
}
...
Now let's re-run our test,
Our test now pass! Excited? Let's do more!
Now let's construct test case for other endpoints.
Let's test getting a single record
//BookTest.php
.....
public function testCanGetASingleBook()
{
Artisan::call('db:seed --class=BookSeeder');
$book= Book::first();
$response=$this->get('/api/books/'.$book->id);
$response->assertStatus(200);
$responseBody=$response->decodeResponseJson();
}
......
Let's re run our test, we should see a failure,
Can you guess what needs to be done? Yes, controller logic. So let's go to our BookController.
//BookController.php
.....
public function get($id)
{
$data = Book::where('id', $id)->first();
return $data;
}
.....
Lets, re-run our test,
Test passes! Hooray!
Our API currently has no format, and its a bit difficult to add more assertions. Let create a formatter for our API to give it some structure. I will do this in the BookController. Although better practice would be to have this as a trait in your project that can be called from anywhere.
//BookController.php
....
public function formatApiResponse($message, $status, $data = null, $errors = null)
{
return response()->json([
'message'=>$message,
'data'=>$data,
'errors'=>$errors,
], $status);
}
....
Now we can make changes to our endpoints, and our BookController should look like this, so far,
<?php
namespace App\Http\Controllers;
use App\Book;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class BookController extends Controller
{
public function get($id)
{
$data=Book::where('id', $id)->first();
return $this->formatApiResponse('Book Data Generated Successfully', 200, $data);
}
public function getAll()
{
$data=Book::all();
return $this->formatApiResponse('All Book Data Generated Successfully', 200, $data);
}
public function formatApiResponse($message, $status, $data=null, $errors=null)
{
return response()->json([
'message'=>$message,
'data'=>$data,
'errors'=>$errors,
], $status);
}
}
Now, we can make edits to our test and add more assertions, our BookTest should look like this,
<?php
namespace Tests\Feature;
use App\Book;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;
class BookTest extends TestCase
{
use RefreshDatabase;
/**
* Test to get all books.
*
* @return void
*/
public function testCanGetAllBooks()
{
Artisan::call('db:seed --class=BookSeeder');
$response=$this->get('/api/books');
$response->assertStatus(200);
$response->assertSeeText('All Book Data Generated Successfully');
$responseBody = $response->decodeResponseJson();
$this->assertNotEmpty($responseBody['data']);
}
public function testCanGetSingleBook()
{
Artisan::call('db:seed --class=BookSeeder');
$book=Book::first();
$response=$this->get('/api/books/'.$book->id);
$response->assertStatus(200);
$responseBody=$response->decodeResponseJson();
$this->assertNotEmpty($responseBody['data']);
$response->assertJson([
'message'=>'Book Data Generated Successfully',
'data'=> [
'name'=>$responseBody['data']['name'],
'author'=>$responseBody['data']['author'],
'description'=>$responseBody['data']['description']
]
]);
}
Notice, I have added some more detailed assertions to the existing tests. Let's re-run our tests and see the outcome.
All passed! Let's move!
Now for the last endpoint, which the store, let's add a test case method.
//BookTest.php
....
public function testCanSaveBookInformation()
{
$attributes= [
'name'=>'Beauty and the beast',
'author'=>'Gabrielle-Suzanne de Villeneuve',
'description'=>'A widower merchant lives in a mansion with his twelve children (six sons and six daughters). All his daughters are very beautiful, but the youngest, was named “little beauty” for she was the most gorgeous. She continued to be named “Beauty” ‘till she was a young adult.',
'published_at'=>'1740-1-1'
];
$response=$this->post('/api/books', $attributes);
$response->assertStatus(201);
$responseBody=$response->decodeResponseJson();
$this->assertNotNull($responseBody['data']);
$this->assertDatabaseHas('books', [
'name'=>$attributes['name'],
'author'=>$attributes['author'],
'description'=>$attributes['description'],
'published_at'=>$attributes['published_at']
]);
}
....
Let's run it and see what breaks.
It receives a 200, showing endpoint call was successful, but not a 201, because there was no data being saved. So, let head to our BookController to include the logic for the store method.
//BookController.php
....
public function store(Request $request)
{
try {
$validator=Validator::make($request->all(), [
'name'=>'required | string',
'author'=>'required | string',
'description'=>'required | string',
'published_at'=>'required | date'
]);
if ($validator->fails()) {
return$this->formatApiResponse('Validation error occurred', 422, null, $validator->errors());
}
$data = Book::create([
'name'=>$request->name,
'author'=>$request->author,
'description'=>$request->description,
'published_at'=>$request->published_at
]);
return$this->formatApiResponse('Book Successfully Created', 201, $data);
} catch (Exception $e) {
return$this->formatApiResponse('Error while creating book', 500, null, $e);
}
}
....
Now that we have that in place, let's head back to our test and re-run it.
Yesss! You nailed!
Our Book Controller finally looks like this,
<?php
namespace App\Http\Controllers;
use App\Book;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class BookController extends Controller
{
public function get($id)
{
$data=Book::where('id', $id)->first();
return $this->formatApiResponse('Book Data Generated Successfully', 200, $data);
}
public function getAll()
{
$data=Book::all();
return $this->formatApiResponse('All Book Data Generated Successfully', 200, $data);
}
public function formatApiResponse($message, $status, $data=null, $errors=null)
{
return response()->json([
'message'=>$message,
'data'=>$data,
'errors'=>$errors,
], $status);
}
public function store(Request $request)
{
try {
$validator=Validator::make($request->all(), [
'name'=>'required | string',
'author'=>'required | string',
'description'=>'required | string',
'published_at'=>'required | date'
]);
if ($validator->fails()) {
return$this->formatApiResponse('Validation error occurred', 422, null, $validator->errors());
}
$data = Book::create([
'name'=>$request->name,
'author'=>$request->author,
'description'=>$request->description,
'published_at'=>$request->published_at
]);
return$this->formatApiResponse('Book Successfully Created', 201, $data);
} catch (Exception $e) {
return$this->formatApiResponse('Error while creating book', 500, null, $e);
}
}
}
NOTEWORTHY
When test cases breaks in our test currently by default PHPUnit handles the Exceptions gracefully. Although, it looks neater, it might be so helpful when you cannot see the full error message on your terminal. Nicely though, there a provision to override this, we can use $this->withoutExceptionHandling()
.
Notice that all out test cases start with the word test, this is important as PHPUnit will use it to recognize the methods as a test case. Another way to do this is to specify with a comment above the test case with @test
.
Our final test file looks like this,
<?php
namespace Tests\Feature;
use App\Book;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;
class BookTest extends TestCase
{
use RefreshDatabase;
/**
* Test to get all books.
*
* @return void
*/
public function testCanGetAllBooks()
{
$this->withoutExceptionHandling();
Artisan::call('db:seed --class=BookSeeder');
$response=$this->get('/api/books');
$response->assertStatus(200);
$response->assertSeeText('All Book Data Generated Successfully');
$responseBody = $response->decodeResponseJson();
$this->assertNotEmpty($responseBody['data']);
}
public function testCanGetSingleBook()
{
$this->withoutExceptionHandling();
Artisan::call('db:seed --class=BookSeeder');
$book=Book::first();
$response=$this->get('/api/books/'.$book->id);
$response->assertStatus(200);
$responseBody=$response->decodeResponseJson();
$this->assertNotEmpty($responseBody['data']);
$response->assertJson([
'message'=>'Book Data Generated Successfully',
'data'=> [
'name'=>$responseBody['data']['name'],
'author'=>$responseBody['data']['author'],
'description'=>$responseBody['data']['description']
]
]);
}
public function testCanSaveBookInformation()
{
$this->withoutExceptionHandling();
$attributes= [
'name'=>'Beauty and the beast',
'author'=>'Gabrielle-Suzanne de Villeneuve',
'description'=>'A widower merchant lives in a mansion with his twelve children (six sons and six daughters). All his daughters are very beautiful, but the youngest, was named “little beauty” for she was the most gorgeous. She continued to be named “Beauty” ‘till she was a young adult.',
'published_at'=>'1740-1-1'
];
$response=$this->post('/api/books', $attributes);
$response->assertStatus(201);
$responseBody=$response->decodeResponseJson();
$this->assertNotNull($responseBody['data']);
$this->assertDatabaseHas('books', [
'name'=>$attributes['name'],
'author'=>$attributes['author'],
'description'=>$attributes['description'],
'published_at'=>$attributes['published_at']
]);
}
}
You can get more information about PHPUnit's assertions more details at the PHPUnit Manual, Current version is 8.5, but you can switch the doc's version below the side bar navigation.
Laravel's documentation also cover testing. You should read this to get more insights on how you can easily test some Laravel's advanced functionalities like Jobs, Queues, Notification etc.
This API is also available at this github repo if you want to further play around with it. You can use API clients like POSTMAN to view the structure of the data. Testing is essential and grant you assurance that your API functions properly even without a clicking through your app.
Happy Testing.