7.3 Testing infrastructure
KMP uses PHPUnit for PHP behavior, Jest/jsdom for frontend units, and Playwright BDD for browser-visible flows. Tests should prove the boundary that matters: policy and persistence in PHP, local UI behavior in Jest, and integrated host/tenant/queue/Turbo behavior in Playwright.
Test lanes
| Lane | Primary scope | Command from app/ |
|---|---|---|
| Core unit | models, behaviors, services, KMP primitives, application wiring | vendor/bin/phpunit --testsuite core-unit |
| Core feature | controllers, commands, middleware, views, HTTP behavior | vendor/bin/phpunit --testsuite core-feature |
| Plugins | plugin-owned tests plus shared plugin harness | vendor/bin/phpunit --testsuite plugins |
| All PHP | complete PHP regression | vendor/bin/phpunit --testsuite all or composer test |
| JavaScript | Stimulus/utilities in jsdom | npm run test:js |
| UI smoke | login/workflow smoke | npm run test:ui:smoke |
| UI journey | curated cross-domain journey | npm run test:ui:journey |
| UI platform | destructive platform tenant provisioning | npm run test:ui:platform-provisioning |
| UI UAT | full Playwright BDD/spec set | npm run test:ui |
Run one Playwright lane at a time. Lanes share the local Docker application, PostgreSQL instance, worker, scheduler, and Mailpit.
PHPUnit organization
app/phpunit.xml.dist defines four suites:
core-unitincludestests/TestCase/Core/Unit,Model,Services,KMP, andApplicationTest;core-featureincludesCore/Feature,Controller,Command,Middleware, andView;pluginsincludestests/TestCase/Pluginsand plugin-owned test trees;allincludes every core and plugin test.
Put a test in the suite that owns the behavior, not whichever directory happens to make it run fastest.
Base classes
BaseTestCasestarts a transaction on thetestconnection and rolls it back after each test. It exposes deliberately stable seed constants and common DB assertions.HttpIntegrationTestCaseadds CakePHP HTTP integration and authentication helpers.PluginIntegrationTestCaseloads the plugin named by the subclass before an HTTP feature test.- Plain CakePHP/PHPUnit
TestCaseis appropriate for isolated code that supplies all of its own connections and cleanup, including many platform service tests.
Always call parent setup/teardown in subclasses. Use disableTransactions() only
when the feature cannot execute inside the standard wrapper. Use
reseedDatabase() only for destructive tests that genuinely require a full
seed reset; it is expensive and changes process-wide state.
Test database bootstrap
tests/bootstrap.php aliases the test datasource to default and disables
runtime HTTP tenancy for the ordinary suite. On PostgreSQL it applies current
core and loaded-plugin migrations first, then loads the data-only
tests/pg_seed.sql and required workflow/reference configuration. The reset
script creates the application and platform test databases before the suite.
This means ordinary table/controller tests run against one seeded tenant-shaped
database. Tenancy behavior is tested separately through middleware,
TenantConnectionManager, platform services/commands, and multi-host
Playwright scenarios. Do not mistake KMP_TENANCY_ENABLED=false in PHPUnit
bootstrap for the production request contract.
Platform-focused unit/feature tests configure and restore a platform
connection explicitly, often with an isolated test schema or mock. A test that
changes ConnectionManager, FactoryLocator, TenantContext, global workflow
registries, behavior suppression, Configure, environment variables, or static
logs must restore it in tearDown()/finally.
Stable seed data
Use named constants from BaseTestCase for the small set of supported stable
IDs. Query by a unique semantic property when no constant exists. The canonical
reference is app/tests/TestDataReference.md.
Avoid:
- raw magic IDs copied from a SQL file;
- total record counts unrelated to the behavior under test;
- ordering that is not part of the query contract;
- naturally aging dates without an explicit effective time; and
- tests that send mail or jobs to every matching row in the shared seed.
Use a unique per-test token for records created by additive browser fixtures. For destructive bulk mutation, reseed intentionally rather than relying on test order.
PHP test patterns
Authorization
Test both the controller boundary and policy logic where appropriate. Include an allowed user, denied user, out-of-branch resource, collection scope, and direct request. UI visibility is not an authorization assertion.
Workflows and queues
Exercise the user/domain trigger that starts the workflow. Assert durable state, then drain or flush through the project test helper before checking queued mail or side effects. Keep negative assertions scoped to the fixture so unrelated seeded jobs cannot make the test flaky.
Multi-tenancy
At minimum, sensitive infrastructure tests should execute tenant A, tenant B, then tenant A in one process and assert:
default/tenantaliases and table locators restore;- mail/cache/storage/secret state is scoped;
- open transactions fail loudly;
- host resolution is normalized and fail-closed; and
- IDs or queued work do not cross databases.
Middleware tests cover unhealthy platform, unknown host, inactive tenant, schema-behind tenant, platform-admin host, and cleanup on exceptions.
Commands and migrations
Commands need exit-code, dry-run, validation, error-scrubbing, and retry/idempotent coverage. Fleet commands also need selector/status rules, suspended-tenant behavior, advisory lock contention, partial failure, and final catalog verification. Use actual PostgreSQL coverage when SQL or locks are PostgreSQL-specific.
Jest/jsdom
Jest tests live under tests/js, mirroring the frontend source where practical.
tests/js/setup.js provides shared DOM/browser mocks. Load a Stimulus controller
through the same global registration pattern used by the app, connect it to a
minimal semantic fixture, perform keyboard and pointer interactions, and assert
state/announcements/cleanup.
npm run test:js
npm run test:js:watch
npm run test:js:coverage
Mock browser APIs consistently and restore globals/timers/listeners after each test. A jsdom assertion does not replace a browser check for Bootstrap focus, Turbo Frames, layout, downloads, camera/file APIs, or accessibility.
Playwright BDD
Editable feature files are under tests/ui/bdd; shared and domain step files sit
beside them. npx bddgen test produces tests/ui/gen, which is generated output
and should not be hand-edited. Reports/results are generated too.
Playwright lane orchestration resets the development database once, then feature fixtures add uniquely named records. Use:
runPhpJson()with JSON over standard input for fixture setup;- host-bound tenant contexts from
tests/ui/support/tenant-context.cjs; flushWorkflowsAndQueue()/waitForQueueSettled()before mail or queued assertions; and- accessible selectors (role/name/label) rather than brittle CSS structure.
The platform-provisioning lane enables destructive coverage deliberately. Do not mix it into a normal parallel lane or target a non-local environment.
Standard verifier
bash bin/verify.sh
It runs:
core-unit,core-feature, andpluginswith Xdebug disabled;- the skipped-test budget;
- seed snapshot contracts;
- Jest;
- the Vite development build;
- PHPCS on changed PHP (syntax-only for changed embedded Queue code);
- the Azure deployment runtime contract; and
- PHPStan with the recorded baseline handling.
For a compact command-oriented companion, see the test lane quick reference.
It does not run Playwright by default. Coverage and mutation are opt-in:
bash bin/verify.sh --with-coverage=security
bash bin/verify.sh --with-coverage=all
bash bin/verify.sh --with-mutation=security
Diagnosing failures
- A failing test only after many others usually indicates static/config/table or transaction leakage; run it alone and then after a suspected predecessor.
- A redirect instead of the expected page usually means authentication, authorization, host, restore-lock, or CSRF setup is incomplete.
- Seed mismatch calls for the supported setup/reset scripts, not editing a test to accept incidental counts.
- A Playwright mail/side-effect timeout often means the queue/workflow was not flushed or the assertion used the wrong tenant context.
- A tenant test returning local baseline data may have bypassed
TenantConnectionManagerand continued using the boot datasource.
Report exact commands and outcomes in the handoff. Never convert a real failure to a skip merely to stay inside the skip budget.