Testing¶
The InteractsWithTenancy trait provides everything needed to write PHPUnit integration tests
that require tenant context. It handles tenant setup, schema creation, assertion helpers, and
automatic cleanup — with :memory: SQLite for fast, isolated tests.
Overview¶
InteractsWithTenancy is a PHPUnit trait for KernelTestCase subclasses. It provides:
initializeTenant(string $slug)— boot a clean tenant context with:memory:SQLiteclearTenant()— tear down tenant context and bootstrapper chainassertTenantActive(string $slug)— assert a specific tenant is activeassertNoTenant()— assert no tenant is activegetTenantService(string $class)— retrieve a service from the test container- Automatic
tearDown()that callsclearTenant()after every test
In-Memory SQLite Strategy
Each initializeTenant() call creates a fresh :memory: SQLite database for the tenant EM.
No external database, no test fixtures to clean up, and sub-millisecond schema creation. Tests
run in complete isolation — each test method gets a pristine, empty database.
Setup¶
Test Kernel¶
You need a test kernel configured for database-per-tenant mode. Use the bundled
TenancyTestKernel or your own application kernel:
<?php
declare(strict_types=1);
namespace App\Tests;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Tenancy\Bundle\Testing\InteractsWithTenancy;
class InvoiceRepositoryTest extends KernelTestCase
{
use InteractsWithTenancy;
public function testFindsOnlyTenantInvoices(): void
{
$this->initializeTenant('acme');
$this->assertTenantActive('acme');
// The tenant EM is switched to an :memory: SQLite DB for 'acme'
// Schema is created — ready to use
$em = static::getContainer()->get('doctrine')->getManager('tenant');
$invoice = new Invoice();
$invoice->setAmount('99.99');
$em->persist($invoice);
$em->flush();
$invoices = $em->getRepository(Invoice::class)->findAll();
$this->assertCount(1, $invoices);
$this->assertSame('99.99', $invoices[0]->getAmount());
}
}
How initializeTenant() Works¶
The method follows a strict sequence. Order matters — particularly the schema creation step:
1. Clear prior context
└─ TenantContext::clear()
└─ BootstrapperChain::clear()
2. Build synthetic Tenant
└─ new Tenant($slug, $slug)
└─ setConnectionConfig(['driver' => 'pdo_sqlite', 'memory' => true, 'path' => null])
3. Activate in TenantContext
└─ TenantContext::setTenant($tenant)
4. Run BootstrapperChain::boot()
└─ DatabaseSwitchBootstrapper::boot() → $connection->close()
(next query lazy-reconnects through TenantAwareDriver::connect(),
which merges the synthetic tenant's config over placeholder params)
└─ Other bootstrappers (cache namespace, etc.)
5. Reset tenant EM + create schema
└─ $registry->resetManager('tenant')
└─ SchemaTool::createSchema(allMetadata)
Schema creation must happen AFTER boot()
DatabaseSwitchBootstrapper::boot() calls $connection->close(). On SQLite
:memory: databases, close() followed by a new connect opens a fresh empty
database — the prior in-memory DB cannot be rediscovered. Schema creation must
happen after boot() completes, not before. The trait enforces this order.
The 'path' => null key in the connection config is explicit: it ensures array_merge()
in TenantAwareDriver::connect() nulls out any pre-existing path from the placeholder
connection, because DBAL's SQLite driver checks isset($params['path']) before checking
the memory flag.
Available Methods¶
initializeTenant(string $slug)¶
Sets up a clean tenant context with an :memory: SQLite database. Boots all registered
bootstrappers. Safe to call multiple times within a single test — clears prior state first.
clearTenant()¶
Clears the active tenant context and runs BootstrapperChain::clear(). Resets the DBAL
connection to the placeholder. Guards on hasTenant() — safe to call even when no tenant
was initialized.
assertTenantActive(string $slug)¶
Asserts that TenantContext has an active tenant with the given slug. Fails with a descriptive
message if no tenant is active or the slug does not match.
assertNoTenant()¶
Asserts that TenantContext has no active tenant. Useful for testing code paths that run outside
tenant context.
getTenantService(string $class)¶
Retrieves a service from the test container. Equivalent to
static::getContainer()->get($class) but typed for generics.
Automatic Teardown¶
tearDown() is overridden by the trait to call clearTenant() before parent::tearDown().
PHPUnit always runs tearDown() even when the test or setUp() throws — tenant context is
always cleaned up.
Two-Tenant Isolation Test¶
Testing that data created under one tenant is invisible to another:
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use App\Entity\Tenant\Invoice;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Tenancy\Bundle\Testing\InteractsWithTenancy;
class TenantIsolationTest extends KernelTestCase
{
use InteractsWithTenancy;
public function testTenantsHaveSeparateData(): void
{
$doctrine = static::getContainer()->get('doctrine');
// Boot tenant 'acme' and create an invoice
$this->initializeTenant('acme');
$acmeEm = $doctrine->resetManager('tenant');
$invoice = new Invoice();
$invoice->setAmount('100.00');
$acmeEm->persist($invoice);
$acmeEm->flush();
$this->clearTenant();
// Boot tenant 'demo' — completely separate :memory: database
$this->initializeTenant('demo');
$demoEm = $doctrine->resetManager('tenant');
$demoInvoices = $demoEm->getRepository(Invoice::class)->findAll();
// Demo cannot see acme's invoice
$this->assertCount(0, $demoInvoices);
$this->assertTenantActive('demo');
}
}
See Also¶
- Database-per-Tenant — how the connection switching works
- Getting Started — basic test setup
- Contributor Guide: Test Infrastructure