vusys/laravel-runabout
Runabout is journey testing for Laravel: define the steps of a user journey once, and it executes them in randomised-but-deterministic orders, checking your invariants after every step to catch bugs.
Runabout is a test-only trait, not a service provider — there is nothing to register, publish, or configure before the first run.
composer require --dev vusys/laravel-runabout
Then, in a test case:
use Vusys\Runabout\RunsJourneys;
final class PostLifecycleTest extends TestCase
{
use RunsJourneys;
public function test_post_lifecycle(): void
{
$this->journey(PostLifecycleJourney::class)->shuffles(25)->run();
}
}
That call is the whole day-to-day surface: one journey class, N seeds, one assertion of everything you care about. It runs the declared order once, then 25 seeded shuffles, checking invariants after every step.
Where to look first, in this order:
Journey, Step, Context, Invariant, Trail). Ten minutes here saves an afternoon of mis-shaped journeys.shuffles(25) is only one of four ways to spend test time.Best first use case: a lifecycle on one aggregate where the order of operations is the thing you don't trust — a subscription that can be upgraded, paused, and cancelled; an order that can be paid, refunded, partially refunded; a deal that can be opened, amended, closed. Write the steps you already know users perform, declare the transitions you believe are legal, and let the shuffler find the pair of operations you never wrote a test for. Do not start with a wide multi-tenant interleaving journey; start with the single-entity lifecycle where a failure is easy to read.
A realistic expectation: your first journey will feel like writing a feature test with worse ergonomics. It pays off on the second and third shuffle count, not the first.
One journey per aggregate lifecycle, not per feature. The unit of authoring is "everything a user can do to this thing", because that is the space the shuffler samples from. Journeys that encode a single happy path produce nothing a feature test wouldn't. Journeys that encode the full legal vocabulary of an aggregate find the bug at step 11.
Invariants are the real product; write them first. A journey without invariants only fails when a step throws. The interesting failures are states that are silently wrong: a negative balance, two active subscriptions, a ledger that doesn't sum. Reach for the shipped helpers before hand-rolling closures:
Invariants::legalTransitions(...) // state machine, checks the transition, not just the value
Invariants::uniqueBy($model, $columns) // no duplicate (tenant, start), (report, metric), etc.
Invariants::quotaBalances(...) // starting allowance as int or Closure(model): int per row
quotaBalances() taking Closure(TModel): int is the pattern to remember for anything plan- or tier-dependent — quotas that differ per tenant are the common case, and expressing them as a constant is how invariants get quietly weakened.
Separate "did the write happen" from "is the world still sane". Steps should do the action and assert the immediate response (status code, redirect, shape). Invariants should assert the durable property. Mixing them means a failing shuffle gives you three unrelated assertion failures and you can't tell what the shuffle discovered.
Drive the app as named actors, through HTTP, when the bug is multi-user. Register users on the journey once:
public static function actors(): array
{
return ['manager' => $this->manager, 'agent' => $this->agent];
}
then steps call $ctx->as('manager')->postJson(...). This goes through the full middleware stack, which is where tenancy and authorization ordering bugs live. For tenancy that rides in the session, $ctx->actingAs($user, 'agent', ['tenant' => 5]) attaches session data to every request, and Actor::withSession([...]) layers more for one request. Note the distinction from the changelog: users declared in actors() must exist before the run; anything created inside a trail is rolled back, so create users inside a step with $ctx->actingAs() instead.
Interleave journeys for cross-actor races. The single-instance journey tests ordering within one user's session. The interesting class of bug — "A reads B's records only if B has any", "two agents claim the same lead" — needs two instances whose steps interleave. That is a separate authoring mode; read Interleaving journeys before attempting it, because the constraints (when() / after()) are the part people get wrong.
Use execution modes as a budget dial, not a preference. Day to day: shuffles(25) in the test you run on every save. repeatHeavy() when a step is idempotent-sensitive and you want the same position replayed. exhaustive() sparingly — it is the mode that turns a 40-second test into a 6-minute one and gets commented out by the third week. Keep exhaustiveness for a nightly or pre-release lane.
Pin a seed in CI, plus a rotating one. RUNABOUT_SEED gives you a stable, reproducible suite; a second scheduled job with no seed (or a date-derived one) is what actually finds new bugs. Pin the seed in committed config, not in someone's shell history.
Make reproduction a copy-paste, not an investigation. When a shuffle fails, capture the RUNAOUT_TRAIL artifact the failure prints. PendingJourney::trail($artifact) replays that exact ordered trail, and [email protected] reads large artifacts from disk. This is the workflow that makes the package tolerable in CI: a red build hands you a file, you paste it into a local test, you get the identical failure. Note that RUNABOUT_SEED alone cannot reproduce repeat-heavy or shrunk/partial trails — order travels with the artifact, the seed does not.
Reset state properly between trails when you have more than one datastore. The default is a transaction rollback; use resetConnections(...$connections) for multiple connections and resetExternal(Closure $cleanup) for anything outside the DB (cache tags, queued jobs, search indexes, object storage). A journey that is green locally and red in CI is very often external state, not a real ordering bug.
Turn on observability when you're tuning, off when you're not. RUNABOUT_VERBOSE for a per-trail log and RUNABOUT_COVERAGE for an end-of-run summary of what the shuffler explored. Use these when a journey passes suspiciously easily — coverage tells you whether the shuffler actually visited the interesting orderings or whether when() constraints pruned them all away.
Seed schema v2 re-keyed every pinned seed. The changelog is explicit: data draws left the shared stream so each execution is a pure function of (seed, instance label, step name, run index). That is what makes replay and shrinking sound, and it is worth the churn — but any RUNABOUT_SEED you committed before 0.1.0 now produces a different trail. Pin seeds after you pin the package version, and re-pin both together.
RUNABOUT_SHRINK is an opt-out; every other flag is an opt-in. Only the exact string "0" disables shrinking; unset, empty, and "0" all read as off for RUNABOUT_VERBOSE, RUNABOUT_COVERAGE, and friends. This asymmetry is documented but it is exactly the kind of thing that bites in a Docker or CI env file where RUNABOUT_SHRINK= (empty) does not mean what you think. Set it to 0 or leave it alone.
Shrinking costs replays. Default budget is 200 candidate replays (RUNABOUT_SHRINK_BUDGET), and each replay re-runs the trail. On a fast journey that's free; on a journey that touches HTTP, mail, and queues it's the difference between a 30-second failure and a 4-minute one. When a failure is clearly a straight-line bug at the end of a long trail and you don't need the minimal counterexample, RUNABOUT_SHRINK=0 while iterating is legitimate. Shrinking is off for canonical and structural failures anyway.
A step that reaches for the raw randomizer is value-opaque. Value shrinking works by pushing each randomInt()/pick() toward the low end of its domain. $ctx->randomizer() bypasses that, so the shrinker skips the step's drawn values — you get a shorter trail but not a concrete one ("open two deals of random amounts" instead of "open 51, open 50"). Prefer randomInt()/pick(), and only drop to randomizer() when the distribution genuinely isn't a bounded int or a pick.
Baseline invariants need fromStart(). A state-machine invariant on a row that exists before the journey never observes its true initial state — it first sees the row after the opening step and mistakes that transition for the start. Invariants::legalTransitions() now points you at the fix when it detects "not a legal initial state", but the cleaner habit is Invariant::fromStart() on anything that constrains a pre-existing row. The alternative fix — record the row in a leading step — also works and keeps all invariants uniform. Pick one convention per project.
uniqueBy() reads the default scope, so soft-deleted rows may repeat a live key. That is usually what you want (a deleted record doesn't violate uniqueness), but it means uniqueBy() will not catch a unique constraint that fails at the database level against a trashed row. Test the constraint, not just the invariant, if the real guarantee comes from the index.
Canonical order is round-robin across instances, not journey-by-journey. Fixed in 0.1.1, and it changes what the "declared order" run means for interleaving journeys: A's first step, B's first step, A's second, and so on. If you wrote constraints against the old behaviour, they may now be enabled earlier than you expected — usually a fix, occasionally a surprise.
The root namespace is the public contract; internals moved in 0.2.0. Journey, Step, Context, Invariant, Invariants, RunsJourneys, PendingJourney, Actor, Trail, TrailCoverage, HttpDriver and Exceptions\* are stable. Execution\, Randomness\, Shrinking\, Replay\, Support\ are machinery. If you find yourself extending a class under those namespaces to work around a limitation, that's a signal to file the issue rather than fork behaviour — you'll be re-merging it on every minor.
Maturity caveat, stated plainly. Zero stars, zero dependents, and a 0.2.x version line that has already renamed classes and re-keyed seeds. The engineering signals are strong — PHPStan level 9, mutation testing, Pint, a real CI matrix across Laravel 11/12/13, and docs that are unusually good for a package this young — but the API is still moving. Pin the version exactly ("vusys/laravel-runabout": "0.2.*"), keep journeys confined to a tests/Journeys/ namespace so an upstream rename is one directory, and don't put it on the critical path of a release gate until you've seen it find something real.
Don't replace your feature tests with it. Runabout's cost is per-journey × per-shuffle × per-replay; feature tests are cheap and precise about where a bug is. The pragmatic split: feature tests for the documented flows you promise, one Runabout journey per aggregate for the orderings you didn't think to write down. If a shuffle finds a bug, the correct output is usually a new feature test pinning the minimal sequence — Runabout found it, the feature test keeps it found.
Debugging a shuffle that fails on the 24th seed only: don't add dump() inside steps and re-run the whole thing. Take the artifact, replay it as a single explicit trail with shrinks off, and only then start inspecting. The failure is deterministic by construction; you lose more time to re-shuffling than to reading the shrunk trail.
How can I help you explore Laravel packages today?