Testing Your Modules
Most of what you write is ordinary Laravel, tested the ordinary way. The part that is not is authorization, because the question "may Alice press this button on Tuesday" has four moving parts — the artifact, the actor, the grant chain and the timestamp — and no amount of mocking will tell you the truth about it.
So the SDK ships a scenario DSL: a builder that writes a real permission landscape into your test database, runs the real resolver over it, and hands you something to assert against.
The one rule
Never mock the authorization engine
An AbilityResolverInterface double proves that your test's idea of the rules matches your test's idea of the rules. It cannot catch a wrong join, an off-by-one validity window, a role that grants through a group it should not, or a grant that expired last night. Every authorization test in the SDK's own suite runs against a real database and the real contributor, and yours should too.
The scenario DSL
Declare a helper once, in your tests/Pest.php:
use LaravelUi5\Sdk\Testing\Scenario;
use LaravelUi5\Sdk\Testing\ScenarioBuilder;
function sdk(): ScenarioBuilder
{
return new ScenarioBuilder(new Scenario());
}Then a test reads as a sentence:
it('allows execution of WorldAction for a tenant admin', function () {
$sdk = sdk()
->module(HelloModule::class)
->partner('alice', SystemLevel::TenantAdmin)
->resolve();
expect($sdk->actor('alice'))
->canExecute(WorldAction::class)
->toBeTrue();
});module() puts one module under test — exactly one, a second call is an error — and partner() creates an actor at a system level. Note what partner() does quietly: it also grants the role that level implies, so the common case needs no grantRole() at all.
Building a permission landscape
| Call | Does |
|---|---|
module($class) | The module under test. Once. |
modules([...]) | Additional modules that must be registered but are not the focus. |
partner($name, $level, ?$definition) | An actor at a system level, plus the role that level implies. |
group($name, $definition) | A group, defined by a closure that adds roles and abilities. |
grantAbility($artifact, $ability, $partner, ?$from, ?$until) | A direct ability grant, scoped to an artifact class. |
grantRole($role, $partner, ?$from, ?$until) | A role grant. Takes the enum or its string. |
grantGroup($group, $partner, ?$from, ?$until) | A group grant. |
at($carbon) | The moment the abilities are resolved as of. |
resolve() | Writes it all, runs the real contributor, returns a context. |
Every grant takes an optional validity window. Leave it out and you get the SDK's defaults — valid from a little before now, until the end of time — which is what you want unless the test is about time.
Asserting
resolve() returns a context; actor($name) narrows it to one person, and that object is what you assert on:
expect($sdk->actor('alice'))
->can('act.toggleLock')->toBeTrue() // one ability, "{type}.{name}"
->canAccess(PartnersApp::class)->toBeTrue()
->canExecute(WorldAction::class)->toBeTrue();snapshot($class) gives you the whole resolved map for an artifact, and get() / set() / reset() / editable() reach the settings surface for the same actor — so a settings test and an authorization test are the same kind of test.
Two ability spellings, and they are not interchangeable
In tests an ability is "{type}.{name}" — act.toggleLock. In client code it is "{type}/{name}" — sdk.can("see/exportButton"). The dot is the test DSL, the slash is the wire.
Three shapes worth copying
A grant through a group, which is the path most likely to be wrong:
$sdk = sdk()
->module(HelloModule::class)
->partner('alice', SystemLevel::User)
->group('Special', fn ($group) => $group->role(SdkRole::TenantAdmin))
->grantGroup('Special', 'alice')
->resolve();A direct ability grant, bypassing roles entirely:
->grantAbility(WorldAction::class, 'toggleLock', 'alice')A time-aware test, which is the whole reason the validity columns exist:
sdk()->at(Carbon::parse('2026-03-01 09:00:00'))
->module(HelloModule::class)
->partner('alice', SystemLevel::User)
->grantRole(SdkRole::TenantAdmin, 'alice',
validFrom: Carbon::parse('2026-01-01'),
validUntil: Carbon::parse('2026-02-01'))
->resolve();Alice held the role in January. Asked about March, the resolver says no — and that assertion is one your production code depends on every day without anyone noticing.
Where the tests live
In your host application, not in a package. That is a deliberate strategy, not an omission: a test that boots the real application exercises the wiring a package test cannot see — your config/ui5.php, your modules, your users table, your bindings. The SDK's own suite works this way, against its reference host.
The practical consequence is that you do not need Testbench. The providers are already wired by your application's bootstrap, so there is no getPackageProviders() scaffolding to write.
The one wrinkle, and how to get around it
A vanilla setUp() runs after the providers have booted — too late for config the SDK reads at register time, such as ui5.modules or ui5.intents. If your test needs different modules registered, inject the config before providers register:
public function createApplication()
{
$app = require __DIR__ . '/../bootstrap/app.php';
$app->beforeBootstrapping(RegisterProviders::class, function ($app) {
$this->defineTestConfig($app['config']);
});
$app->make(Kernel::class)->bootstrap();
return $app;
}That hook fires after the configuration loads and before any provider registers — which is exactly the timing Testbench's defineEnvironment() gives you, restored on a real application.
Seed the system actor
Any test that runs ui5:sync needs the system actor to exist, because the settings worker stamps it as set_by on every seeded platform row. One line in setUp():
Partner::create(['name' => 'system-actor', 'system_level' => SystemLevel::PlatformOwner]);Partner #1, which is what config('ui5.system_actor_id') defaults to.
SQLite and foreign keys
SQLite does not enforce foreign keys by default, so a test suite on SQLite will happily let a RESTRICT pass that MySQL would refuse. If a test is about referential integrity, enable the pragma at the connection level, before providers boot:
$config->set('database.connections.sqlite.foreign_key_constraints', true);It must be set outside RefreshDatabase's transaction — a PRAGMA foreign_keys issued inside one is a no-op. Keep such tests in their own base class rather than enabling it globally.
SQLite will not catch a type mismatch
unsignedInteger against bigIncrements is a foreign-key error MySQL raises and SQLite does not. If your migrations run on MySQL in production, run the migration suite against MySQL at least once in CI.
Testing the rest
HTTP endpoints — ordinary feature tests. Authenticate a user whose partner_id points at a seeded partner, and the whole request edge runs for real: context resolution, the auth gate, the #[Access] check. This is the only way to test that a route is gated, and it is worth doing — a gate that was never asserted has a way of quietly disappearing.
Actions — post to ui5/api/{namespace}@{version}/{uri} and assert on the envelope. The #[Act] gate lives in the form request, so a test with the wrong actor should get a 403 before your handler runs.
OData reads — a scoped entity set returning an empty list is the failure mode to watch for, and it looks like success. Assert on the rows a scoped actor should see, not merely on the 200.
Help — ui5:help --validate in CI. It is fast, and it catches the declaration mistakes that are otherwise invisible until someone presses F1. See the build pipeline.