Appearance
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 ReviewsThere are three kinds of tests, from the fastest:
| Kind | Where | What it boots |
|---|---|---|
| Domain | the package's tests/ | a container and SQLite in memory with WordPress's tables |
| Admin screens | the application's tests/Feature | the whole application, as a signed-in user |
| Site pages | the application's tests/Feature | the 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_fieldslists, 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 oneA module generated into the repository is added to the PHPStan configs and passes all of these as generated.