Test-Driven Development (TDD) in Laravel 8
Before we delve into Test-Driven development, let's first define the term TDD.
What is Test-Driven Development (TDD)?
According to Wikipedia, “Test-Driven Development is a software development process relying on software requirements being converted to test cases before the software is fully developed, and tracking all software development by repeatedly testing the software against all test cases”.
It is a software development approach that emphasizes writing bug-free codes by developing test cases to specify and validate what the code will do.
Here is what Test-Driven Development(TDD) cycle looks like:
- Write a test
- Run the test — which will fail
- Write some code
- Run the test again
- Make changes to code to make the test pass(refactor)
- Repeat
How To Write Tests in Laravel
For the purpose of this article, we will create a simple CRUD API to create, read, update and delete blog posts. let's begin by creating a fresh Laravel project using Composer.
Step 1: Create and initialize the project
composer create-project laravel/laravel blogcd blog
You can run the command below to be sure that everything works fine.
php artisan serve
Step 2: Set up your test suite
Update your phpunit.xml file in your root directory. Uncomment these lines of code
<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
The updated file should look like this:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<php>
<server name="APP_ENV" value="testing"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/>
<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
<server name="MAIL_MAILER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
<server name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>
We uncommented or added those lines of code to ensure that PHPUnit uses :memory: database so that tests run faster. Now that our test suite is set up, let’s set up our base test file TestCase.php
<?php
namespace Tests;
use Faker\Factory as Faker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Artisan;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication, RefreshDatabase;
protected $faker;
/**
* Sets up the tests
*/
public function setUp(): void
{
parent::setUp();
$this->faker = Faker::create();
Artisan::call('migrate'); // runs the migration
}
/**
* Rolls back migrations
*/
public function tearDown(): void
{
Artisan::call('migrate:rollback');
parent::tearDown();
}
}
You would notice that we use the RefreshDatabase trait because it is often useful to reset our database after each test so that data from a previous test does not interfere with subsequent tests. You may also notice that we added two methods setUp() and tearDown(). The former is run by the test runner prior to each test and the latter after each test. They help keep your test code clean and flexible.
Step 3: Create and write your test
To create a test file in Laravel 8, simple run this artisan command
php artisan make:test PostTest
The command above creates a PostTest.php file in the tests/Feature directory.
The next thing we need to do is write our actual test.
Note: You will need to either prefix your methods name with test i.e testCanCreateAPost or use the /** @test */ annotation. That way PhpUnit knows that it needs to run your test.
<?php
namespace Tests\Feature;
use Tests\TestCase;
class PostTest extends TestCase
{
/** @test*/
public function canCreateAPost()
{
$data = [
'title' => $this->faker->sentence,
'description' => $this->faker->paragraph
];
$response = $this->json('POST', '/api/v1/posts', $data);
$response->assertStatus(201)
->assertJson(compact('data'));
$this->assertDatabaseHas('posts', [
'title' => $data['title'],
'description' => $data['description']
]); }
}
Step 4: Run your test
Now we need to run our test with PHPUnit as thus:
./vendor/bin/phpunit
or Laravel artisan command which is pretty cool as it provides verbose test reports in order to ease development and debugging:
php artisan test
Oops! Our test FAILED! is this good? Yes, it is in fact fine for our test to fail as it follows the second rule of TDD that it should Fail after creating the test.
So why did our test fail?
The error says the expected response code is 201 — as we have asserted in our test — but received 404 (Not found). This means that the endpoint [POST]‘/api/v1/posts’ does not exist and we need to create one. This brings us to the next step.
Step 5: Make Test Pass
What we will be doing in this step is to make our test pass. First to get rid of the 404 error, let's create an endpoint.
CREATE THE ENDPOINT IN YOUR ROUTE FILE
let’s go to our route file located at ‘routes/api.php’ and create that endpoint. All routes created in this file are automatically prefixed with ‘/api’.
php artisan make:controller --resource
You can either run this command to create a RESTful controller in the /app/Http/Controllers/Api directory or you can create it manually.
Now let’s debug our controller and validate our request
In the store method, where the POST request goes, return a response as thus:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index()
{
//
}
public function create()
{
//
}
public function store(Request $request)
{
return response()
->json([
'message' => 'Post created'
]);
}
public function show($id)
{
//
}
public function edit($id)
{
//
}
public function update(Request $request, $id)
{
//
}
public function destroy($id)
{
//
}
}
We will create a request file containing some validation rules to validate the data going into our database.
php artisan make:request CreatePostRequest
Remember to change false to true in the authorize() method
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreatePostRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'title' => ['required'],
'description' => ['required']
];
}
}
We are going to import and pass our CreatePostRequest to the store method of our controller. The store() method in our controller should now look like this:
public function store(CreatePostRequest $request)
{
return response()
->json([
'message' => 'Post created'
]);
}
Note: Remember to import the CreatePostRequest class.
CREATE YOUR MODEL AND MIGRATION
php artisan make:model Post -m
This artisan command creates our model and migration. The -m flag creates a migration file under database/migrations directory. We will add title and description columns in our migration as thus:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreatePostsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('description');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('posts');
}
}
And in our Model, we have to define the hidden and fillable fields.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use HasFactory;
protected $fillable = [
'title',
'description'
];
}
Once that is done, we will edit our store method as thus:
public function store(CreatePostRequest $request)
{
$data = $request->validated(); // only validated request
$post = Post::create($data); // creates and return post
return response()
->json([
'message' => 'Post created'
]);
}
Note: remember to import the Post class in your controller
RE-RUN TESTS
php artisan test
Our test Failed again but this time it says that it can’t assert that 201 is identical to 200. This means that our endpoint returns a 200 response code instead of a 201. So let’s add a 201 response code — which means that the resource was successfully created
public function store(CreatePostRequest $request)
{
$data = $request->validated(); // only validated request
$post = Post::create($data); // creates and return post
return response()
->json([
'message' => 'Post created'
], 201);
}
let's re-run our test again.
Note: if you get a 500 response code, you can take a look at the log file at /storage/logs/laravel.log.
Now we get another error. It can’t assert that a certain JSON exists, which was defined in our test here
$data = [
'title' => $this->faker->sentence,
'description' => $this->faker->paragraph
];$response = $this->json('POST', '/api/v1/posts', $data);$response->assertStatus(201)
->dump() // use this to know what data is being returned
->assertJson(compact('data')); // this line here
To solve this, we need to return the created resource to assert that it matches the data that is being sent to the endpoint. So, within our store() method will add a ‘data’ object that returns the newly created resource to our payload.
public function store(CreatePostRequest $request)
{
$data = $request->validated(); // only validated request
$post = Post::create($data); // creates and return post
return response()
->json([
'data' => $post,
'message' => 'Post created'
], 201);
}
Once that is done, we will re-run our test.
WELL DONE! You have made the test pass.