Feature Testing in Laravel — Beginner’s Guide

Zubair Idris Aweda
11 min readJan 7, 2023

Laravel is a framework built with testing in mind, as it ships with PHPUnit. It even includes phpunit.xml, a configuration file for your tests.

It also comes with a tests directory with the sub-directories Feature and Unit. These directories will house your feature tests and units tests, respectively.

These directories also include an ExampleTest.php test file, to show you how testing works in Laravel.

You can use unit tests to ensure that the smallest parts of your application work. These tests are usually focused on single methods. You can learn more about unit testing in PHP by reading “ How to Test PHP Code With PHPUnit”.

To go through this article here, you should know the basics of Laravel (like routing and controllers). This article will focus solely on writing feature tests.

What is Feature Testing?

Most of the tests you’ll be writing as a Laravel developer are feature tests. These tests focus on how objects work together, or even endpoints. You use them to test entire features of your applications.

How to Run Feature Tests

You can run feature tests using the artisan test command, like this:

$ php artisan test

Alternatively, since Laravel uses PHPUnit under the hood to perform its tests, you can run a test the same way it would be run in any other PHP project using PHPUnit.

Run the binary file installed in your vendor folder:

$ ./vendor/bin/phpunit --verbose tests

Also, Laravel already configures the tests directory in the phpunit.xml file. So, you can run the test without specifying the directory.

$ ./vendor/bin/phpunit --verbose

Creating a fresh application and running the tests would look like this:

$ laravel new laravel-testing 
$ cd laravel-testing
$ php artisan test
Laravel Example Tests

Look at the ExampleTest.php test file in the Featuredirectory. Notice that it makes a request to the root route of the application.

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function test_example()
{
$response = $this->get('/');

$response->assertStatus(200);
}
}

The test ran, and returned success, even when the server wasn’t started yet. This is because Laravel understands that feature tests require various components of the application to be in place. So, it boots the application to test. The test now has access to application features like the database, cache, and others.

How to Run Feature Tests in Parallel

Typically, Laravel runs the tests one at a time. But you can decide to run multiple tests at the same time (in parallel). This greatly reduces the time it takes to run these tests, as you don’t have to wait for one to complete before running the next. It also uses multiple processes.

To run these tests in parallel, you need to have version ^5.3 or greater of the nunomaduro/collision package. Run the tests in parallel by adding the --parallel flag to the previous commands:

$ php artisan test --parallel

If the nunomaduro/collision package is not found, you'll be prompted to install it.

Running tests in parallel

By default, Laravel will create as many processes as there are available CPU cores on your machine. However, you may adjust the number of processes by using the --processes flag:

$ php artisan test --parallel --processes=4

To learn more about testing in parallel, visit the official Laravel Documentation.

How to Set Up Your Testing Environment

As the Lavavel docs explain, when a test is run, Laravel automatically changes the environment configuration from development to testing. This setting is defined in the phpunit.xml file, along with other configurations.

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

Notice that it also sets the CACHE_DRIVER and SESSION_DRIVER to array. This means that no cached data or data stored in session will be persisted after the test. You may also add your own configurations to this file.

Alternatively, you could create a .env.testing file. This file will be used, by default, instead of the main .env file when testing.

How to Build the Application to Test

For the purposes of this article, we’ll create a simple blog application with basic CRUD functionalities, and then we’ll test it.

Create an API Resource Controller

$ php artisan make:controller BlogController --api

Make a Model and Migration

$ php artisan make:model Post -m

Update the newly created create_posts_table migration file with this content:

public function up() 
{
Schema::create('posts', function (Blueprint $table) {
$table->id(); $table->string('title');
$table->text('body');
$table->timestamps();
});
}

Run Migrations

$ php artisan migrate

Configure Routes And Controller

Use a resource route to get the CRUD routes. Update the api.php routes file with the following and the BlogController.php file with the following:

// Import Controller 
use App\Http\Controllers\BlogController;

Route::resource('blog', BlogController::class);
<?php 

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class BlogController extends Controller
{
/**
* Display a listing of the resource.
*
* @return Collection|Post[]
*/
public function index()
{
return Post::all();
}

/**
* Store a newly created resource in storage.
*
* @param Request $request
* @return Post
*/
public function store(Request $request): Post
{
$fields = $request->validate([
'title' => ['string', 'required'],
'body' => ['string', 'required']
]);

return Post::create($fields);
}

/**
* Display the specified resource.
*
* @param int $id
* @return Post
*/
public function show(int $id): Post
{
return Post::find($id);
}

/** * Update the specified resource in storage.
*
* @param Request $request
* @param int $id
* @return Response
*/
public function update(Request $request, int $id)
{
$fields = $request->validate([
'title' => ['string', 'required'],
'body' => ['string', 'required']
]);

$post = Post::find($id);
$post->update($fields);

return $post;
}

/**
* Remove the specified resource from storage.
*
* @param int $id
* @return Response
*/
public function destroy(int $id)
{
$post = Post::find($id);
$post->delete();

return $post;
}
}

Start Application

$ php artisan serve

Now, you can test the created endpoints using postman.

This is not exactly how it would work in a real application. This is just a sketch, for learning purposes.

Some developers argue that tests should be written before code. Others do not agree. Here, I build the application before testing, so we already know what we’re to test.

Creating A Test

Creating a test is similar to creating a controller. To create a new test, use the artisan make command.

$ php artisan make:test BlogTest

This command creates a new BlogTest.php file in the Feature directory in your tests directory. This is because feature tests are the default tests in Laravel. This test file now has code that looks like this:

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class BlogTest extends TestCase
{
use RefreshDatabase;

/**
* A basic feature test example.
*
* @return void
*/
public function test_example()
{
$response = $this->get('/');

$response->assertStatus(200);
}
}

To create a unit test instead, add the --unit flag to the test. Like this:

$ php artisan make:test BlogTest --unit

Testing Routes

Within your tests, Laravel allows you make HTTP requests (GET, POST, PUT, PATCH, DELETE) to your endpoints. These requests are not actually real. Instead, the entire network request is simulated internally.

After the request runs, the methods do not return regular responses. Instead they return an instance of Illuminate\Testing\TestResponse. This instance allows you perform many helpful assertions. These assertions are what Laravel uses to confirm that some value matches some expected value.

To see a list of all possible assertions, visit the official documentation.

Making Requests

You can make a request to a route like this:

// GET Request
$response = $this->get('/');

// POST Request
$response = $this->post('/', [
'key' => 'value'
]);

// PUT Request
$response = $this->put('/', [
'key' => 'value'
]);

// PATCH Request
$response = $this->patch('/', [
'key' => 'value'
]);

// DELETE Request
$response = $this->delete('/');

You may also make JSON requests too. These requests make it very easy to pass headers and data too. They are used to test JSON endpoints.

$response = $this->json('POST', '/', [
'key' => 'value'
]);

// GET Request
$response = $this->getJson('/');

// POST Request
$response = $this->postJson('/', [
'key' => 'value'
]);

// PUT Request
$response = $this->putJson('/', [
'key' => 'value'
]);

// PATCH Request
$response = $this->patchJson('/', [
'key' => 'value'
]);

// DELETE Request
$response = $this->deleteJson('/');

Customising Requests

You may customise the requests as much as you wish. You can set headers, cookies, session variables.

// With a single header
$response = $this->withHeader('Accept', 'application/json')->get('/');

// With multiple headers
$response = $this->withHeaders([
'Accept' => 'application/json',
'Content-Type' => 'application/json'
])->get('/');

// With a cookie
$response = $this->withCookie('token', 'xxxxxxxxxx')->get('/');

// With cookies
$response = $this->withCookies([
'age' => '20',
'name' => 'Zubair',
])->get('/');

// With a session variable
$response = $this->withSession([
'dark_mode' => true
])->get('/');

Testing With Authentication

Other times, you might want to test that a user may perform some action. Or need a user to perform some action. Or want to see how the application responds to different users. You can act as a user for a test.

$admin = User::firstWhere('type', 'admin'); 

$response = $this->actingAs($admin)->get('/');

Debugging Responses

If for some reason, you feel like the test results are not correct and want to see the actual response. You can debug using either dump, dd, dumpHeaders, ddHeaders, dumpSession, and ddSession.

$response = $this->get('/'); 

$response->dump();

$response->dd();

$response->dumpHeaders();

$response->ddHeaders();

$response->dumpSession();

$response->ddSession();

Testing The Application

Now, we can dive into testing our application. To get started, we can test the store method.

Test Create New Post

public function test_store()
{
$response = $this->postJson('/api/blog', [
'title' => 'First Post Test',
'body' => 'I love to do this'
]);

$this->assertJson($response->getContent());

$response->assertStatus(201); // Or assertCreated

$this->assertDatabaseHas('posts', [
'title' => 'First Post Test'
]);

$this->assertDatabaseCount('posts', 1);
}

Here, we start by making a POST request to the endpoint to create a new post. Next, we confirm that the response is exactly the type we were expecting (JSON) using assertJson. This method is used to check whether a string is valid JSON. Then we check the status code using assertStatus. We pass in the expected status code of 201 for a database insert operation. Alternatively, we could have used assertCreated to perform the check. We then check the database for posts matching the title we passed in using assertDatabaseHas to be sure that the data was inserted properly. And finally, since it's the first item we're adding to the database, we can check if it's the only one there by checking the count of the items in the table using assertDatabase count.

Test Fetch All Posts

public function test_index()
{
$this->postJson('/api/blog', [
'title' => 'First Post Test',
'body' => 'I love to do this'
]);

$this->postJson('/api/blog', [
'title' => 'Second Post Test',
'body' => 'I love to do this always'
]);

$response = $this->get('/api/blog');

$this->assertJson($response->getContent());

$this->assertIsArray(json_decode($response->getContent()));

$response->assertSee([
'title' => 'First Post Test'
]);

$this->assertCount(2, json_decode($response->getContent()));

$response->assertStatus(200); // Or assertOk
}

Here, we create two posts by making JSON calls to the /api/blog endpoint. This is just so we can test that out fetch API has some data to get. We could avoid this step by removing the RefreshDatabase trait that we use at the beginning. This traits helps ensure that whatever happens during the tests is not persisted. It refreshes the database after each test. So after the post was created in the first test, it was refreshed and we have an empty table again by the second test.

Next, we try to fetch all posts by making a GET call to /api/blog. We first check the returned content to see if it is valid JSON. Then, we check that the content, when decoded, is an array of data. We did this check using assertIsArray. Then, we check if the title of the one of the newly created posts was returned using assertSee. Then, we confirm that there are exactly two posts in the array using assertCount. Finally, we check the status code.

Test Fetch Single Posts

public function test_get()
{
$new_post = $this->postJson('/api/blog', [
'title' => 'First Post Test',
'body' => 'I love to do this'
]);

$new_post_id = json_decode($new_post->getContent())->id;

$response = $this->get('/api/blog/' . $new_post_id);

$this->assertJson($response->getContent());

$response->assertSee([
'title' => 'First Post Test'
]);

$response->assertOk();
}

The logic here is quite similar to the previous one. We create a new post. Then, we use the id of the newly created post to try to fetch it using by making a GET request to /api/blog/{id}. Next, we confirm that the API returned JSON. Then we check that response contains the newly created post, by seeing if a post with the title exists in the response. Finally, we confirm that the API returned 200.

Test Update Post

public function test_update()
{
$new_post = $this->postJson('/api/blog', [
'title' => 'First Post Test',
'body' => 'I love to do this'
]);

$new_post_id = json_decode($new_post->getContent())->id;

$response = $this->putJson('/api/blog/' . $new_post_id, [
'title' => 'This is now a second post',
'body' => 'This actually used to be the first post'
]);

$this->assertJson($response->getContent());

$response->assertSee([
'title' => 'This is now a second post'
]);

$this->assertDatabaseHas('posts', [
'title' => 'This is now a second post'
]);

$response->assertOk();
}

Here, we create post. Then get its id. Next, we make a JSON PUT request to the /api/blog/{id} endpoint. We check the response type. We confirm that the response contains the updated title using assertSee. Then, we check that this change reflects in the database too. And finally, we confirm that the response status is Ok.

Test Delete Post

public function test_delete()
{
$post_1 = $this->postJson('/api/blog', [
'title' => 'First Post Test',
'body' => 'I love to do this'
]);

$post_2 = $this->postJson('/api/blog', [
'title' => 'Second Post Test',
'body' => 'I love to do this again'
]);

$post_1_id = json_decode($post_1->getContent())->id;

$response = $this->delete('/api/blog/' . $post_1_id);

$this->assertJson($response->getContent());

$this->assertDeleted('posts', [
'title' => 'First Post Test',
]);

$this->assertDatabaseHas('posts', [
'title' => 'Second Post Test'
]);

$response->assertOk();
}

Here, two posts are created. The first one is deleted by passing its id to the /api/blog/{id} endpoint in a DELETE request. We check the response type. Then we confirm that the data was deleted from the posts table using assertDeleted. We also check that the second post still exists and has not been deleted. Finally, we check the response status.

Conclusion

Now you know how to set up test your Laravel projects and how to ensure that you’re building world class software. You can find all the code for this article here.

If you have any questions or relevant advice, please get in touch with me to share them.

To read more of my articles or follow my work, you can connect with me on LinkedIn, Twitter, and Github. It’s quick, it’s easy, and it’s free!

--

--

Zubair Idris Aweda
Zubair Idris Aweda

Written by Zubair Idris Aweda

Software Engineer | PHP | Javascript. Technical Writer

Responses (2)