How to use parallel testing in multi tenant laravel application?

Viewed 437

I am using Stancl Tenancy for a multi tenant Laravel 8 app. I have this testing well in a single core, however I want to take advantage of the new feature to run tests in parallel.

I think to do this sensibly I should create a new tenant for each database that is created by the parallelization. I've been trying to do this using the ParallelTesting::setUpTestDatabase callback, but am getting some odd behaviour.

When I go on to run tests in parallel, only database 5 of 7 (and consistently this one) has a tenant created for it:

AppServiceProvider

ParallelTesting::setUpTestDatabase(function ($database, $token) {
            $tenant = Tenant::create(['id'=>"testTenant$token",'name'=>"PHPUnit Process $token"]);
});

Without the call to create a model, the databases are being setup correctly:

ParallelTesting::setUpTestDatabase(function ($database, $token) {
            //$tenant = Tenant::create(['id'=>"testTenant$token",'name'=>"PHPUnit Process $token"]);
            Log::info("Setup called by $token My database connection is ".DB::connection()->getDatabaseName());
});
[2021-10-21 14:45:32] testing.INFO: Setup called by 4 My database connection is project_test_4
[2021-10-21 14:45:32] testing.INFO: Setup called by 3 My database connection is project_test_3
[2021-10-21 14:45:32] testing.INFO: Setup called by 7 My database connection is project_test_7
[2021-10-21 14:45:32] testing.INFO: Setup called by 6 My database connection is project_test_6
[2021-10-21 14:45:34] testing.INFO: Setup called by 5 My database connection is project_test_5

With the line to create the model uncommented, nothing is logged and only the last connection 5 gets a Tenant created for it. Subsequent tests then appear to use the correct connection as 4/5 of them fail for TenantNotIdentified (because they have no tenant or domain).

Can anyone spot the problem?

1 Answers

The setupTestDatabase callback is not helpful for this as the database has not yet been migrated. Multi tenancy can work with parallel testing (after a lot of work on this!) if:

  1. DatabaseTransactions is used rather than RefreshDatabbase
  2. You manually handle restarting database transactions for your tenant tests
  3. You do some of the cleanup manually

Register a new service provider

class ParallelTestingMultiTenantProvider extends ServiceProvider
{
    /**
     * Register services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    /**
     * Bootstrap services.
     *
     * @return void
     */
    public function boot()
    {
        ParallelTesting::setUpTestCase(function ($token, $testCase) {
            $tenant = Tenant::firstOrCreate(['id' => "test_{$token}_tenant", 'name' => "testTenant$token"]);
            $tenant->domains()->firstOrCreate(['domain'=>"testtenant$token.site.test"]);
        });

        ParallelTesting::tearDownProcess(function ($token) {
            try {
                DB::disconnect();
                config(['database.connections.mysql.database'=>"site_test_{$token}"]);
                DB::reconnect();
                if($tenant = Tenant::find("test_{$token}_tenant")){
                    $tenant->delete();
                }
            }
            catch(\Exception $e){
                if($e->getCode() !== 1049){ //Intercepts database does not exist errors - where tests do not use Database traits
                    throw $e;
                }
            }
        });
    }
}

Add to setup() in your base test classes

class TenantTestCase extends TestCase
{
    use DatabaseTransactions;

    public function setUp(): void
    {
        parent::setUp();
        if($this->isTestingInParallel()){
            $token = ParallelTesting::token();
            tenancy()->initialize(Tenant::find("test_{$token}_tenant"));
            DB::beginTransaction(); //Needed as otherwise database transactions don't work on the tenant database
            URL::forceRootUrl("https://testtenant$token.site.test");
        }
        else {
            //Test setup for when not running in parallel 
        }
    }

    protected function tearDown(): void
    {
        if($this->isTestingInParallel()){
            DB::rollBack();
        }
        else {
            //Tear down if not testing in parallel
        }
        parent::tearDown();
    }

    protected function isTestingInParallel() : bool
    {
        return (bool)ParallelTesting::token();
    }
}

This allowed me to reduce time for a whole test suite run from 1m 30 to 16s, whilst retaining the ability to run tests not in parallel for red green using if statements as above.

Related