Skip to content

Testing

Tests are PHPUnit classes, about 500 files, nearly all feature tests on a real database.

phpunit.xml sets DB_CONNECTION=mariadb and DB_DATABASE=testing. The schema uses features SQLite doesn’t support, and matching production avoids migration surprises. Don’t switch the test config to SQLite to make a test pass.

The testing database must exist, with privileges for the db user:

Terminal window
ddev exec mysql -e "CREATE DATABASE IF NOT EXISTS testing;"
ddev exec mysql -e "GRANT ALL PRIVILEGES ON testing.* TO 'db'@'%';"

phpunit.xml also sets the queue to sync, cache and session to array, mail to array, and Scout’s driver to null with a phpunit_ prefix and a dead Meilisearch host, so a test can never touch your dev index. A test that wants to see search work turns the driver on and fakes the queue:

config(['scout.driver' => 'meilisearch']);
Queue::fake();
Task Command
Everything ddev artisan test
One suite ddev artisan test --testsuite=Modules
One file ddev artisan test app-modules/sales/tests/Feature/CustomerTimelineTest.php
By name ddev artisan test --filter=ValidDomainTest

There are two suites:

Suite Directory
Unit tests/Unit, which holds app-level tests such as AuthorizationMatrixTest, ModuleDependencyTest and the seeder and import tests
Modules app-modules/*/tests, files ending in Test.php

A module test file that doesn’t end in Test.php is silently not run. The full run takes several minutes, so use a filter while you work and run everything at checkpoints.

RefreshDatabase resets the database under any other run using it. phpunit.xml’s values are not forced, so the process environment wins. Give the second run its own database (create and grant it as above):

Terminal window
ddev exec "DB_DATABASE=testing_panel php artisan test app-modules/customer-panel"
  • Extend Tests\TestCase and use RefreshDatabase. Name tests test_a_sentence_saying_what_happens.
  • No test reaches the network. Tests\TestCase::setUp() calls Http::preventStrayRequests(). Fake what you need with Http::fake().
  • Use the factories in each module’s database/factories.
  • Fake what the test isn’t about: Queue::fake(), Mail::fake(), Storage::fake('local'), Carbon::setTestNow(...).
  • Module tests may depend on modules that their src/ doesn’t: ModuleDependencyTest only scans src/.
  • Shared fixtures live beside the tests, in tests/Fixtures and tests/Concerns.

The pattern used across the modules. Set the panel, sync the roles, sign in as a real role, then drive the page with Livewire::test:

protected function setUp(): void
{
parent::setUp();
Filament::setCurrentPanel('admin');
app(RoleDefinitions::class)->sync();
$manager = User::factory()->create();
$manager->assignRole('manager');
$this->actingAs($manager);
}
public function test_a_rule_is_built(): void
{
Livewire::test(CreateAutomationRule::class)
->fillForm(['name' => 'Paint red gadgets blue', /* ... */])
->call('create')
->assertHasNoFormErrors();
}

Tests\TestCase has shortcuts for the signing in: actingAsRole('manager') and actingAsSuperAdmin() sync the roles for you. Test as the role that does the job, not as a super admin, unless the test is about the resource’s behaviour and not about access: a super admin passes every check.

Other patterns you will see:

  • Table and record actions: ->callAction(TestAction::make('promote')->table($record), [...]), with Filament\Actions\Testing\TestAction.
  • Edit pages: Livewire::test(EditAutomationRule::class, ['record' => $rule->id]), then ->call('save').
  • Relation managers: pass ownerRecord and pageClass, as in AutomationRuleResourceTest.
  • The customer panel: Filament::setCurrentPanel('customer') and $this->actingAs($customer, 'customer').
  • Who can open what: tests/Unit/AuthorizationMatrixTest.php asserts each role against each screen through $resource::canViewAny(). When a role’s capabilities change on purpose, update its matrix.

A storefront request needs a site whose domain matches the host, with content rules and a projected document. From app-modules/catalog/tests/Feature/ProductPageTest.php:

$site = Site::factory()->create(['domain' => 'shop.test']);
SiteContentRule::create([
'site_id' => $site->id, 'mode' => ContentRuleMode::Include,
'target_type' => Product::contentTypeHandle(),
'target_id' => null, 'include_descendants' => true, 'priority' => 0,
]);
$document = app(SiteDocumentIndexer::class)->index($site, $product);
$this->get('http://shop.test/'.$document->resolved_path)->assertOk();