Skip to content

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 Reviews

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

KindBootsFor
Domaina container and SQLite in memory with WordPress's tableshow values are read and stored, what a query finds
Applicationthe application the tests run from, its providers, routes and themeservices, 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:

HelperPackageFor
Kitloom\WpSchema\Testing\CreatesWordPressContentkitloom/wp-schemawpPost(), wpTerm() — rows straight in the WordPress tables, as WordPress leaves them, not as the writers would
Kitloom\Site\Testing\DatabaseTestCasekitloom/siteA 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\AssertsTranslationskitloom/wp-schemaassertCodeTranslated() — the package's lang/<language>.json in step with the texts its code passes to __()
Kitloom\Widgets\Testing\AssertsThemeDemokitloom/widgetsA 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 main

tools/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 Redirects

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

kitloom — modules and themes for Laravel.
Pricing · Services · License · Refunds · Privacy · Community · [email protected]