Testing TruCMS Core and Plugins with Pest

TruCMS uses Pest. Tests run against an SQLite database with every external service switched off, so they are fast and need no built front end or network. This page covers the setup, the helpers, and how to test a plugin the way the bundled modules are tested.

Running tests

php artisan test --compact                                   # everything
php artisan test --compact tests/Feature/ResponseCacheTest.php
php artisan test --compact Modules/Testimonials              # one plugin
php artisan test --compact --filter="stores the testimonials settings"
vendor/bin/pest --filter=TestimonialsTest                    # the runner directly

Run the narrowest set that covers your change while you work, then the plugin's whole folder before you ship.

The test environment

SettingValueWhy it matters
DatabaseSQLite file database/testing.sqliteThe file is not in version control. Create an empty one on a fresh copy (touch database/testing.sqlite). Write migrations that work on SQLite as well as MySQL
Cache, session, mailarrayNothing persists between tests
QueuesyncDispatched jobs run inline unless you fake them
Response cacheoff, array storeTurn it on inside a test to test caching
Config cachebootstrap/cache/config.testing.phpKept apart from the app's cache

tests/TestCase.php also marks the app as installed, creates the Booking roles the suite expects, calls withoutVite() (views render without a build) and Http::preventStrayRequests() (any real HTTP call fails the test; fake it with Http::fake()).

Cached routes make tests lie

The route cache is shared with your development site. If routes are cached (for example after saving a setting that is stored in .env, or after dotartisan:optimize), new or changed plugin routes are invisible to the tests. Run php artisan optimize:clear before a test run when route-related tests fail unexpectedly.

Testing a plugin

tests/Pest.php binds the base TestCase and RefreshDatabase to the core test folders and to each bundled module's tests/Feature and tests/Unit. Your plugin is not on that list, so opt in at the top of each test file:

Modules/Testimonials/tests/Feature/TestimonialsTest.php

<?php

declare(strict_types=1);

use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Testimonials\Models\Testimonial;
use Tests\TestCase;

uses(TestCase::class, RefreshDatabase::class);

it('hides the admin screens from users without the permission', function (): void {
    $this->actingAs(regularUser())
        ->get(route('admin.testimonials.index'))
        ->assertForbidden();
});

it('lists testimonials for a user who may manage them', function (): void {
    Testimonial::factory()->create(['author_name' => 'Ada Lovelace']);

    $this->actingAs(userWithPermissions(['manage testimonials']))
        ->get(route('admin.testimonials.index'))
        ->assertOk()
        ->assertSee('Ada Lovelace');
});

Helpers from tests/Pest.php

HelperReturns / does
regularUser(array $attributes = [])A user with no permissions
userWithPermissions(array $permissions, array $attributes = [])A user holding exactly these permissions (they are created if missing)
superAdmin(array $attributes = [])A user with the super-admin role, which passes every check
testSettings(array $values)Write and save settings
seedTestLocales(array $locales = ['en', 'ar'])Create and enable languages (the first is the default; ar is right-to-left)
pluginRoute(string $name)The route object for a name, asserting it exists (check middleware and URIs)
cacheStatus(TestResponse $response)'HIT', 'MISS' or null from the response-cache header
cachedResponses()Everything the array response cache currently holds
nextVisitor(TestCase $test)A fresh session with nobody signed in, so one test can play several visitors

Prefer userWithPermissions() over superAdmin(): a super admin passes every check, so a test with one never notices a missing ->can().

Patterns that work well

Authorisation on every route

it('protects every testimonials admin route', function (string $name): void {
    $route = pluginRoute($name);

    expect(collect($route->gatherMiddleware())->contains(
        fn ($middleware) => str_starts_with((string) $middleware, 'can:')
    ))->toBeTrue();
})->with(['admin.testimonials.index', 'admin.testimonials.store', 'admin.testimonials.destroy']);

Registries are per request

The sidebar, settings and dashboard registries are singletons that are filled during a request. Assert on what a request renders (assertSee() on the dashboard or the sidebar), not on the registry after the request, and use nextVisitor() between two users in one test.

Shortcodes and page content

use Illuminate\Support\Facades\Blade;

it('renders the testimonials block', function (): void {
    Testimonial::factory()->create(['author_name' => 'Grace Hopper']);

    $html = Blade::render('<x-shortcode-testimonials></x-shortcode-testimonials>');

    expect($html)->toContain('Grace Hopper');
});

That is exactly how a saved page renders the tag, so the test covers the real path.

Files, mail and outside services

Style and static analysis

CommandChecks
vendor/bin/pint Modules/TestimonialsFormatting (Laravel preset)
vendor/bin/phpstan analyse Modules/TestimonialsPHPStan level 4 with Larastan; flags env() outside config/
composer lint:check, composer types:checkThe same checks for the whole codebase

Baseline before you blame your change

Run the full suite once before you start and note any tests that already fail on your copy. Compare against that list rather than assuming every failure is yours.