Testing Laravel: resetting the database after each test, but better

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:

  1. Hooking into PHPUnit and run some code before all the tests start running and cleaning up after the tests have run.
  2. Migrating and seeding the database.
  3. "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;

    /**
     * Set up the database for testing.
     *
     * @param TestSuite $suite
     */
    public function startTestSuite(TestSuite $suite): void
    {
        if ($suite->getName() !== 'Feature') {
            return;
        }

        chdir(__DIR__ . '/..');

        shell_exec('php artisan migrate:refresh --seed');
    }

    /**
     * Clean up the database files.
     *
     * @param TestSuite $suite
     */
    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.

Step 3: Performing the database refresh

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  
{
    /**
     * Refresh the database to a clean version.
     */
    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:

/**
 * Boot the testing helper traits.
 *
 * @return array
 * @throws \Exception
 */
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!