# 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.

## PHP Tests

### Running Them

| Task                        | Command                                              |
| --------------------------- | ---------------------------------------------------- |
| Every PHP test, in parallel | `vendor/bin/pest --parallel`                         |
| One file                    | `vendor/bin/pest tests/Feature/Path/To/SomeTest.php` |
| Tests whose name matches    | `vendor/bin/pest --filter=test_administration_panel` |
| Tests in uncommitted files  | `vendor/bin/pest --dirty`                            |
| Stop at the first failure   | `vendor/bin/pest --bail`                             |
| Every test, with coverage   | `vendor/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](https://laravel-starter.entapp.northwestern.edu/guides/testing/#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.

### Where Tests Go

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.

| Class                                     | Test                                                        |
| ----------------------------------------- | ----------------------------------------------------------- |
| `App\Domains\Auth\LoginCodes`             | `tests/Feature/Domains/Auth/LoginCodesTest.php`             |
| `App\Providers\Filament\AppPanelProvider` | `tests/Feature/Providers/Filament/AppPanelProviderTest.php` |
| `App\Domains\User\Events\NetIdUpdated`    | `tests/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

```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.

### Factories

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

| State                                     | Gives 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.

### API Endpoint Tests

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:

```php
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](https://laravel-starter.entapp.northwestern.edu/features/api/).

### Panel Tests

* **`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.

### Test Shards

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:

```bash
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

Browser tests run through [Pest’s browser plugin](https://pestphp.com/docs/browser-testing) (`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.

### Setting Up

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

```bash
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.

### Running Them

| Task                            | Command                                                  |
| ------------------------------- | -------------------------------------------------------- |
| Every browser test, in parallel | `composer test:browser`                                  |
| One file                        | `vendor/bin/pest tests/Browser/Auth/SignInTest.php`      |
| Tests whose name matches        | `vendor/bin/pest --testsuite=Browser --filter="sign-in"` |
| Watch the browser               | `vendor/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.

### What They Check

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

| File                                | Checks                                                                                                                                                                                                                                |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `HomeTest.php`                      | The landing page, where guests and signed-in people are sent, and who sees the user menu’s administration link                                                                                                                        |
| `Auth/SignInTest.php`               | The sign-in page and the email code flow, with the code read from the faked email                                                                                                                                                     |
| `Auth/SignOutTest.php`              | Signing out from either panel                                                                                                                                                                                                         |
| `Pages/PublicPagesTest.php`         | Every 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.php`       | Every 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.php` | Every 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](https://laravel-starter.entapp.northwestern.edu/reference/chassis/#browser-testing) 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.

### Writing a Test

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

tests/Browser/WidgetsTest.php

```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();
});
```

| Helper                                | What 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.

### Adding a Page

`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.

### Excluding an Element

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

```php
// 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']);
```

### Debugging

* **`--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](https://trace.playwright.dev/).

### In CI

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](https://laravel-starter.entapp.northwestern.edu/guides/development-workflow/#continuous-integration).

### Rules That Keep Pages Passing

* **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:

  ```php
  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.
