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.
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 directlyRun the narrowest set that covers your change while you work, then the plugin's whole folder before you ship.
| Setting | Value | Why it matters |
|---|---|---|
| Database | SQLite file database/testing.sqlite | The 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, mail | array | Nothing persists between tests |
| Queue | sync | Dispatched jobs run inline unless you fake them |
| Response cache | off, array store | Turn it on inside a test to test caching |
| Config cache | bootstrap/cache/config.testing.php | Kept 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.
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');
});modules_statuses.json) for its provider, routes and migrations to load. RefreshDatabase then runs its migrations with the core's.Modules\\Testimonials\\Tests\\ → tests/) under autoload-dev in the plugin's composer.json, then composer dump-autoload.tests/Pest.php| Helper | Returns / 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().
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']);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.
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.
Storage::fake('public') before anything that writes media, including seeders.Mail::fake() and Notification::fake().Http::fake([...]); unfaked calls fail by design.| Command | Checks |
|---|---|
vendor/bin/pint Modules/Testimonials | Formatting (Laravel preset) |
vendor/bin/phpstan analyse Modules/Testimonials | PHPStan level 4 with Larastan; flags env() outside config/ |
composer lint:check, composer types:check | The 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.