Skip to content

Testing

The starter has two kinds of test, and Pest runs both. PHP tests in tests/Unit and tests/Feature cover actions, models, controllers, commands and Filament pages. Browser tests in tests/Browser drive the application in Chromium, sign in, fill in forms, and check every page the starter ships with axe.

TaskCommand
Every PHP test, in parallelvendor/bin/pest --parallel
One filevendor/bin/pest tests/Feature/Path/To/SomeTest.php
Tests whose name matchesvendor/bin/pest --filter=test_administration_panel
Tests in uncommitted filesvendor/bin/pest --dirty
Stop at the first failurevendor/bin/pest --bail
Every test, with coveragevendor/bin/pest --parallel --coverage

--filter takes a test class or method name, or a regular expression. --coverage needs the PCOV or Xdebug extension. phpunit.xml runs the Unit and Feature suites by default, so these commands leave out the browser tests.

Tests run against the database in DB_PHPUNIT_DATABASE, through the phpunit connection in config/database.php. Tests\TestCase refreshes and seeds it, and turns off Vite. With --parallel, Laravel creates a copy for each process (your_project_name_test_test_1 and so on), so the database user needs permission to create databases.

phpunit.xml sets the rest of the test environment: the sync queue, the array mailer, cache and session, and the optional features (the API, local sign-in, Contact Support, the changelog and the Wildcard photo sync) turned on so their tests run.

Tests mirror the namespace of the class they test, under tests/Feature or tests/Unit. A regression test goes in the existing test file for that class.

ClassTest
App\Domains\Auth\LoginCodestests/Feature/Domains/Auth/LoginCodesTest.php
App\Providers\Filament\AppPanelProvidertests/Feature/Providers/Filament/AppPanelProviderTest.php
App\Domains\User\Events\NetIdUpdatedtests/Unit/Domains/User/Events/NetIdUpdatedTest.php

The starter’s tests are PHPUnit classes with test_ methods, which Pest runs. Mark each class with #[CoversClass], or #[CoversTrait] when it tests a trait:

tests/Feature/Domains/Auth/LoginCodesTest.php
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;
#[CoversClass(LoginCodes::class)]
final class LoginCodesTest extends TestCase
{
public function test_a_local_account_gets_a_code_that_signs_it_in(): void
{
// ...
}
}

The coverage exclusions in phpunit.xml must not include a class or trait that a test names in one of these attributes; PHPUnit warns when coverage runs.

Create models with factories rather than by hand. The User factory’s states:

StateGives you
(none)An SSO user with the Northwestern User role, assigned after the user is created
->affiliate()A local (passwordless) user with the Affiliate affiliation and no roles
->api()An API user with an api- username and no roles
->faculty(), ->staff(), ->student()An SSO user with the Northwestern User role and that primary affiliation

Mock external services such as Northwestern\SysDev\SOA\DirectorySearch rather than calling them, and use Event::fake() and Queue::fake() to assert on dispatched events and jobs.

Tests of an API endpoint extend Tests\ApiTestCase. Its setUp() creates an API user ($this->apiUser) with a service client, exchanges the client at /oauth/token for an access token ($this->accessToken), and skips the test when the API is disabled. Other tests can get a token the same way with the Tests\Concerns\IssuesServiceClientTokens trait. Tests\Concerns\IssuesPersonalAccessTokens gives a person the permission and a personal access token with the scopes you name, for testing routes that require a scope, and Tests\Concerns\RunsAuthorizationCodeFlow registers an application or MCP client and runs the consent and token exchange as a person would. A subclass names its endpoint and HTTP methods:

use Tests\ApiTestCase;
#[CoversClass(WidgetApiController::class)]
final class WidgetApiControllerTest extends ApiTestCase
{
public function endpoint(): string
{
return '/api/v1/widgets';
}
public static function methods(): array
{
return ['get', 'post'];
}
public function test_index_lists_widgets(): void
{
$this->authenticatedGet()->assertOk();
}
}

authenticatedGet(), authenticatedPost(), authenticatedPut(), authenticatedPatch(), authenticatedDelete() and authenticatedJson() send the request with the token’s bearer header. The inherited test_requires_authentication() checks that each method in methods() answers 401 without a token. See API.

  • app is the default panel. Livewire tests of administration pages must call Filament::setCurrentPanel(AdministrationPanelProvider::ID) first, usually in setUp().
  • Render hooks persist for the rest of a test. A panel registers its render hooks when it boots, and they stay registered for the rest of the test. When you assert on panel chrome, such as the footer, request /app and /administration in separate tests. tests/Feature/Providers/Filament/AppPanelProviderTest.php shows the pattern.

CI splits the PHP tests into three shards that run in parallel jobs (--shard 1/3, 2/3 and 3/3). tests/.pest/shards.json records how long each test class took, and Pest uses those timings to give every shard about the same amount of work. A test class missing from the file is assigned round-robin, and Pest prints a warning that the file is out of date.

Refresh the timings after adding test classes:

Terminal window
vendor/bin/pest --parallel --update-shards

Pest writes tests/.pest/shards.json only when the run passes. Commit the file. To change the number of shards, edit both the matrix.shard list and the --shard ${{ matrix.shard }}/3 argument in .github/workflows/check-pr.yml.

Browser tests run through Pest’s browser plugin (pestphp/pest-plugin-browser) and Playwright. The application runs inside the test process, as it does in a feature test, so there’s no server to start and no database snapshot to restore. Tests\TestCase refreshes and seeds the database as it does for a feature test, and factories, actingAs() and fakes such as Mail::fake() apply to every request the browser makes.

Install the JavaScript dependencies and Playwright’s Chromium, and build the assets:

Terminal window
pnpm install
pnpm exec playwright install chromium
pnpm build

Pages load the application’s real assets, so run pnpm build again after you change CSS or TypeScript, or add Tailwind classes to a view.

TaskCommand
Every browser test, in parallelcomposer test:browser
One filevendor/bin/pest tests/Browser/Auth/SignInTest.php
Tests whose name matchesvendor/bin/pest --testsuite=Browser --filter="sign-in"
Watch the browservendor/bin/pest --testsuite=Browser --headed

composer test:browser runs vendor/bin/pest --testsuite=Browser --parallel. The browser tests are their own suite in phpunit.xml, so vendor/bin/pest --parallel doesn’t run them. They use the same test database and phpunit.xml environment as the PHP tests.

tests/Browser holds Pest test functions. tests/Pest.php binds them to Tests\BrowserTestCase, which extends Tests\TestCase.

FileChecks
HomeTest.phpThe landing page, where guests and signed-in people are sent, and who sees the user menu’s administration link
Auth/SignInTest.phpThe sign-in page and the email code flow, with the code read from the faked email
Auth/SignOutTest.phpSigning out from either panel
Pages/PublicPagesTest.phpEvery public page, also at a phone’s width; the email code step; a changelog entry; the error pages from 401 to 503 and the database-paused page
Pages/AppPanelPagesTest.phpEvery app panel page a signed-in person can open, plus the lockdown page, the changelog, the not-found page and /administration, which they can’t open; the dashboard on a phone; the announcement banner; the OAuth consent screen
Pages/AdministrationPagesTest.phpEvery administration page, and the app panel pages only administrators see; record pages for users, an API user, roles, an announcement, a support ticket and an audit; the MCP client consent screen; the dashboard on a phone

The page checks run in light and dark mode, and use Chassis’s expectations. toBeHealthy() fails a page for any of these:

  • An axe violation of any impact. Transitions and animations are settled while axe runs, so colors are checked as they end up.
  • A browser error: a console.error(), an uncaught exception, an unhandled promise rejection or a failed Livewire request.
  • An exception the application reported while serving the page, Livewire updates included.
  • An image that failed to load.

A failure lists every problem on the page at once. toBeAccessible(), toHaveNoClientErrors() and toHaveNoServerErrors() check one kind of problem; toBeHealthyInEachTheme() and toBeHealthyOnMobile() check a page in both themes or at a phone’s width; and toAllBeHealthy() checks a list of paths and names every page that fails.

tests/Pest.php turns off two axe rules for every page: duplicate-id, and empty-table-header, because Filament leaves the actions column’s header cell empty in every table.

A test signs in with a helper from Tests\BrowserTestCase, makes its data with factories, and visits a page:

tests/Browser/WidgetsTest.php
use App\Domains\Widget\Models\Widget;
it('lists widgets for a signed-in person', function () {
$this->signInAsNorthwesternUser();
Widget::factory()->create(['name' => 'Blue Widget']);
$page = visit('/app/widgets')->assertSee('Blue Widget');
expect($page)->toBeHealthy();
});
HelperWhat it does
$this->signInAsSuperAdministrator()Signs in as a new user with the Super Administrator role, which holds every permission
$this->signInAsNorthwesternUser()Signs in as a new user with only the Northwestern User role
$this->signIn($user)Signs in as a user you made, with every column loaded as a real sign-in would
$this->useDarkMode()Visits every later page in the test in dark mode, including the pages an expectation visits

A few things about the browser plugin that are easy to miss:

  • @name finds a test hook. click('@sign-in-link') finds the element whose data-testid (or data-test) attribute is sign-in-link. The starter’s views mark the elements tests use with data-testid.
  • A selector without CSS punctuation is matched as text. assertSeeIn('h1', ...) looks for an element containing the text “h1”. Write main h1:first-of-type instead.
  • visit() loads nothing until you use the page. The first assertion or action loads it.
  • inDarkMode() returns a new page rather than changing the one you have.
  • Use Chassis’s FilamentPage::press($page, 'Create') for Filament buttons. Pest’s own press() clicks the first element with that text, which on a Filament page is often a breadcrumb. FilamentPage also opens the user menu, confirms and cancels modals, and asserts on notifications and field errors.

FilamentPages::in('app') and FilamentPages::in('administration') find each panel’s pages from the panel itself: each resource’s index and create pages, and each page and dashboard the signed-in person can open. A new Filament page or resource is checked without any change to the tests.

Add the pages they can’t find to the file for the people who can see them:

  • A record page, such as a view or edit page, needs a record. Add it to the dataset of the record pages test in Pages/AdministrationPagesTest.php, with a closure that makes the record and returns the path.
  • A public page goes in the public pages dataset in Pages/PublicPagesTest.php, which also checks it at a phone’s width.
  • A page that needs data to show its interesting state, such as a banner or a consent screen, gets a test of its own that makes the data and asserts that the state is visible before it checks the page.
  • A new error page goes in the list of statuses in the error page test in Pages/PublicPagesTest.php. ErrorPages::path($status) registers a test route that answers with that status.

When a third-party widget has a violation you can’t fix, exclude its element by selector and say why in a comment:

// The @pierre/diffs viewer renders syntax colors on its change backgrounds inside a shadow
// DOM, and none of its themes meet color contrast there.
expect(visit("/administration/audits/{$audit->getKey()}"))->toBeHealthyInEachTheme(exclude: ['diffs-container']);
  • --headed shows the browser while the tests run.
  • --debug shows the browser and pauses at the first failure, so you can inspect the page. It doesn’t work with --parallel.
  • ->debug() on a page runs only that test, in a visible browser, and pauses there. Never commit it: CI fails when a browser test calls ->debug() or ->tinker().
  • Screenshots. A failed assertion saves a screenshot of the page to tests/Browser/Screenshots/, which git ignores.
  • --trace keeps a Playwright trace of each failed test in tests/Browser/Traces/. Open one with pnpm exec playwright show-trace or at trace.playwright.dev.

The 🌐 Browser Tests job in .github/workflows/check-pr.yml starts PostgreSQL and RustFS. RustFS is S3-compatible, and the CI environment stores files in it as production stores them in S3, so a test that uploads a file exercises real signed URLs and CORS. The job then installs Playwright’s Chromium, builds the assets and runs the suite with --parallel --ci. --ci runs every test even when one is marked ->only(). The job publishes the results as the 🌐 Browser Test Results check, uploads the screenshots of failed tests as an artifact, and prints storage/logs/laravel.log. See Continuous Integration.

  • Filament callout headings are always <h4>. A callout directly under a page title or a top-level section skips heading levels and fails axe. Leave the heading off and open the description with bold text:

    Callout::make()
    ->description(new HtmlString(
    '<strong>Exports run overnight.</strong> Large reports arrive by email the next morning.'
    ))
    ->warning(),
  • Clickable table rows wrap every cell in a link. A table with a record URL renders each cell as an <a>, so a cell that can be empty becomes a link with no text. Give that column a ->placeholder(), such as ->placeholder('—').

  • Tailwind skips what git ignores. The themes compile classes from every tracked file, but a class that appears only in a package’s views under vendor/ needs an @source line in the theme, as vendor/filament/** has, or it does nothing.