Testing
Pyle provides one complete test command and smaller scopes for focused local development. The same runner powers local development and CI, so a failing CI lane can be reproduced without reconstructing its Pest command by hand.
Running the Complete Suite
From the repository root, run:
composer testThe complete suite runs:
- Framework PHPStan analysis using its locked dependencies;
- Framework PHP tests and an independent architecture audit;
- Admin, Storefront, Admin Panel host, and host Feature PHP tests;
- both ERP Bridge test roots;
- Admin Panel Vitest tests and package typechecking;
- host application typechecking, production builds, and SSR builds; and
- test inventory and workflow contract guards.
Independent checks run concurrently. The PHP lanes share a worker budget and the host frontend waits for generation before its typecheck and two builds run together. A failed check blocks its dependents while unrelated checks finish.
Every intended PHP suite uses Pest's empty-suite failure guard. CI also checks that each registered test root contributes tests to its JUnit report, which prevents a configuration or discovery change from silently dropping coverage.
Installing Dependencies
The complete suite needs the Framework and embedded Laravel application Composer dependencies. It also needs the Admin Panel package and embedded application npm dependencies:
composer install --working-dir=packages/framework
composer install --working-dir=apps/laravel
npm ci --prefix packages/admin-panel
npm ci --prefix apps/laravelFor the full CI workload, including installation, Pint, Rector, and per-check logs/timings, run:
node bin/ci
node bin/ci --skip-setup
node bin/ci --serial --output=build/ci-serialcomposer test uses the same scheduler with setup, Pint, and Rector omitted. CI_TEST_PROCESSES sets its Pest workers; --jobs=N sets the number of concurrent checks. Phase timings count overlapping commands once within a phase; different phases overlap, so only total_seconds is the complete wall time. Logs and timings.json are written to build/ci by default.
The local runner reserves frontend workers around the PHP worker budget. CI_FRONTEND_WORKERS overrides that allocation. Dedicated frontend Actions jobs explicitly use all available CPUs. Calendar tests require the fork pool because they change Node's timezone during the test.
You may install only the dependencies needed by the scope you are working on. The runner reports missing dependency directories before starting a suite.
Pyle's current dependency locks require PHP 8.4. If PHP 8.4 is not the first PHP executable on your PATH, set PYLE_PHP_BINARY:
PYLE_PHP_BINARY="$HOME/Library/Application Support/Herd/bin/php84" composer test:appRunning a Focused Scope
Use the smallest scope that exercises your change while iterating:
composer test:framework
composer test:app
composer test:erp
composer test:frontend
composer test:contractstest:app includes Admin, Storefront, Admin Panel host, and host Feature PHP tests. test:frontend runs the Admin Panel Vitest suite and package typecheck concurrently, then generates the host Admin Panel assets and runs the host typecheck, production build, and SSR build concurrently. test:contracts verifies the test inventory and the GitHub Actions workflow contract tests.
Pass Pest arguments after a second --. Runner arguments, such as the process count, belong before it:
composer test:app -- --serial -- --filter=OrdersIndex
composer test:framework -- --processes=4 -- tests/Feature/app/McpUse a file or directory for focused parallel runs to limit discovery overhead. Check the command's exit code when reproducing failures.
Controlling Parallelism
PHP suites use the CPUs available to the process, respecting both affinity and container CPU quotas. Override the worker count with either the environment or a runner option:
PYLE_TEST_PROCESSES=4 composer test:app
composer test:app -- --processes=4Run serially when diagnosing isolation or order-dependent behavior:
composer test:framework -- --serialNative Windows shells use serial execution because Pest parallel execution is not reliable there. Use WSL to run the suites in parallel on Windows.
Reproducing CI Shards
CI splits the Framework suite into two measured, time-balanced lanes on 16-vCPU runners. Reproduce either lane locally with:
composer test:framework -- --separate-architecture --shard=1/2
composer test:framework -- --separate-architecture --shard=2/2
node .github/scripts/run-test-suite.mjs architecture --ciManual workflow runs can compare framework_vcpus (16 or 32), framework_shards (2 or 4), and app_vcpus (16 or 32) on the same commit. Each shard gets its own runner and uses the same timing manifest. Compare complete workflow time, including runner queues and setup, rather than only the fastest test job; the app/host suite can become the final lane.
Full manual and scheduled runs select every lane without path filtering. Pull requests still use changed-path selection, and pushes retain the Git history needed for their comparison. Draft PR test policy stays unchanged.
Pest owns the committed tests/.pest/shards.json timing manifest. Refresh it after adding, deleting, or renaming Framework test files or classes, or after a test's runtime changes materially:
node .github/scripts/run-test-suite.mjs framework --separate-architecture -- --update-shardsCollect timings with a consistent runner size and worker budget. The current weights use the median class durations from six recent 16-vCPU CI runs; architecture and ERP remain in their independent lanes. After changing shard counts or weights, compare the combined JUnit case identities across shards to check that no cases were lost or executed twice.
You can combine --shard with --processes=4 to bound native shard execution on a smaller machine. The same worker budget applies to manifest refreshes. These modes omit --passthru-php, which Pest's internal discovery does not accept; configure worker PHP settings in the CLI INI, as the Actions jobs do.
The ordinary composer test:framework scope still includes architecture. Only the CI plan and the explicit --separate-architecture option move those checks into the independent audit lane. The required gate checks both lanes; architecture discovery and JUnit ownership guards prevent dropped coverage.
Understanding the PHP Layers
Framework tests are classified by their actual runtime requirements rather than their directory name:
PureUnitruns without Laravel;Applicationboots Laravel without refreshing a database; and- database-backed Unit, Feature, and ERP tests retain the full schema and transactional isolation contract.
The Framework scope excludes the erp-bridge group. The ERP scope owns that group and verifies that its discovery exactly matches both intended ERP test roots before executing it.
Framework Database Baseline
Framework Testbench uses Laravel's native SQLite schema dump from packages/framework/tests/Support/database/schema/sqlite-schema.sql. A fresh worker loads the schema and migration repository, then Laravel runs any newer migrations. Every historical PHP migration remains available for upgrades and direct migration tests; the dump is generated without --prune.
SQL schema dumps omit application data. The test-only SchemaLoaded listener restores the migration-created reference records, audit events, role grants, and transport catalog. It imports the catalog once instead of replaying both historical imports. Host and production seeding remain separate. Each worker still owns its SQLite file, with foreign keys and per-test transaction rollback.
After changing migrations or required reference data, run from the repository root (the native Laravel dump requires sqlite3 on PATH):
php scripts/ci/refresh-framework-test-schema.php
php scripts/ci/refresh-framework-test-schema.php --checkRefresh builds a candidate through the full migration chain, compares its native restore and reference seed against another full replay, and replaces the committed dump only after validation passes. The independent schema-parity CI scope checks table definitions, columns, indexes, foreign keys, all baseline rows and grants, migration history, subsequent migrations, and restoring an explicit alternate SQLite connection. Generated UUIDs and audit timestamps vary between fresh replays and are excluded from the row comparison.
For an isolated investigation of the historical bootstrap, set PYLE_TEST_MIGRATION_REPLAY=1 when invoking Framework Pest. Explicit Laravel --schema-path options and native per-connection dump selection still apply. Normal tests require the committed dump to be readable. The parity checks also assert native schema-load events so a missing dump cannot silently pass through Laravel's migration replay fallback.
Writing Pest and Laravel Tests
Prefer a functional scenario: create the required state, perform the real request or action, then assert the response and persisted result. Keep database, HTTP, mail rendering, and Blade integration cases in Feature. Use Application for Laravel container/configuration behavior without a database, and PureUnit for code that needs neither Laravel nor a database. Existing database-backed Unit tests retain their isolation until deliberately migrated.
Use model factories and relationships for fixtures, $this->seed() for seeder behavior, and Laravel's database, response, and mailable assertions. Let the suite's RefreshDatabase or LazilyRefreshDatabase setup own transactions; avoid additional migrations in each test. Give fixtures explicit locales, identities, and values when those affect the expected result.
Host tests use Laravel 13's native WithCachedConfig and WithCachedRoutes. Each test still starts a fresh application; configure scenario-specific values after setup and keep temporary routes local to that test. Isolation regressions exercise two requests with different country settings and verify that neither configuration nor temporary routes leak into the next case. Framework SQLite files belong to individual workers, retain foreign keys, and roll back rows between tests. Large chunk-boundary fixtures may use factory attributes and bulk inserts when separate scenarios already cover model creation events. Tests that register routes dynamically call useMutableRoutes() before doing so: Laravel's compiled route collection intentionally skips name/action lookup refreshes. Verify the resulting requests and route matching rather than the identity of compiled route objects. Factory filler descriptions use Faker's text() with the same length bounds; scenarios that depend on content supply explicit values.
Use named datasets for meaningful variants. Put database-dependent fixture creation in the test or in a bound dataset closure, which Pest resolves after beforeEach. Do not create models while declaring a dataset or rely on another test's records.
Exercise public APIs. Assert a mailable's rendered subject and attachment through Laravel's helpers rather than invoking a protected method with reflection. Strings in rendered HTML or API output are valid assertions; line-by-line comparisons with implementation source do not establish behavior. Semantic architecture checks may inspect public class, schema, or dependency contracts when those contracts are themselves the requirement.
Fake network, mail delivery, storage, or expensive external rendering at the boundary being excluded from the scenario. Keep real authorization, validation, events, and persistence in the path under test. Keep separate integration coverage for an excluded renderer or transport. Http::preventStrayRequests() and the suite's Saloon guard catch accidental outbound calls. Laravel 13's Validator::fakeDnsLookups() isolates email DNS checks in each Laravel test case while retaining real syntax and business validation. Laravel resets that fake during teardown; production email rules stay unchanged.
Use freezeSecond() and travel() for time windows instead of sleeping. Laravel restores test time during teardown. Bare PHPUnit tests must restore global clocks and containers explicitly, including on assertion failure. For frontend debounce checks, advance Vitest timers through the actual user interaction and assert the request before and after its deadline. Restore real timers and Testing Library configuration in finally; keep timezone-sensitive cases in fork workers. Filesystem fixtures belong in a unique temporary directory; clean them in finally and restore any view namespace changed by the test. Avoid writing to shared application resources from parallel tests.
Check changed files or directories with native Pest parallel execution and a bounded worker count. Also run related cases serially in a reproducible random order when changing shared fixtures or global state. Require a zero command exit code; a green assertion summary alone is insufficient. The complete CI runner retains its discovery and empty-suite guards.
These conventions follow the installed Laravel 13 / Pest 5 toolchain and the official Laravel testing, database testing, mocking, Pest datasets, hooks, and test optimization guidance.
Running API Contract Checks
API documentation artifacts are generated from the embedded Laravel application. Install its dependencies, then generate or verify the artifacts:
cd apps/laravel
npm run api-docs:generate
npm run api-docs:verifyGeneration uses an in-memory SQLite connection and does not migrate an application database. See the API contract guide for artifact ownership and CI behavior.