Skip to content

Testing ​

Introduction ​

A module's tests live in its domain package, tests/, and run from an application that installs it — the module has no vendor of its own. kitloom:make-module sets this up: it maps the tests' namespace in the application's autoload-dev and adds a testsuite to its phpunit.xml.

json
"autoload-dev": {
    "psr-4": {
        "Acme\\Reviews\\Tests\\": "vendor/acme/reviews/tests/"
    }
}
xml
<testsuite name="Reviews">
    <directory>vendor/acme/reviews/tests</directory>
</testsuite>
bash
php vendor/bin/phpunit --testsuite Reviews

There are three kinds of tests, from the fastest:

KindWhereWhat it boots
Domainthe package's tests/a container and SQLite in memory with WordPress's tables
Admin screensthe application's tests/Featurethe whole application, as a signed-in user
Site pagesthe application's tests/Featurethe whole application, as a guest

Domain tests ​

The generated ReviewsTestCase creates WordPress's tables from kitloom/wp-schema's migrations — and the module's own — in an in-memory SQLite database, and puts the models in the ModelRegistry:

php
class ReviewTest extends ReviewsTestCase
{
    #[Test]
    public function the_model_sees_only_posts_of_its_type(): void
    {
        $this->insertPost('hello-world', 'post');
        $id = $this->insertPost('dune', Reviews::POST_TYPE);

        // Whether drafts show is asked of the signed-in user; a package test has no auth.
        $ids = Review::query()->withoutGlobalScope(PostPublishedScope::class)->pluck('ID');

        $this->assertSame([$id], $ids->map(fn (mixed $value): int => (int) $value)->all());
    }
}

They need no web server, no MySQL and no admin panel, and run in milliseconds. Test what the module decides here: how values are read and stored, what a query finds, what a part contains.

Data WordPress wrote

Put rows into the tables exactly as WordPress or the plugin left them — a serialize()d array, '1' for true — and check that the module reads them. That is how a site moved from WordPress keeps working. Keep such samples as fixtures next to the tests.

Admin screens ​

Admin tests belong to the application that runs the admin: they need the panel, the user model and the session. Put them in the application's tests/Feature.

Filament ​

Filament pages are Livewire components:

php
use Acme\Reviews\Filament\Resources\ReviewResource\Pages\CreateReview;
use Acme\Reviews\Filament\Settings\ReviewsSettingsPage;
use Kitloom\Settings\Facades\Settings;
use Livewire\Livewire;

#[Test]
public function a_review_is_saved_with_its_rating(): void
{
    $this->actingAs(User::factory()->create());

    $this->get('/admin')->assertOk()->assertSee('All Reviews');

    Livewire::test(CreateReview::class)
        ->fillForm(['post_title' => 'Dune', 'post_name' => 'dune', 'post_status' => 'publish', 'rating' => 4])
        ->call('create')
        ->assertHasNoFormErrors();

    $id = DB::table('posts')->where('post_name', 'dune')->value('ID');
    $this->assertSame('4', DB::table('postmeta')->where('post_id', $id)->where('meta_key', 'rating')->value('meta_value'));
}

#[Test]
public function the_settings_page_stores_an_option(): void
{
    $this->actingAs(User::factory()->create());

    Livewire::test(ReviewsSettingsPage::class)->fillForm(['reviews_out_of' => 10])->call('save');

    $this->assertSame('10', (string) Settings::get('reviews_out_of'));
}

Nova ​

Nova's API is plain HTTP. Post forms the way Nova's own browser client does — as form data, not a JSON body:

php
#[Test]
public function a_review_is_saved_in_nova(): void
{
    Gate::define('viewNova', fn () => true);
    $this->actingAs(User::factory()->create());

    $this->post('/nova-api/reviews', [
        'post_title' => 'Dune',
        'post_name' => 'dune',
        'post_author' => auth()->id(),
        'post_status' => 'publish',
        'post_date' => now()->toDateTimeString(),
        'post_content' => '<p>Spice.</p>',
        'rating' => 4,
    ], ['Accept' => 'application/json'])->assertCreated();
}

#[Test]
public function the_settings_page_stores_an_option_in_nova(): void
{
    Gate::define('viewNova', fn () => true);
    $this->actingAs(User::factory()->create());

    $this->postJson('/nova-vendor/kitloom-settings/pages/reviews-settings', ['reviews_out_of' => 10])
        ->assertNoContent();
}

The sidebar is checked through Nova's menu:

php
$this->get('/nova/dashboards/main')->assertOk();
$menu = collect(json_decode(json_encode(Nova::resolveMainMenu(request())), true))->keyBy('name');

$this->assertSame(['All Reviews', 'Add New Review', 'Settings'], collect($menu['Reviews']['items'])->pluck('name')->all());

Flexible fields ​

A Flexible field — repeaters in custom fields, redirect sources — is sent by the browser in a shape of its own, and Nova reads it only in that shape:

  • the field's value is a JSON string of rows: [{"layout": "source", "key": "c1a2", "attributes": {"c1a2__pattern": "old/", …}}];
  • each attribute of a row is prefixed with the row's key and two underscores;
  • ___nova_flexible_content_fields lists, as a JSON array, the attributes that are Flexible fields (a nested Flexible field gets its own {key}_____nova_flexible_content_fields).
php
$this->post('/nova-api/redirections', [
    'url_to' => '/new/',
    'header_code' => '301',
    'status' => 'active',
    'sources' => json_encode([
        ['layout' => 'source', 'key' => 'c1a2', 'attributes' => ['c1a2__pattern' => 'old/', 'c1a2__comparison' => 'exact']],
    ]),
    '___nova_flexible_content_fields' => json_encode(['sources']),
], ['Accept' => 'application/json'])->assertCreated();

Not as JSON

The same data sent with postJson() fails with a server error: the Flexible package reads its rows by form-data keys. Use post() with Accept: application/json.

Site pages ​

A page as JSON shows every part a module provides, whatever the active theme:

php
#[Test]
public function a_review_page_has_its_rating(): void
{
    $id = DB::table('posts')->insertGetId([/* a published review */]);
    DB::table('postmeta')->insert(['post_id' => $id, 'meta_key' => 'rating', 'meta_value' => '4']);

    $this->getJson('/reviews/dune/')
        ->assertOk()
        ->assertJsonPath('kind', 'single')
        ->assertJsonPath('review.rating', 4);

    $this->get('/reviews/')->assertOk()->assertSee('Dune');
}

?include=review,content limits the JSON to the parts named.

In the kitloom repository ​

The kitloom packages are tested from the two skeletons that install them, kitloom-filament-skeleton (domain packages, themes, Filament admins) and kitloom-cms-skeleton (Nova admins). One command runs everything a change has to pass:

bash
tools/check.sh            # Pint, PHPStan, both skeletons' tests
tools/check.sh tests      # or: pint | phpstan | fix | baseline
php tools/check-package-deps.php   # every kitloom package a package uses is in its require
php tools/check-psr4.php           # namespaces match paths
php tools/licenses.php --check     # license fields; no free package requires a paid one

A module generated into the repository is added to the PHPStan configs and passes all of these as generated.