Optimizing Laravel Tests: Cutting Test Time by 87% in a Multi-Database Scenario

Testing

Testing is the foundation of high-quality development, regardless of the type of test, as different scenarios call for different testing solutions. Laravel offers a robust set of testing features, which we have leveraged extensively. However, a common issue that arises sooner or later is the growing number of tests, leading to increased test duration. Even if each test runs quickly, their cumulative runtime in sequential execution can become substantial.

Problem: Slow Sequential Tests

So, what can be done about this? The solution lies in running tests in parallel, a method well-documented in Laravel’s official documentation. This solution is straightforward… provided the application isn’t particularly complex. However, our case was different: we had three concurrent database connections, utilized two different database engines (a mix of SQL and NoSQL), had a multi-tenant service, substantial legacy code, and tests that heavily relied on predefined tenant data, leading to multiple tests often using the same data. Laravel’s standard solution wasn’t prepared for this level of complexity.

Even sequential execution of such tests was challenging, but using transactions, specifically the DatabaseTransactions trait, allowed us to mitigate potential issues. After each test, changes were rolled back, leaving the database “clean” and ready for the next test. Despite this, the tests still took a long time to run.

Initially, we considered refactoring the tests to use random data, thereby avoiding data collisions and allowing parallel execution without the risk of tests interfering with each other. Unfortunately, preliminary analyses revealed that this refactoring would be very time-consuming and costly, and since it doesn’t directly deliver functionality to the client, we needed another solution.

Solution: Custom Databases Handling

And we found one. By extending our database system, we could add some additional operations to be executed in each parallel process. For clarity: the native solution automatically creates multiple processes, each with its own database. This works well if there’s only one connection. Our solution involved adding support for all the other connections we used. Here are the necessary operations:

  1. For each process, create additional databases for connections B and C.
  2. If required, perform migrations for connections B and C.
  3. After the tests (or at the beginning of new ones), clean up the data and databases so they can be reused by the next process.

Here’s the actual implementation, first, create new provider:

php artisan make:provider DatabaseServiceProvider

Then edit new provider. This code is only example with some comments – it should be adjusted to specific scenario. Also, some polishing like using loops for connections is possible, but it depends on used databases:

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\ParallelTesting;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
use Illuminate\Testing\Concerns\TestDatabases;

class DatabaseServiceProvider extends ServiceProvider
{
    use TestDatabases;

    // This was required as main connection to avoid failure if there is no existing DB
    // Depends on driver, for example MySQL allows to connect even if there is no DB
    private static string $MAIN_CONNECTION = 'your-base-connection';

    public function boot(): void
    {
        if ($this->app->runningInConsole()) {
            $this->bootTestDatabase();
        }
    }

    protected function bootTestDatabase(): void
    {
        ParallelTesting::setUpProcess(function () {
            // We can skip for in memory (sqlite) - it's only for simple unit tests
            $this->whenNotUsingInMemoryDatabase(function ($database) {
                if (ParallelTesting::option('recreate_databases')) {
                    $this->cleanNoSQLConnection();
                    $this->dropTenantDb();
                }
            });
        });

        ParallelTesting::tearDownProcess(function () {
            $this->whenNotUsingInMemoryDatabase(function ($database) {
                if (ParallelTesting::option('drop_databases')) {
                    $this->cleanNoSQLConnection();
                    $this->dropTenantDb();
                }
            });
        });

        ParallelTesting::setUpTestCase(function () {
            $this->whenNotUsingInMemoryDatabase(function ($database) {
                DB::purge();

                // Switch to proper databases based on current process token
                $token = ParallelTesting::token();
                $masterDb = empty($token) ? 'test_main' : 'test_main_test_' . $token;

                config()->set(
                    'database.connections.main.database',
                    $masterDb,
                );
                config()->set(
                    'database.connections.tenant.database',
                    $this->getCurrentTenantDb(),
                );
                config()->set(
                    'database.connections.nosql.database',
                    $this->getCurrentNoSQLDb(),
                );
            });
        });


        ParallelTesting::setUpTestDatabase(function (string $database, int $token) {
            $this->whenNotUsingInMemoryDatabase(function ($database) {
                $this->cleanNoSQLConnection();
                $this->dropTenantDb();

                // Crete all required DBs for tests and migrate them
                $mainDB = $this->getCurrentTenantDb();
                Schema::connection(self::$MAIN_CONNECTION)->createDatabase($mainDB);
                Config::set('database.connections.tenant.database', $mainDB);

                $this->migrateDatabases();
            });
        });
    }

    protected function getCurrentTenantDb(): string
    {
        $token = ParallelTesting::token();
        if (!empty($token)) {
            return 'test_tenant_' . $token;
        } else {
            return 'test_tenant';
        }
    }

    protected function getCurrentNoSQLDb(): string
    {
        $token = ParallelTesting::token();
        if (!empty($token)) {
            return 'test_nosql_test_' . $token;
        } else {
            return 'test_nosql';
        }
    }

    protected function cleanNoSQLConnection(): void
    {
        $dbName = $this->getCurrentNoSQLDb();
        Config::set('database.connections.nosql.database', $dbName);
        // Implementation: cleanup on no sql db
    }

    protected function dropTenantDb(): void
    {
        // Implementation: based on DB engine
    }

    protected function migrateDatabases(): void
    {
        // Only example
        Artisan::call('migrate:fresh', [
            '--database' => 'main',
            '--path' => database_path('migrations/main'),
            '--realpath' => true,
            '--schema-path' => $this->schemaPath('main'),
        ]);

        Artisan::call('migrate:fresh', [
            '--database' => 'tenant',
            '--path' => database_path('migrations/tenant'),
            '--realpath' => true,
            '--schema-path' => $this->schemaPath('tenant'),
        ]);
    }

    protected function schemaPath(string $conn): string
    {
        return database_path('schema/test-' . $conn . '-' . '-schema.sql');
    }
}

Finally add new provider to bootstap/providers.php list:

App\Providers\DatabaseServiceProvider::class

The result? Test time dropped from nearly 24 minutes to just 3! This was a huge improvement, noticeable both in our CI/CD pipelines and during local development.

We quickly realized we could further speed up operations by using the migration squash mechanism. Instead of executing numerous migrations each time, we simply ran SQL queries based on a pre-prepared, clean database dump – a SQL file created after all migrations. It’s already added in code presented above. The result? We shaved off another minute from the test time. While it might not seem spectacular, percentage-wise, it’s a significant improvement.

Huge Win & Moral

Of course, the tests still need improvements, but they now run in isolation without impacting each other. This means they can be modified and improved gradually, without rushing and without delaying the delivery of new features. The significantly shorter test duration offers several advantages:

  • Reduced infrastructure costs due to shorter process runtimes.
  • Faster code delivery as developers wait less for feedback from pipelines.
  • Reduced delays during local development, positively affecting the entire software creation and delivery process.

The moral of this story? If you face a problem that seems incredibly complex, if the solution appears to be highly demanding and tedious… Step aside, focus on something else for a moment. Clear your mind and allow new, creative ideas to surface, helping you bypass the obstacle instead of dismantling it brick by brick. This approach works, as long as we give ourselves a moment for creativity.

How to Restore Focus and Wellbeing

Last year I have read amazing book Stolen Focus: Why You Can’t Pay Attention by Johann Hari. It was a part of checking what can cause issues during my life, why I sometimes feel bad and totally overwhelmed. This book does not resolve issues but gives a lot of information what can we do, to improve daily basics. Of course, as in many cases, after reading for a while I had some plans in my head, but then came other responsibilities and I lost them. In effect, last quarter was hard, I think the hardest in my carrer because I was even considering leaving the IT industry, temporarily or permanently.

There were a lot of factors: stress in work, some changes I did not accepted, other people actions and a lot of stuff on my head, not only in work, but also in personal life. I was “on the edge” when I was reminded of the most important principle of stoicism: focus on what you can control. So, I have decided to look for issues in my behavior and fix them, work on them to recover not only focus, but general wellbeing. During last few weeks, I have decided to cut, remove a lot of things from my life, especially digital stuff. Some of them may look strange, but results are amazing, especially for me, so I have decided to write a post about that, maybe it will help.

Continue reading “How to Restore Focus and Wellbeing”

ChatGPT Over a Year Later

Do you use ChatGPT or other, similar tool? I can say, I use it almost every day and for multiple purposes. Not only because of work – but it is probably the most beneficial in this area, but also in my personal life. To check some information, to get advice, to prepare trip plans or even to not forget about something important, when I will prepare to something. During last year it was transformed from some nice, maybe a bit exotic stuff into powerful tool we can – and maybe even should – utilize to simplify our lives and be more efficient. I know there is a lot of articles about ChatGPT, but I’ve decided to write what I think about that, also with some comparison to other “revolutions” we observed in last few years.

Continue reading “ChatGPT Over a Year Later”

Stoic self-review: two months later

At the end of last year, I became interested in Stoic philosophy and found something in it that attracted me a lot – above all, the values it follows. In all humility: stoicism is not an end to be achieved in itself, it is a process, it is a path in which one strives for some form of perfection. I still have a long way to go, or to put it more accurately, this road will never end.

Continue reading “Stoic self-review: two months later”