In this blog post I'll be explaining my solution for refreshing and seeding a test database for each test, in a quick way.
The current situation
As stated in the Laravel docs we can automatically refresh the database before each test with the RefreshDatabase
trait.
With a small database (having a small database structure and not much data to be seeded), this will work pretty well; it's quite quick and we'll have a fresh database for each test we run. But as our database and required data grows, our tests will take longer and longer to run. Another drawback is that the RefreshDatabase
trait will not automatically seed the database. This means that we'll have to add the seeding to our tests, which will make them more complex.
In one of my larger projects I ran into this issue as well and I thought the solution should be pretty simple. Well... not so much.
The ideal solution
In an ideal world we'd be able to fix this in a way that, when the testing starts, the system would run all the migrations and seeders once and use a copy of the resulting database for each test.
By doing this, the time it would take to run all the tests sould be drastically decreased.
The actual solution
The actual solution consists of three steps:
- Hooking into PHPUnit and run some code before all the tests start running and cleaning up after the tests have run.
- Migrating and seeding the database.
- "Refreshing" the database for each test.
Step 1: Hooking into PHPUnit
It turns out that making PHPUnit do something just once before all tests, is quite difficult. When we put some code in the setUp()
method, it'll run for each test and when we put it in the setUpBeforeClass()
method, it'll be run once for that class, but there's no option to run some code before all tests.
Thanks to an answer on Stack Overflow by edorian I figured out that PHPUnit implements test listeners. By creating a class that implements PHPUnit\Framework\TestListener
, we can have it run code when a test suite starts in the startTestSuite()
method and have it run code when a test suite ends in the endTestSuite()
method. This is very useful since all the feature tests in our Laravel application are part of the Feature suite, so we can just check for that suite name.
A possible way to migrate the database once in the Feature suite would be with the following code:
public function startTestSuite(TestSuite $suite): void { if ($suite->getName() !== 'Feature') { return; } $this->artisan('migrate:refresh', [ '--seed' => true, ]); }
But we run into a problem here: $this->artisan()
doesn't exist in a test listener class, so we can't use it. What we can do is executing Artisan as a shell command with shell_exec
:
public function startTestSuite(TestSuite $suite): void { if ($suite->getName() !== 'Feature') { return; } chdir('path/to/project/root'); shell_exec('php artisan migrate:refresh --seed'); }
Unless PHP is running in safe mode, this will work perfectly.
We also need to do some cleaning up after the test suite has run. We can do this in the endTestSuite()
method:
public function endTestSuite(TestSuite $suite): void { if ($suite->getName() !== 'Feature') { return; } $basePath = __DIR__ . '/data/base.sqlite'; if (File::exists($basePath)) { File::delete($basePath); } $copyPath = __DIR__ . '/data/database.sqlite'; if (File::exists($copyPath)) { File::put($copyPath, ''); } }
In the code above we check if some SQLite files exist and either delete them or empty them. More on these files in the next steps.
So putting this all together we create the event listener like this (in the tests/DatabaseTestListener.php
file):
<?php namespace Tests; use Illuminate\Support\Facades\File; use PHPUnit\Framework\TestListener; use PHPUnit\Framework\TestListenerDefaultImplementation; use PHPUnit\Framework\TestSuite; class DatabaseTestListener implements TestListener { use TestListenerDefaultImplementation; public function startTestSuite(TestSuite $suite): void { if ($suite->getName() !== 'Feature') { return; } chdir(__DIR__ . '/..'); shell_exec('php artisan migrate:refresh --seed'); } public function endTestSuite(TestSuite $suite): void { if ($suite->getName() !== 'Feature') { return; } $basePath = __DIR__ . '/data/base.sqlite'; if (File::exists($basePath)) { File::delete($basePath); } $copyPath = __DIR__ . '/data/database.sqlite'; if (File::exists($copyPath)) { File::put($copyPath, ''); } } }
We can use the TestListenerDefaultImplementation
trait, so we don't have to implement all methods from the TestListener
interface.
To make this work in PHPUnit, we'll have to add the listener to the PHPUnit configuration file (phpunit.xml
):
<phpunit> ... <listeners> <listener class="Tests\DatabaseTestListener"/> </listeners> </phpunit>
That's it. Step 1: done!
Step 2: Building the database
If you haven't done so already, you need to change the PHPUnit configuration to use SQLite, so we can store the database into a file. To do this you'll have to open up phpunit.xml
again and add the following tags to the <php>
tag:
<phpunit> ... <php> ... <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value="./tests/data/database.sqlite"/> </php> </phpunit>
By doing this we're telling Laravel to use the sqlite
connection and store the database in the tests/data/database.sqlite
file. Make sure to create the tests/data
folder and an empty file at tests/data/database.sqlite
too.
This is basically it for step 2, but we can make it much, much better. We can actually make the code be aware of changes to the database migrations and only create a new database file when those have changed. How to do this will be covered in a later blog post.
We want to copy the existing database file for each test, so we need to make our own RefreshDatabase
trait. By using a trait we don't have to write the same code over and over again and we can make sure it only runs in test classes that need it.
We want our test classes to only have to use the trait and be done, just like the original RefreshDatabase
trait. Thanks to this answer on Laracasts by @MikeHopley we know how to do this.
We first need to create a new PHP file, for example: tests/Concerns/RefreshDatabase.php
. In this file we're creating our trait and performing the copying of the database file. We first need to check if there's a base file (base.sqlite
), this will be our source we will reuse and copy from.
If the base file doesn't exist, this is the first time the method is being called and since there haven't been any other tests we can safely assume that the database.sqlite
file is the freshly built database. So we can create the base file by copying database.sqlite
to base.sqlite
.
<?php namespace Tests\Concerns; use Illuminate\Support\Facades\File; trait RefreshDatabase { public function refreshDatabase(): void { $basePath = base_path('tests/data/base.sqlite'); $copyPath = base_path('tests/data/database.sqlite'); if (!File::exists($basePath)) { File::copy($copyPath, $basePath); } else { File::copy($basePath, $copyPath); } } }
Now we need to make sure the refreshDatabase()
method is automatically called when a class uses this trait. We need to do this in the TestCase
class in the tests
folder. We need to override the setUpTraits()
method and check for our own RefreshDatabase
trait:
protected function setUpTraits(): array { $uses = parent::setUpTraits(); if (isset($uses[RefreshDatabase::class])) { $this->refreshDatabase(); } return $uses; }
The code above check to see if the current class uses the RefreshDatabase
trait (don't forget to import the correct trait) and calls the refreshDatabase()
method.
That's it! Now each time we run phpunit
for all tests in the classes we've used the RefreshDatabase
trait, the test will run on a copy of the clean database!
No comments:
Post a Comment