Appearance
Testing
Introduction
A module's tests live in its packages, in tests/, and run from an application that installs them — the packages have no vendor of their 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 ReviewsTests are PHPUnit 12 with attributes — #[Test], #[DataProvider] — not test_ prefixes or docblock annotations. The skeletons' phpunit.xml runs them on SQLite in memory (DB_CONNECTION=sqlite, DB_DATABASE=:memory:); kitloom's migrations create WordPress's tables there.
There are two kinds of test case, from the fastest:
| Kind | Boots | For |
|---|---|---|
| Domain | a container and SQLite in memory with WordPress's tables | how values are read and stored, what a query finds |
| Application | the application the tests run from, its providers, routes and theme | services, agent tools, site pages, admin screens |
Domain tests
The generated ReviewsTestCase sets up a bare container with Eloquent on SQLite in memory, runs kitloom/wp-schema's migrations and puts WordPress's models in the ModelRegistry. No web server, no MySQL, no admin panel — they run in milliseconds:
php
use Kitloom\WpSchema\Scopes\PostPublishedScope;
use PHPUnit\Framework\Attributes\Test;
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);
$ids = Review::query()->withoutGlobalScope(PostPublishedScope::class)->pluck('ID');
$this->assertSame([$id], $ids->map(fn (mixed $value): int => (int) $value)->all());
}
}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 in tests/Fixtures/.
Application tests
Most module tests boot the application they are run from — the skeleton, with every installed module and the active theme — and use Laravel's testing helpers. kitloom:make-module generates only the domain test case; for these, extend Kitloom\WpSchema\Testing\ApplicationTestCase from kitloom/wp-schema. It requires the application's bootstrap/app.php, turns Vite off, starts each test as a fresh request would, puts back the trailing slash Laravel's test client cuts off, and drops the models' event dispatcher afterwards, so a domain suite run after it in the same process does not inherit its listeners. Refresh the database yourself:
php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Kitloom\Permissions\Testing\CreatesUsers;
use Kitloom\WpSchema\Testing\ApplicationTestCase;
class ReviewToolsTest extends ApplicationTestCase
{
use CreatesUsers;
use RefreshDatabase;
}Users come from Kitloom\Permissions\Testing\CreatesUsers (kitloom/permissions): $this->user() makes an administrator, $this->user('editor') a user with another role, $this->user(null) one without a role on the site; $this->signIn('editor') makes one and signs them in for the next requests. Use them to check that what a role may not do is refused. Don't reach for the application's User model or its factory: a buyer's application may have another.
Other helpers the packages ship:
| Helper | Package | For |
|---|---|---|
Kitloom\WpSchema\Testing\CreatesWordPressContent | kitloom/wp-schema | wpPost(), wpTerm() — rows straight in the WordPress tables, as WordPress leaves them, not as the writers would |
Kitloom\Site\Testing\DatabaseTestCase | kitloom/site | A test without the application: the WordPress tables in SQLite in memory, rows as WordPress stores them (post(), term(), meta()), and the site's main query over them (visit(), mainQuery()) |
Kitloom\WpSchema\Testing\AssertsTranslations | kitloom/wp-schema | assertCodeTranslated() — the package's lang/<language>.json in step with the texts its code passes to __() |
Kitloom\Widgets\Testing\AssertsThemeDemo | kitloom/widgets | A theme's demo leaves no gaps: assertZonesAreFilled(), assertMenusAreAssigned(), assertPublished(), assertFrontPageIsSet() |
Write through the module's services and kitloom's writers, with the user the test acts as, as the code under test would:
php
$book = app(PostWriter::class)->create('book', ['title' => 'Dune', 'status' => 'publish'], $admin);
app(SettingWriter::class)->put('reviews_out_of', 10);Site pages
Kitloom\Site\Site renders a page without a request — as a visitor, or as a given user:
php
use Kitloom\Site\Site;
#[Test]
public function a_review_page_has_its_rating(): void
{
$review = app(PostWriter::class)->create(Reviews::POST_TYPE, ['title' => 'Dune', 'status' => 'publish']);
$review->setMeta('rating', 4);
$page = app(Site::class)->render($review, Site::JSON)->toArray();
$this->assertSame('single', $page['kind']);
$this->assertSame(4, $page['review']['rating']);
$this->assertStringContainsString('Dune', app(Site::class)->render('/reviews/')->body);
}render() takes a record, an address or a main query result; Site::resolve($url) gives the main query's result alone (isSingle(), isSearch(), posts). A draft is not found for a visitor — render('/soon/', Site::JSON, $editor) sees it as that user.
Over HTTP the same page is $this->get('/reviews/dune/'), and its JSON $this->getJson('/reviews/dune/') — every part a module provides, whatever the active theme; ?include=review,content narrows it to the parts named. A page not found answers 404 with its document, kind: not-found.
Trailing slashes
Site addresses end with a slash, as in WordPress, and any other form gets a 301. Laravel's test client cuts the slash off; ApplicationTestCase puts it back (prepareUrlForRequest()), so $this->get('/reviews/dune/') asks for /reviews/dune/. A test case of your own needs the same, or renders through Site.
Agent tools
A tool for AI agents is a class in the module's domain package; test it directly, without kitloom/ai, as kitloom/redirects does in tests/Agent:
php
use Kitloom\WpSchema\Contracts\AgentTool;
#[Test]
public function the_module_offers_its_tools_behind_its_permission(): void
{
$tools = [];
foreach (app()->tagged(AgentTool::TAG) as $tool) {
$tools[$tool->name()] = $tool->permission();
}
$this->assertSame('manage_reviews', $tools['reviews_create']);
}
#[Test]
public function an_agent_creates_a_review_as_its_user(): void
{
$review = app(ReviewsCreate::class)->handle(['title' => 'Dune', 'rating' => 4], $this->user());
$this->assertSame('Dune', $review['title']);
$this->expectException(AuthorizationException::class);
app(ReviewsCreate::class)->handle(['title' => 'Dune'], $this->user('subscriber'));
}Admin screens
Admin tests live in the admin packages, -filament/tests and -nova/tests, and boot the application the same way — on ApplicationTestCase, with CreatesUsers and the admin's own trait. The Filament packages are tested from kitloom-filament-skeleton, the Nova packages from kitloom-cms-skeleton.
Filament
Filament pages are Livewire components. Kitloom\WpSchema\Filament\Testing\InteractsWithFilament (kitloom/wp-schema-filament) resets Livewire's state before each test, so a Livewire::test() earlier in the process does not leak Livewire's assets into the next site page:
php
use Kitloom\Settings\Settings;
use Kitloom\WpSchema\Filament\Testing\InteractsWithFilament;
use Livewire\Livewire;
class ReviewsAdminTest extends ApplicationTestCase
{
use CreatesUsers;
use InteractsWithFilament;
use RefreshDatabase;
#[Test]
public function the_section_is_there_for_an_administrator_only(): void
{
$this->signIn();
$this->get('/admin/reviews')
->assertOk()
->assertSeeInOrder(['Reviews', 'Settings', 'How it works']);
$this->signIn('subscriber');
$this->get('/admin/reviews')->assertForbidden();
}
#[Test]
public function the_settings_page_stores_an_option(): void
{
$this->signIn();
Livewire::test(ReviewsSettingsPage::class)
->fillForm(['reviews_out_of' => 10])
->call('save')
->assertHasNoFormErrors();
$settings = app(Settings::class);
$settings->refresh();
$this->assertSame('10', (string) $settings->get('reviews_out_of'));
}
}Nova
Nova's API is plain HTTP. Resources are under /nova-api/{resource}, a settings page under /nova-vendor/kitloom-settings/pages/{path}. Kitloom\WpSchema\Nova\Testing\InteractsWithNova (kitloom/wp-schema-nova) opens the viewNova gate to whoever the test signs in, flushes Nova's static tools and resources after each test, and sends a resource form the way Nova's interface does — postAsNova(), putAsNova():
php
use Kitloom\WpSchema\Nova\Testing\InteractsWithNova;
class ReviewsNovaTest extends ApplicationTestCase
{
use CreatesUsers;
use InteractsWithNova;
use RefreshDatabase;
#[Test]
public function an_administrator_saves_the_settings(): void
{
$this->signIn();
$this->get('/nova/settings/reviews')->assertOk();
$this->postJson('/nova-vendor/kitloom-settings/pages/reviews', ['reviews_out_of' => 10])
->assertNoContent();
$this->postJson('/nova-vendor/kitloom-settings/pages/reviews', ['reviews_out_of' => 'many'])
->assertUnprocessable()
->assertJsonValidationErrors(['reviews_out_of']);
}
}Rows fields
A Rows field (kitloom/wp-schema-nova) — repeaters and flexible content in custom fields, redirect sources, slides, sidebar widgets — is sent by the browser as:
- the field's value: its rows in order as JSON,
[{"key": "a", "layout": "source"}]; - each row's field under
{field}__{key}__{attribute}.
php
$this->postJson('/nova-api/redirections', [
'url_to' => '/new/',
'header_code' => '301',
'status' => 'active',
'sources' => json_encode([['key' => 'a', 'layout' => 'source']]),
'sources__a__pattern' => 'old/',
'sources__a__comparison' => 'exact',
])->assertCreated();A row field's validation error comes under its row name, sources__a__pattern.
In the kitloom repository
The kitloom packages are tested from the two skeletons that install them, which sit next to the packages repository: kitloom-filament-skeleton (domain packages, themes, Filament admins) and kitloom-cms-skeleton (Nova admins). The skeletons need PHP 8.4 — tools/check.sh takes /usr/local/opt/[email protected]/bin/php when it is there, PHP=… picks another.
bash
tools/check.sh # the repository checks, Pint (--test), PHPStan, both skeletons' tests
tools/check.sh tests # or: repo | pint | phpstan | fix | baseline
tools/check.sh tests --affected # only the tests what is not committed yet can reach
tools/check.sh tests --affected main # … what changed since the branch left maintools/check.sh repo runs the repository's own rules, each also on its own:
bash
php tools/check-package-deps.php # every kitloom package a package uses is in its require or suggest
php tools/check-psr4.php # namespaces match paths
php tools/check-skeleton-tests.php # the skeletons' own tests import core packages only
php tools/licenses.php --check # license fields and LICENSE.md in every package
php tools/constraints.php --check # branch aliases and ^1.0 between the packages and in the skeletons--affected takes the changed packages and every package that requires them, and their suites in both skeletons. It is for the loop while working: it does not see modules that meet through container tags, so run the full tools/check.sh tests before a commit.
One suite while working, from the Filament skeleton:
bash
cd ../kitloom-filament-skeleton-site
/usr/local/opt/[email protected]/bin/php vendor/bin/phpunit --testsuite RedirectsPHPStan has baselines; new code must not add errors to them. Run tools/check.sh locally before a commit. The GitHub Actions workflow runs the same script, and the suites once more on MySQL with the wp_ prefix, but only when started by hand (Actions → Check → Run workflow). A module generated into the repository is added to the PHPStan configs and passes all of these as generated.