This article was published over 2 years ago. Some information may be outdated.
Laravel provides the Illuminate\Foundation\Testing\RefreshDatabase trait to reset the database after each test so that data from a previous test does not interfere with subsequent tests.
The RefreshDatabase trait uses the artisan command migrate:fresh to drop all the tables from the database and then execute the migrate command. This is useful when you create or modify migrations.
In large applications with hundreds of migrations, the migrate:fresh command can significantly slow down your tests.
This post shows you an easy solution to make your tests run faster by only executing migrate:fresh when necessary.
RefreshTestDatabase Trait
The refreshTestDatabase method in the RefreshDatabase trait is responsible for migrating the database using migrate:fresh, which drops all tables and re-migrates:
protected function refreshTestDatabase()
{
if (! RefreshDatabaseState::$migrated) {
$this->artisan('migrate:fresh');
$this->app[Kernel::class]->setArtisan(null);
RefreshDatabaseState::$migrated = true;
}
$this->beginDatabaseTransaction();
}
Read more about
migrate:fresh
The code is straightforward. It checks the migrations state, then runs migrate:fresh if necessary. Checking the state avoids running migrate:fresh on each test, which is an expensive operation.
The issue with migrate:fresh command
Here is the problem: migrate:fresh executes whenever you run phpunit:
# drop and migrate the database #1
./vendor/bin/phpunit
# drop and migrate the database #2
./vendor/bin/phpunit --filter="my_test"
# drop and migrate the database #3
./vendor/bin/phpunit --filter="my_test2"
Dropping and migrating the database makes sense if you have made changes by creating or modifying migrations. But what if you have not?
Consider the following example.
You create a new languages migration:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('languages', function (Blueprint $table) {
$table->id();
$table->string('code', 2)->unique();
$table->string('name')->unique();
$table->string('native_name')->unique();
$table->timestamps();
});
}
};
Then you write some tests:
use Tests\TestCase;
class LanguageTest extends TestCase
{
/** @test **/
public function it_creates_langage()
{
// ...
}
/** @test **/
public function it_updates_langage()
{
// ...
}
/** @test **/
public function it_deletes_langage()
{
// ...
}
}
And you run the test:
# running the test for the 1st time
./vendor/bin/phpunit --filter="it_creates_a_new_langage"
You run the test several times because you are working on some improvements:
# running the test for the 2nd time
./vendor/bin/phpunit --filter="it_creates_a_new_langage"
That is the problem. Laravel should not drop and migrate the database unless you make changes to the migrations.
For example, renaming the code column to iso_639_1 should force Laravel to drop and migrate the database:
$table->string('iso_639_1', 2)->unique();
You do not need
migrate:freshto execute on everyphpunitrun if no migrations have changed.
Make it faster
You can avoid the migrate:fresh command on each phpunit execution unless you make changes to the migrations.
Here is how it works:
- First, calculate and save the checksum of the entire
migrationsfolder. You can use Symfony Finder to iterate over thedatabase/migrationsfolder and calculate the checksum for each migration file using themd5_filefunction. - Then compare the checksum on the next test run. If the current checksum differs from the saved one, run
migrate:fresh. Otherwise, skip it.
Implementation
Create a new trait named RefreshTestDatabase in the /tests folder.
namespace Tests;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\RefreshDatabaseState;
use Symfony\Component\Finder\Finder;
trait RefreshTestDatabase
{
use DatabaseTransactions;
protected function refreshTestDatabase(): void
{
if (! RefreshDatabaseState::$migrated) {
$this->runMigrationsIfNecessary();
$this->app[Kernel::class]->setArtisan(null);
RefreshDatabaseState::$migrated = true;
}
$this->beginDatabaseTransaction();
}
protected function runMigrationsIfNecessary(): void
{
if (false === $this->identicalChecksum()) {
$this->createChecksum();
$this->artisan('migrate:fresh');
}
}
protected function calculateChecksum(): string
{
$files = Finder::create()
->files()
->exclude([
'factories',
'seeders',
])
->in(database_path())
->ignoreDotFiles(true)
->ignoreVCS(true)
->getIterator();
$files = array_keys(iterator_to_array($files));
$checksum = collect($files)->map(fn($file) => md5_file($file))->implode('');
return md5($checksum);
}
protected function checksumFilePath(): string
{
return base_path('.phpunit.database.checkum');
}
protected function createChecksum(): void
{
file_put_contents($this->checksumFilePath(), $this->calculateChecksum());
}
protected function checksumFileContents(): bool|string
{
return file_get_contents($this->checksumFilePath());
}
protected function isChecksumExists(): bool
{
return file_exists($this->checksumFilePath());
}
protected function identicalChecksum(): bool
{
if (false === $this->isChecksumExists()) {
return false;
}
if ($this->checksumFileContents() === $this->calculateChecksum()) {
return true;
}
return false;
}
}
From now on, use the RefreshTestDatabase trait instead of the RefreshDatabase one.
The next step is to call the refreshTestDatabase method whenever you use the trait in your test files. This is done through the setUpTraits method in the Tests\TestCase file:
namespace Tests;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
use RefreshDatabase;
protected function setUpTraits()
{
$uses = parent::setUpTraits();
if (isset($uses[RefreshTestDatabase::class])) {
$this->refreshTestDatabase();
}
return $uses;
}
}
The last step is to add the .phpunit.database.checkum entry to the .gitignore file.
Run some tests and, depending on the number of migrations you have, you will notice a significant difference.
Here is the benchmark for one of my projects, which has more than 300 migrations:
- Without checksum: 9.229 s
- With checksum: 222.2 ms
Summary
migrate:freshruns on everyphpunitexecution by default -- this is wasteful when no migrations have changed.- Checksum-based detection skips unnecessary migrations -- by hashing the migrations folder and comparing it between runs, you only drop and re-migrate when something actually changed.
- The performance gain scales with migration count -- projects with hundreds of migrations can see test startup drop from seconds to milliseconds.