← Back to blog

Make RefreshDatabase trait much faster

| Laravel

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:fresh to execute on every phpunit run 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:

  1. First, calculate and save the checksum of the entire migrations folder. You can use Symfony Finder to iterate over the database/migrations folder and calculate the checksum for each migration file using the md5_file function.
  2. 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:fresh runs on every phpunit execution 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.
Share