Skip to content
MCG Filament CMS Demo

Testing your work

This page explains how to test code built on the package. It has two parts. The first part, for a developer testing the blocks, content types and layouts they have built in their own application, needs only the application's own test suite. The second part, for a developer working on the package itself, needs the package checked out and describes the package's own suite, a known limitation of the test harness, and the suite's memory limit.

Run the smallest set of tests that covers the change. In a terminal at the site's root:

Shell

php artisan test --compact --filter=PricingTable

Block tests

The package ships one testing helper: the trait Mcg\FilamentCms\Testing\InteractsWithBlocks, which the package also uses on its own blocks. Add it to the application's test cases in tests/Pest.php:

tests/Pest.php PHP

use Mcg\FilamentCms\Testing\InteractsWithBlocks;

pest()->extend(Tests\TestCase::class)->use(InteractsWithBlocks::class)->in('Feature', 'Unit');

The trait adds three methods to every test case. Both validation methods run the block's rules() against $block->cast($data), so the block's defaults() are merged into the data before it is validated.

Test-case methods provided by InteractsWithBlocks
Method Returns
renderBlock(Block $block, array $data = []) The block's rendered HTML, as a string
blockPassesValidation(Block $block, array $data) true or false
blockValidationErrors(Block $block, array $data) The names of the fields that failed validation, as an array

The test shorthand

The package does not ship the shorthand used in its own examples. The package's suite wraps the trait's methods in a global renderBlock() function and two custom Pest expectations, toPassValidation() and toFailValidation(). They are defined in the package's tests/Pest.php, which is not included in the distributed package. An application has none of them until it defines them.

A test copied unchanged from an example in this documentation therefore fails with Call to undefined function renderBlock() or Undefined expectation: toPassValidation. The message suggests a missing dependency, but the cause is that the three definitions below have not been added.

  1. In the application's tests/Pest.php, below the pest()->extend() line, add the three definitions:

tests/Pest.php PHP

use Mcg\FilamentCms\Contracts\Block;

function renderBlock(Block $block, array $data = []): string
{
    return test()->renderBlock($block, $data);
}

expect()->extend('toPassValidation', function (array $data) {
    $errors = test()->blockValidationErrors($this->value, $data);

    expect($errors)->toBe([], 'Block failed validation on: '.implode(', ', $errors));

    return $this;
});

expect()->extend('toFailValidation', function (array $data) {
    expect(test()->blockValidationErrors($this->value, $data))->not->toBe([]);

    return $this;
});

The package does not ship these three wrappers because the global namespace of a test suite belongs to the application. If the package defined a global renderBlock() function, it would collide with any function of the same name the application already defines, and the collision would be a fatal error at boot that the application could not disable.

Assertions for a block

PHP

it('renders its body', function () {
    expect(renderBlock(new Quote, ['body' => 'Hello']))->toContain('Hello');
});

it('applies the width preset', function () {
    expect(renderBlock(new Quote, ['body' => 'x', 'width' => 'bleed']))
        ->toContain('cms-block--bleed');
});

it('validates', function () {
    expect(new Quote)->toPassValidation(['body' => 'Hello'])
        ->and(new Quote)->toFailValidation(['body' => '']);
});

renderBlock() registers the block with BlockRegistry and renders it through the <x-cms::block> component that renders blocks on real pages. One call therefore exercises cast(), withView() and the view together, including the merge of $cmsAttrs into the block's wrapper element.

Keep the width assertion in every block's tests. A view that does not output $cmsAttrs renders its own content correctly but discards the five settings an editor applies through the common fields: the width preset, the custom classes, the anchor and the two ARIA attributes. No error is raised; see Known traps. The width preset is the only one of the five that produces a class name known in advance (cms-block--{width}), so asserting on it is the shortest test that proves the merge is present.

Assert on what the markup contains, not on the whole string. A block's wrapper carries attributes the block does not declare itself, and a test that compares the rendered output byte for byte fails the next time a common field is added to every block.

Content type tests

A content type is a plain PHP class, so most of its behavior can be tested without the database. Assert three properties:

  1. The content type registry lists the type's key.
  2. The type reports itself as routable.
  3. Content created with the type receives the path its prefix implies.

PHP

use Mcg\FilamentCms\Support\ContentTypeRegistry;
use Mcg\FilamentCms\Support\ContentWriter;

it('is discovered', function () {
    expect(app(ContentTypeRegistry::class)->keys())->toContain('case_study');
});

it('is addressable', function () {
    expect((new CaseStudy)->isRoutable())->toBeTrue();
});

it('lands under its own path', function () {
    $study = app(ContentWriter::class)
        ->create(new CaseStudy, ['title' => 'Acme', 'slug' => 'acme']);

    expect($study->path)->toBe('/case-studies/acme');
});

The third test assumes the type declares pathPrefix(): '/case-studies'. The public routes match on that prefix. A typo in it produces content that saves and publishes without an error and then returns 404 at the URL the panel displays.

The discovery test detects the most common fault. A content type class in the wrong namespace or the wrong directory produces no error; it is missing from the content type picker, which looks like a panel fault rather than a file placement fault. See Content types.

Layout tests

A layout is discovered from its filename, so a misplaced layout file is detected by asserting that LayoutRegistry lists it:

PHP

use Mcg\FilamentCms\Support\LayoutRegistry;

it('is discovered', function () {
    expect(app(LayoutRegistry::class)->all())
        ->toHaveKey('cms-layout::marketing');
});

To test what a layout renders, publish a page that uses the layout and request the page's path. The package's model factories are included in the distributed package, so creating a page takes two calls:

PHP

use Mcg\FilamentCms\Models\Content;
use Mcg\FilamentCms\Support\VersionManager;

it('renders the sidebar region', function () {
    $page = Content::factory()->create(['slug' => 'about', 'path' => '/about']);

    app(VersionManager::class)->save($page, [
        'attributes' => ['title' => 'About us', 'layout' => 'cms-layout::marketing'],
        'blocks' => [[
            'type' => 'rich_text',
            'id' => 'b1',
            'region' => 'sidebar',
            'data' => ['body' => '<p>Aside</p>'],
        ]],
    ]);
    app(VersionManager::class)->publish($page);

    $this->get('/about')->assertOk()->assertSee('Aside', escape: false);
});

The publish() call is required. An unpublished page returns 404 to a guest, so a test without it fails on the status code rather than on the assertion it was written for. See Layouts.

The Filament modal limitation under Testbench

Filament v5 under Testbench allows one Livewire component that mounts an action modal per PHP process. After one test has mounted an action modal, the next Livewire::test call in the same process fails while rendering Filament's action-modals partial from stale state. The exception is RootTagMissingFromViewException, which reports a missing root element in markup that the test author did not write.

The failure is not caused by a particular action. Mounting the unmodified compare action twice fails in the same way, and so does calling a standard action such as unpublish after any other action. Pest runs one test file in a single process, so moving the tests into separate files does not avoid the failure either. A suite in which every file passes when run alone can still fail when run as a whole.

Four tests in the package are skipped with a message naming this limitation:

  • tests/Feature/Admin/MediaResourceTest.php, line 31: the message says to run the file on its own.
  • tests/Feature/Admin/MediaResourceTest.php, line 52: the message says the success path is covered by an earlier test and that acceptedFileTypes on MediaResource::form() enforces the rule.
  • tests/Feature/Admin/PreviewShareLinkTest.php, line 85: the message gives the command vendor/bin/pest --filter="version on the row".
  • tests/Feature/Admin/VersionsRelationManagerTest.php, line 52: the message says VersionManager::restore() is covered directly by VersionManagerPublishTest.

To test an action despite the limitation, test the action's behavior instead of its modal. A Filament action holds its work in a closure. Calling that closure directly exercises what the test is about (which record is written, whether it is published, what the page then shows) and leaves the modal rendering, which the harness cannot perform, to Filament's own tests. When the modal itself is the subject of the test, put the test in its own file and run that file by name.

Running the package's suite

Shell

composer test          # everything, serially, ~588s
composer test:unit     # tests/Unit, ~25s
composer test:feature  # tests/Feature, ~8 min
composer test:admin    # tests/Feature/Admin, the panel tests, ~60s
composer test:pgsql    # everything against PostgreSQL, ~10 min; run before a release
composer test:mysql    # everything against MySQL; never yet run
composer lint          # Pint, seconds
composer types         # PHPStan, seconds

composer test must pass before a commit. After changing PHP files, format them with Pint:

Shell

vendor/bin/pint --dirty --format agent

Running composer test:pgsql before a release

SQLite accepts queries that PostgreSQL rejects, and this has hidden three real defects: a foreign-key ordering bug, a case-sensitive LIKE, and a missing ESCAPE clause. Each one passed on SQLite and failed on PostgreSQL. None of them could have been found by reading the code, because the code was correct for SQLite, the database the default suite runs on. Run composer test:pgsql before every release.

The absence of a parallel test script

The package has no script that runs the suite in parallel. Running the suite across eight processes takes 67 seconds instead of 588, and about ten tests fail on each run, never the same ten. The worker processes race on Laravel's compiled service manifest inside the single shared Testbench skeleton, so each failure is caused by process scheduling, not by a defect in the code.

A command that reports false failures on every run is not trusted. After the first time a red result turns out to be a scheduling failure, every later red result is re-run instead of investigated. The parallel script will not be added until the workers are isolated from each other.

The suite's memory limit

phpunit.xml sets memory_limit to 1G. The whole suite runs in one process and keeps Laravel, Filament and Livewire loaded for its entire duration, so peak memory use is determined by the suite as a whole, not by any single test. Peak use exceeded PHP's common default of 512M when a seventh common field was added to every block.

When the limit is reached, the report names the wrong test. There is no failing assertion and no test name: PHP stops wherever execution is when the limit is reached, so the report is a fatal error inside an unrelated test. The failure appeared as:

Plain text

Fatal error: Allowed memory size of 536870912 bytes exhausted
  at Illuminate\Http\Testing\FileFactory.php:95

That message suggests a fault in fake image uploads, and there was none. Two rules follow from this:

  1. A run that ends in a fatal error has not passed, whatever results were printed before it.
  2. A pipeline such as pest | sed reports the exit code of sed, not of Pest, so a pipeline can report success while hiding a fatal error. Capture Pest's exit status before any pipe.

To confirm whether a specific change is what crossed the limit, disable only that change in place and run the whole suite at the previous limit. One run answers the question. The answer determines the fix: a change that pushed peak memory past a limit that was already close requires a higher limit, and a change that leaks memory requires a code fix.