Testing with Laravel
If you want to deploy with confidence and you have a rather complex project, you'll need to have some tests. Why, what and how to test for a Laravel 7.x project.
If you haven't done so, I would recommend to first get up to speed with Laravel's excellent documentation. Then continue reading to get a better understanding.
You can download the code at github
A good practice is Test Driven Development. You'll write the test first and the code after. This way, you'll have a clear understanding of what the code should do before you'll actually start coding.
1. WHY AUTOMATED TESTING?
Manual vs. automated testing
Of course, every developer already tests their code. For instance, by checking the results in their browser, console... This sort of works as long as the project is not too complex and you don't forget to test everything. These are already two big if's. Let's say you have a website with dozens of pages of which some are protected + a contact form. The form generates a dynamic pdf and attaches it to an e-mail to be sent to the recipient in the form. I would say this is a rather small project. Now, every time you will commit/deploy, you'll need to make sure that everything (still) works as intended. Hitting all the pages in your browser with all the cases (logged in/not logged in, extra parameters), filling out the forms with the data, checking the validation, checking if the e-mail is sent with the correct pdf.
In the above scenario, we're relying on a human to do a boring, repetitive, time-consuming task well. This is prone for error and thus this is something that needs to be automated.
Yes, it will take some time to write your test but once you're up to speed, it won't take that much time. Besides, refreshing your browser, filling in forms, having to deploy multiple times, creating bug tickets that need to be fixed and followed up upon will take much more time. Since developers are a lazy bunch, this should be a no-brainer.
Once you have written your tests, you can set them up automatically. No more refreshing your browser for every little change.
The biggest reason however of why you should use automated tests is that you don't want to deploy something that's broken!
What to test?
Now that you're hopefully convinced that you need to write automated tests, you should know what to test and what not. Strictly speaking, everything that could go wrong, should be tested.
Of course, there is no need to tests the obvious. You will also need to trust that Laravel already tests everything so it is not needed to test any of the code that comes with the framework. This why the default configuration of PHPUnit with Laravel will only check the code in the app directory. Normally, you should not test any code elsewhere.
Code coverage
Code coverage is a great tool to help illustrate the parts of your code that are not covered by your tests. In general, more coverage is preferred but do not let it give you a false sense of confidence. It's possible to have 100% coverage and still have a lot of bugs because you are not testing al cases, combinations, ...
In phpstorm, you can right/command click on you testing folder and choose: "run tests with coverage". After this, you'll get a panel with the root of your project and some statistics next to the app folder: for instance "50% files, 60% lines". This basically means that 50% of the php files in the app folder are being tested. Of these files, 60% of the lines of code are tested.
2. SET-UP
Dependencies
Laravel makes it a breeze to set everything up. Actually, the default Laravel installation in development mode already comes with PHPUnit, Faker and Mockery.
If you don't know how to install a Laravel project, please check the documentation.
Take a look in your composer json:
"require-dev": {
"facade/ignition": "^2.0",
"fzaninotto/faker": "^1.4",
"mockery/mockery": "^1.0",
"nunomaduro/collision": "^4.1",
"phpunit/phpunit": "^9.0"
},
Great! So, if you're not in production mode, than you already have all the dependencies. Not only that but Laravel default already comes with a phpunit.xml file and some demo tests so you can get started right away. Open up your terminal and type:
php artisan test
Note that if you're using Laravel 6 or lower, the above will not work. Skip to Composer script
If everything works, you should see the following on a fresh Laravel 7.x project:
RUNS Tests\Unit\ExampleTest
• basic test
Tests: 2 pending
PASS Tests\Unit\ExampleTest
✓ basic test
RUNS Tests\Feature\ExampleTest
• basic test
Tests: 1 passed, 1 pending
PASS Tests\Feature\ExampleTest
✓ basic test
Tests: 2 passed
Time: 0.14s
There were two test. They all passed and took .14 seconds to complete.
Composer script
If you prefer the output from PHPUnit or you have an older version of Laravel, you'll need to call PHPUnit in the vendor/bin folder:
vendor/bin/phpunit (or vendor\bin\phpunit on windows)
Even better; let's add a script to composer. This way, you can call PHPUnit with "composer ltest" and you can easily swap PHPUnit later with paratest or phpspec and add other scripts.
"scripts": {
...
"ltest": [
"vendor\\bin\\phpunit"
]
}
now you can just call "composer ltest" and you'll get:
> vendor\bin\phpunit
PHPUnit 8.5.8 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 166 ms, Memory: 20.00 MB
OK (2 tests, 2 assertions)
Setting the testing variables
If you're not using SQLite, create an empty database and open up phpunit.xml from the root of the project.
In the "php" section, you can overwrite your local .env variables. Store the name of your database in the "DB_DATABASE" variable and save the file.
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="DB_DATABASE" value="test_project_testing"/>
Another way is to create a env.test file that will override your .env file but than you'll have to make sure to empty your cache before runnning your test.
Feature vs Unit vs Integration tests
Laravel comes default with a "Feature" and a "Unit" folder for the tests. The distinction is, in my opinion, not so important as long as you test well and you have a good logical structure to easily find back your tests.
A unit test should test only one particular thing, like a method. It should be fast and not have any external dependencies like a database.
A feature test on the other hand has typically more than one thing to test for. For those you could use factories.
An integration test is a test that checks if all the parts work together as expected.
Laravel & PHPUnit are flexible enough that you can change the whole structure to whatever you like.
Back-end vs front-end tests
Since Laravel is a php framework, you'll typically test the back-end part of your code. There is an extra composer dependency which will let you test what happens in your browser: dusk For better front-end testing, it is recommended that you (also) use other solutions like these for Vue.js.
3. PREPARATION
In this section, we'll create all the necessary routes, models, controllers etc in order to get started.
We're going to create two endpoints. One for storing new articles and one for displaying a list of articles. The listing can be filtered by published or not and only logged in users can create articles.
Open up routes routes/web.php and add the following code below:
Route::get('/articles', 'ArticleController@index')->name('articles.index');
Route::post('/articles', 'ArticleController@store')->name('articles.store');
Next, we'll create the "Article" model with the migration, factory and resource controller:
php artisan make:model Article -a
The -a option stands for "all" and will create, besides the model, the migration, factory and resource controller.
Open the freshly created Article model and paste the code below that allows us to attach a user to an article.
public function user(){
return $this->belongsTo(User::class);
}
Open up the migration for the articles table and replace the code below:
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('text');
$table->boolean('publish')->default(false);
$table->integer('user_id')->unsigned()->nullable();
$table->timestamps();
});
}
Open up app/Http/Controllers/ArticleController and paste the code below:
namespace App\Http\Controllers;
use App\Article;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ArticleController extends Controller
{
/**
* Display a listing of the articles.
*
* @param Request $request
* @return JsonResponse
*/
public function index(Request $request)
{
$showPublished = (!Auth::check() || $request->get('publish', 1));
$data = Article::join('users', 'users.id', '=', 'articles.user_id')
->where('articles.publish', $showPublished)
->select(['articles.id', 'articles.title', 'articles.updated_at', 'articles.created_at', 'users.name as user'])
->get();
return response()->json([
'data' => $data,
'total' => $data->count(),
]);
}
/**
* Store a newly created article in storage.
*
* @param Request $request
* @return JsonResponse
*/
public function store(Request $request)
{
abort_if(! Auth::check(), 403, "Sorry, you don't have access.");
$request->validate([
'title' => 'required|min:10|max:200',
'text' => 'required|min:10',
'publish' => 'required|boolean',
]);
$article = new Article();
$article->title = $request->title;
$article->text = $request->text;
$article->user_id = Auth::id();
$article->save();
return response()->json([
'status' => 'success'
]);
}
}
These are some very simple examples without the use of middleware, data resources, form requests or pagination. This suffices for our demo.
Factories
Factories are great to abstract some code. Let's take a look at the database/factories/UserFactory that comes with the default installation:
use App\User;
use Faker\Generator as Faker;
use Illuminate\Support\Str;
$factory->define(User::class, function (Faker $faker) {
return [
'name' => $faker->name,
'email' => $faker->unique()->safeEmail,
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
];
});
We'll get to the $faker part later. The password is not set with bcrypt because this is expensive.
The code for the database/factories/ArticleFactory:
use App\Article;
use Faker\Generator as Faker;
$factory->define(Article::class, function (Faker $faker) {
return [
'title' => $faker->sentence(),
'text' => $faker->sentences(5, true),
'publish' => true,
'user_id' => function() {
return factory(User::class)->create()->id;
},
];
});
Note that if you don't pass a userid, a new user record will be automatically created with it's factory and attached to the article table (by the userid column).
You can easily specify/override variables like so:
$hiddenArticle = factory(Article::class)->create(['publish' => false]);
Faker
Faker is a great open source PHP library that generates fake data.
François Zaninotto, the author of Faker announced last year that Faker would be archived and laid out his reasoning here.
It's an interesting read about open source, collaboration and the bus factor.
Some possible uses:
Dummy content: fill your database with dummy content that is more conceptual than variants of "lorem ipsum"
Anonymize your sensitive data. (GDPR, ...)
Testing*
* You could argue that it's best practice to mimic your production environment as close as possible and therefore you need something like Faker.
However, I found that for debugging purposes it is best to simply use the name of the field as the value. Unlike the used examples here. Of course, this only works for text fields.
Anoter argument against using Faker for testing is that randomness should not really be a factor when you're testing. Otherwise, you'll have a hard time figuring out why your test fail sometimes. In those cases a dataprovider with all possible variants should be used.
See the readme on github for tons of examples.
4. SOME EXAMPLES
Storing an article
Once you have everything set-up, it's time to write our first test. What do we want to test?
Only an authenticated user can create articles so we should test that and vice versa an unauthenticated can not. We will also test some validation rules and then we want to test if the record was actually created in the database. Open up your terminal and type:
php artisan make:test ArticlesTest
Make sure that you add "test" at the end of the file, otherwise PHPUnit will not find it. Open the new test "tests/feature/ArticlesTest" in your editor and paste the code from below:
/**
* @test
*/
public function unauthenticatedUserCannotStoreArticle()
{
$this->json('POST', route('articles.store'))
->assertStatus(403);
}
Let's start with the comment in the example above. In order for PHPUnit to detect your test, you'll need to start the name of the function with "test" OR you can use the annotation @test (like in the example above).
Since we don't have an authenticated user (see next test), we should get a 403 server status back. Let's test this. In your terminal, type:
php artisan test --filter unauthenticatedUserCannotStoreArticle
You should get the following result:
RUNS Tests\Feature\ArticlesTest
• unauthenticated user cannot store article
Tests: 1 pending
PASS Tests\Feature\ArticlesTest
✓ unauthenticated user cannot store article
Tests: 1 passed
Time: 0.18s
Now comment out the abort_if line of code in the index function, and run the above again. You'll get the following message: "Expected status code 403 but received 422. Failed asserting that 403 is identical to 422." Great, the test fails, as expected. In stead of the 403 server status (forbidden), we are getting the server status of 422 (unprocessable entity) because of the failed validation. Let's add another test in the same file and paste the code from below.
/**
* @test
*/
public function authenticatedUserCannotStoreArticleWithEmptyData()
{
$user = factory(User::class)->create();
$this->actingAs($user);
$this->json('POST', route('articles.store'))
->assertStatus(422)
->assertJsonValidationErrors(['title', 'text', 'publish']);
}
Here we create a user who we authenticate. Since we don't pass any data, we should get a 422 server status. We'll also assert that we are getting error messages for title, text and publish. You can add some more tests/assertions to check for each validation error but let's move on:
/**
* @test
*/
public function authenticatedUserCanStoreArticle()
{
$user = factory(User::class)->create();
$this->actingAs($user);
$data = factory(Article::class)->make()->toArray();
$this->json('POST', route('articles.store', $data))
->assertStatus(200)
->assertJson(['status' => 'success']);
$this->assertDatabaseHas('articles', [
'user_id' => $user->id,
'title' => $data['title'],
'text' => $data['text'],
]);
}
In this test, we fill the $data array by calling the make method on the factory. This does not store the data.
The RefreshDatabase trait
Since we don't want old test results in our database to influence our test results, we need to add a trait to our test class that resets your database after each test. We need to verify that a new record was created and not an (older) possible duplicate. The title and text are randomized with Faker but they are not unique. Add the trait to the tests/feature/ArticlesTest:
use Illuminate\Foundation\Testing\RefreshDatabase;
class ArticlesTest extends TestCase
{
use RefreshDatabase;
Getting a listing of articles
For the listings, we want to assert that we get all the correct properties, including the name of the author and the total count. We should also make sure that the hidden articles are not displayed unless we are authenticated and we pass an extra parameter "publish=0".
Let's add three more test in the same file. Paste the code from below:
/**
* @test
*/
public function userCanViewArticlesOverview()
{
$article = factory(Article::class)->create();
$hiddenArticle = factory(Article::class)->create(['publish' => false]);
$this->json('GET', route('articles.index'))
->assertStatus(200)
->assertJsonStructure(['data' => [['id', 'title', 'updated_at', 'created_at', 'user']], 'total'])
->assertJsonFragment(['total' => 1])
->assertJsonFragment(['id' => $article->id])
->assertJsonMissing(['id' => $hiddenArticle->id]);
}
/**
* @test
*/
public function unauthenticatedUserCannotViewUnpublishedArticlesOverview()
{
$article = factory(Article::class)->create();
$hiddenArticle = factory(Article::class)->create(['publish' => false]);
$this->json('GET', route('articles.index', ['publish' => false]))
->assertStatus(200)
->assertJsonStructure(['data' => [['id', 'title', 'updated_at', 'created_at', 'user']], 'total'])
->assertJsonFragment(['total' => 1])
->assertJsonFragment(['id' => $article->id])
->assertJsonMissing(['id' => $hiddenArticle->id]);
}
/**
* @test
*/
public function authenticatedUserCanViewUnpublishedArticlesOverview()
{
$user = factory(User::class)->create();
$this->actingAs($user);
$article = factory(Article::class)->create();
$hiddenArticle = factory(Article::class)->create(['publish' => false]);
$this->json('GET', route('articles.index', ['publish' => false]))
->assertStatus(200)
->assertJsonStructure(['data' => [['id', 'title', 'updated_at', 'created_at', 'user']], 'total'])
->assertJsonFragment(['total' => 1])
->assertJsonFragment(['id' => $hiddenArticle->id])
->assertJsonMissing(['id' => $article->id]);
}
The first test will verify that we only get one result back since we only created one published article and default we should only get a listing of the published articles. If we pass the publish parameter but we are not authenticated, this will have no effect, see: unauthenticatedusercannotviewunpublishedarticlesoverview.
It's good practice to test that all the variables are being returned since the front-end will rely on these.
5. OPTIMIZATION
"Premature optimization is the root of all evil". Apart from the hyperbole, there is definitely a case to be made. If you have a rather small project with 100 tests or so, there is no need to replace your MySQL database with an in memory database to shave some seconds off and deal with failing tests because of inconsistencies. If however, you have hundreds of tests that take several minutes and it is integrated with your continuous integration and deployment (you should!), it could make sense to optimize some of the code and the tools being used.
Fast tests are important in order to test often!
SQLite with an in memory database vs MySQL database
If you use MySql and want to use SQLite in memory for the speed gains, you need to be aware of some inconsistencies (for instance with some migrations). Of course, if you're already using SQLite, than you can profit from the faster RAM usage.
Xdebug vs phpspec vs PCOV
Xdebug is known to be slow but is an excellent tool for testing your code. It does more than just running the tests, like setting breakpoints. If you want your tests to complete faster, it could be usefull to replace Xdebug with phpspec or PCOV. See this article by Nicolas Cabot
database transactions
Use the laravel database transaction trait in stead of the RefreshDatabase trait. You will have to make sure that you run the migrations before. For an example of how to do this, watch the excellent video by Adam Wathan.