Introduction
Test-Driven Development (TDD) is an approach to software development that utilizes a test-first environment where you write tests before writing proper code to fulfill the test and then refactoring. It’s a way to think through what your application is required or designed to do before you write code to build out the functionalities. This article is an introduction to testing Laravel Applications using PHPUnit, the most popular testing framework designed for PHP developers. This tutorial shows you why test-driven development is important in day to day activities as well as how to get started with writing tests in a Laravel application using PHPUnit,
Why you should write tests for your application
Testing is necessary because we all are bound to make mistakes. Some of these mistakes made in development carry no heavy consequences while some tend to be very costly or dangerous if neglected. The advantages of Test Driven Development poses are numerous, here are a few reasons why it is necessary to implement Test-driven development.
- TDD helps avoid errors and bugs when introducing new features in an application
- Project processes are made easier to track in a test-driven environment
- TDD helps ease the process of documenting software
- TDD inspires confidence when writing code
- Bugs are easier to spot and fix in an environment where tests are written.
Prerequisites
This tutorial assumes you have the following:
- Intermediate knowledge of Laravel and web development in general
- A Laravel compatible development environment with Composer installed.
Introducing PHPUnit
PHPUnit is a developer-focused framework for testing applications built with PHP and its numerous frameworks. PHPUnit is built following the xUnit architecture for unit testing frameworks.
Laravel is built with testing in mind and as a result, PHPUnit is a testing utility included by default in a fresh installation of Laravel so there is no need to go through so much of a hassle in setting up a testing environment. All you need to do is ensure the default values (which are usually good to go) are modified to your taste. For example, you might need to have a different database driver for your testing needs.
Getting Started
Our demo Laravel app will be a simple tasks manager API that will allow users to create tasks and delete them as necessary. To get started, create and navigate to a new Laravel project using composer by running:
composer create-project laravel-testing-app cd laravel-testing-app
Configuring a Testing Database
When testing, it is very handy to have a testing database setup as you do not want to run tests against your actual database, and as a result, we will be making use of an in-memory SQLite database that keeps things simple and fast.
To configure SQLite as your testing database, open up the phpunit.xml file and uncomment the following lines of code from the file
... <server name="DB_CONNECTION" value="sqlite"/> <server name="DB_DATABASE" value=":memory:"/> ...
Running Tests
To run tests using PHP unit in your Laravel Application, navigate to your projects root directory and run either the /vendor/bin/phpunit
command or the php artisan test
command. Alternatively, you can create a custom alias (composer test
) by adding "test": "vendor/bin/phpunit"
to the scripts object in your project’s composer.json
file as demonstrated in the code block below.
... "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "test": "vendor/bin/phpunit", "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } ...
When you run the initial tests pre-written by Laravel using the php artisan test
or composer test
command, you get something similar to the screenshot below:
The output above tells us that the two default tests: one unit & one feature test ran and passed successfully.
Let’s dissect any of the ExampleTest.php
files in the units Proceed to delete the ExampleTest.php
files in both the Feature and Unit directories in our tests folder as we will create our own tests shortly.
As explained above, our demo-project is a simple tasks manager app with the following requirements:
- A
/api/tasks/create
route that accepts a POST request to create tasks - A
/api/task{id}/complete
endpoint that marks a given task as complete - A final
/api/task/{id}/delete
endpoint that deletes the given task from the database
Now that we have our requirements clear let’s proceed to write tests accordingly that ensure our API does just that!
Create a test file named EndpointsTest.php
by running:
php artisan make:test InsertionTest
Inside this file, delete the content of the default testBasicTest()
method and add the following method instead.
public function test_that_a_task_can_be_added() { $this->withoutExceptionHandling(); $response = $this->post('/api/tasks/create', [ 'name' => 'Write Article', 'description' => 'Write and publish an article' ]); $response->assertStatus(201); $this->assertTrue(count(Task::all()) > 1); }
Let’s run through what the code above does:
$this->withoutExceptionHandling()
tells PHPUnit not to handle exceptions that we may get. This is to disable Laravel’s exception handling to prevent Laravel from handling exceptions that occur instead of throwing it, we do this so we can get a more detailed error reporting in our test output.
$this->post('/task/create', [])
makes a POST request to the task/create
endpoint with the values given in the array.
$response->assertStatus(201)
instructs PHPUnit to confirm that the HTTP status code returned from the post request is a 201 i.e resource created.
Next, we use the $this->assertTrue()
method to confirm that the task was really saved in the database.
On running the tests with php artisan test
, we get the following feedback as our tests fail as expected.
The tests result (with exception-handling disabled) tells us that our tests fail because a POST request sent to http://localhost/task/create
threw a NotFoundException as the route could not be found.
Let’s fix that by registering the route in our project’s web.php
file.
... Route::group(['prefix' => 'tasks'], function () { Route::get('/{task}','TaskController@show'); Route::post('/create', 'TaskController@create_task'); Route::patch('{task}/complete', 'TaskController@mark_task_as_completed'); Route::delete('/{id}', 'TaskController@destroy'); }); ...
On running the tests further, we get the following error that tells us that the Task
model/class inline 24 could not be found. This is because we have not created the class yet.
Let’s create the Task class and its corresponding migration, factories and controllers by running php artisan make:model
Task -a and then importing the created class by adding ” use App\Models\Task;
” at the top of our InsertionTest.php file.
On running the tests once more, we get a result saying the tasks table could not be found which is expected because we do not have migrations created and run.
Let’s create the table and run our migrations by adding the following migration to the public function up()
method in the create_tasks_table
migration file.
public function up() { Schema::create('tasks', function (Blueprint $table) { $table->id(); $table->string('name'); $table->text('description'); $table->boolean('completed')->default(0); $table->timestamps(); }); }
Next, modify your Models/Task.php
file to allow the name, description, and status fields to become mass assignable by adding them to the fillable array as such:
... class Task extends Model { ... protected $fillable = ['name', 'description', 'status']; } ...
On running the tests we have written so far, you should see them failing as expected
Test that a Task can be deleted
In order to test if a task can be deleted, we will create some dummy tasks and attempt to delete them from the database. We can create dummy tasks using laravel factories as seen below.
// TaskFactory.php public function definition() { return [ 'name' => $this->faker->sentence(4), 'description' => $this->faker->sentence(20), 'completed' => random_int(0, 1) ]; }
Our test should look like this
public function test_that_a_task_can_be_deleted() { $this->withoutExceptionHandling(); Task::factory()->times(5)->create(); // create 5 tasks using the factory setup above $id_to_be_deleted = random_int(1, 5); // select a random task $this->delete("/api/tasks/$id_to_be_deleted/"); // send a request to delete it $this->assertDatabaseMissing('tasks', ['id' => $id_to_be_deleted]); // assert that the task deleted previouly does not exist in the database anymore. }
As expected, the test fails. Let’s write the corresponding code to pass the test
// TaskController.php public function destroy($task) { $task_to_be_deleted = Task::findOrFail($task); $task_to_be_deleted->delete(); return response()->json([ 'message' => 'task deleted successfully' ], 410); }
Testing that a Task can be marked as completed
In order to test that tasks can be marked as completed, we need to create a test task and then make a PATCH request to the endpoint responsible for that.
public function test_that_a_task_can_be_completed(){ $this->withoutExceptionHandling(); $task_id = Task::create([ 'name' => 'Example Name', 'description' => 'Demo dscription' ])->id; // create a task and store the id in the $task_id variable $response = $this->patch("/api/tasks/$task_id/complete"); //sends a patch request in order to complete the created task $this->assertTrue(Task::findOrFail($task_id)->is_completed() == 1); // assert that the task is now marked as completed in the database $response->assertJson([ 'message' => 'task successfully marked as updated' ], true); // ensure that the JSON response recieved contains the message specified $response->assertStatus(200); // furthe ensures that a 200 response code is recieved from the patch request }
As expected, our tests fail, add the following methods to the Task model so our model is able to mark tasks as completed.
/ App/Models/Task.php ... public function mark_task_as_completed() { $this->completed = 1; $this->save(); } public function is_completed() { return $this->completed; } ...
Lastly, add the corresponding code in the controller:
// App/Http/Controllers/TaskController.php ... public function mark_task_as_completed(Task $task) { $task->mark_task_as_completed(); //calls the mark_task_as_complete method we created in the App/Models/Task.php file return response()->json([ 'message' => 'task successfully marked as updated' ], 200); //send a json response with the 200 status code } } ...
When we run our tests now, we should get them all passing as expected. If an update is made to the codebase, tests should be written and run to ensure our application is still intact.
Further Reading:
We sure have covered a lot in this walkthrough. We got started by getting our project up and running using composer. Then, we configured PHPUnit to use a local SQLIte file for database related tests. Finally, we wrote our own test to make sure our API would work the way we expected.
While we covered a lot, this is just the tip of the PHPUnit iceberg. To continue learning about testing Laravel application, I recommend that you check out Jeffery Way’s Testing Laravel course on Laracasts. Another wonderful resource is the Laravel testing docs.
If you’d like to learn more about using PHPUnit, feel free to check out the official docs as well as a cheat-sheet of common PHPUnit utilities and functions here.