Run Laravel Tests Crazy Fast
I was working on a task to make the project’s test suite run faster. Over the last six months or so, the project’s test suite had grown, and we started facing slow builds. The first step was to ensure the test seeder runs only once, with each test using factories to create the specific data it requires. The project uses a custom TestCase.php class, which each test extends. So, a BaseDatabaseSeeder.php was added to seed all the data required by most of the tests.
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use FastRefreshDatabase; // will talk about this one in a bit
protected string $seeder = BaseDatabaseSeeder::class;
protected array $connectionsToTransact = [
'conn1',
'conn2',
'conn3',
'conn4',
'conn5',
];
}
The next step was to set up parallel testing — for obvious reasons. However, as you can see, this project has multiple database connections. To handle this, a ParallelTestingServiceProvider.php was created. Previously, I used solution B from Sarah Ting’s article , but this time I chose a solution closer to solution A. It creates less confusion among our developer team and works like a charm. Instead of creating separate databases for each connection, the ParallelTestingServiceProvider simply copies the default test database name to all other connections. This way, every worker stays isolated, and tests run quickly.
use Illuminate\Support\Facades\ParallelTesting;
use Illuminate\Support\ServiceProvider;
class ParallelTestingServiceProvider extends ServiceProvider
{
public function boot(): void
{
if (! app()->runningUnitTests()) {
return;
}
ParallelTesting::setUpTestCase(function () {
$dbName = config('database.connections.mysql.database');
foreach (['conn1', 'conn2', 'conn3', 'conn4', 'conn5'] as $conn) {
config()->set("database.connections.{$conn}.database", $dbName);
}
});
}
}
At this point, we can run tests in parallel, and we’re happy. However, running individual tests locally is still a bit of a pain because the project has lots of migrations. It takes around 15 seconds to run a test on my Mac mini M4, which is a pretty long time to wait — not to mention that some developers on our team have less powerful machines.
A while back, I found the Laravel Fast Refresh Database package that make running tests a breeze. While the package does support parallel testing, I created my own highly inspired FastRefreshDatabase.php trait for more flexibility.
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\RefreshDatabaseState;
use Illuminate\Support\Facades\ParallelTesting;
use Symfony\Component\Finder\Finder;
trait FastRefreshDatabase
{
use RefreshDatabase;
protected function beforeRefreshingDatabase(): void
{
if (! $this->identicalChecksum()) {
$this->createChecksum();
} else {
RefreshDatabaseState::$migrated = true;
}
}
protected function calculateChecksum(): string
{
$files = Finder::create()
->files()
->exclude([
'factories',
'seeders',
])
->in(database_path())
->in(base_path() . '/packages/Package1/database')
->ignoreDotFiles(true)
->ignoreVCS(true)
->getIterator();
$files = array_keys(iterator_to_array($files));
$checksum = collect($files)
->map(fn ($file) => sha1_file($file))
->implode('');
return md5($checksum);
}
protected function checksumFilePath(): string
{
$baseStoragePath = 'framework/testing/';
$parallelTestingToken = ParallelTesting::token();
if ($parallelTestingToken) {
return storage_path($baseStoragePath . '.phpunit.database.' . $parallelTestingToken . '.checksum');
}
return storage_path($baseStoragePath . '.phpunit.database.checksum');
}
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
{
return $this->isChecksumExists()
&& $this->checksumFileContents() === $this->calculateChecksum();
}
}
As a result, after this change, I was able to run an individual test locally (assuming the checksum file already exists) in under one second, which is 15 times faster than before. Our CI test run became 3.5 times faster than the initial start.
I call it a win.