Troubleshooting¶
Runabout tries to fail with a message that names the fix. This page collects the ones you're most likely to meet, what actually causes each, and what to change. Failures of your application — a violated invariant, a failed assertion — are not errors in this sense; those are the tool working, and Reproducing failures covers what to do with them.
Deadlock: no step is enabled¶
Deadlock: no step is enabled but these steps have not run: "publish post", "lock post".
A when()/after() constraint is unsatisfiable from here.
A shuffled trail reached a state where nothing is eligible but some steps still owe a run. This is a bug in the journey's constraints, not in your app.
The usual causes:
- A
when()that can latch permanently false. A step gated onfn ($ctx) => ! $post->lockedbecomes unreachable the moment some other step locks the post. If the shuffler picks that step first, the gated step can never run. - A mutually exclusive pair. Two steps that each disable the other leave whichever loses the coin flip stranded.
- A
repeatable(min: n)step that stops being eligible. The deadlock check counts a step as pending until it has runminRuns()times, so a step needing three runs whose precondition goes false after one deadlocks just as surely as one that never ran.
Fix it by loosening the precondition, adding an after() so the step is only considered once the world supports it, or making the step's action tolerate the state it was trying to exclude. If the app genuinely closes the branch off (converting a book to a shelf destroys the chapters a "delete chapter" step acts on), mark the step optional() so the trail may finish without it. Bounding the trail further won't help — the trail is stuck, not long.
Runaway trail¶
Runaway trail: 250 steps executed without completing the journey.
Bound your repeatable() steps or loosen their preconditions.
The opposite failure: the picker kept finding eligible steps and the journey never finished. The cap is 25 × the total minimum runs across every step, with a floor of 100 executions, so a small journey gets at least 100 ticks before this fires.
This nearly always means an unbounded repeatable() step whose companion "closing" step can't become eligible — the trail can always pick the repeatable one, so it does, forever. Give the repeatable step a max, or check why the step that ends the journey never satisfies its precondition.
Step not enabled in declared order¶
Step "publish post" is not enabled when reached in declared order (round robin across
instances); the canonical order must be a valid trail. Check its when()/after() constraints.
The canonical trail — the declared order, run before any shuffle — must itself be a legal ordering. Two things commonly break it:
- An
after()naming a step declared later. Declaration order is the canonical order, so a step can only depend on steps above it. - A
when()that isn't true yet at that position. A precondition with noafter()is evaluated before anything has run, against a world that doesn't exist yet.
In interleaved runs the canonical order is round robin across instances by declared position (A's first step, B's first, A's second, …), so a step gated on another instance's state must tolerate that instance having only started, not finished.
Journey definition errors¶
These are all InvalidJourneyException and all thrown before any step runs:
| Message | Cause |
|---|---|
Journey X defines no steps. |
steps() returned an empty array. |
Journey X has duplicate step names. |
Two steps share a name. Names key the run history and the replay artifact, so they must be unique. |
Step "a" declares after("b") but no step has that name. |
A typo in after(), or the dependency was renamed and this call wasn't. |
At least one journey is required. |
interleave() was called with no arguments. |
Step builder arguments are validated where they're set, so these throw as the journey is constructed:
| Message | Cause |
|---|---|
Step "x" needs a minimum of at least 0 runs, got -1. |
A negative repeatable(min: ...). Use min: 0 (or optional()) for a step that may never run. |
Step "x" has min 5 greater than max 3. |
repeatable(max: 3, min: 5) can never be satisfied. |
Step "x" needs a weight of at least 1, got 0. |
weight() is a relative multiplier, so it must be positive. To make a step rarer, raise the others. |
One quiet non-error worth knowing: repeatable(max: 1) does not make a step repeatable. A step counts as repeatable only when its maximum exceeds one, so max: 1 leaves it a once-only step and repeatHeavy() will not favour it.
aroundStep() returned without invoking the execution¶
App\Journeys\TenantJourney::aroundStep() returned without invoking the execution closure it was given.
An aroundStep() override must call the closure it receives. A wrapper that returns early — an unmet guard clause, an accidental return — would silently skip the step and its invariant checks, so the run rejects it instead of passing a test that didn't test anything.
If you need a step to sometimes not run, that's a when() precondition on the step, not a conditional wrapper.
Exhaustive mode refuses to run¶
Exhaustive mode would run more than 720 orderings for the 7 steps of App\Journeys\BigJourney.
Shrink the journey or raise the limit: exhaustive(limit: ...).
Orderings grow factorially: six steps is exactly 720, seven is 5,040. The limit is a guard against accidentally asking for a run that never finishes. Either raise it deliberately (exhaustive(limit: 5040)) or accept that sampling is the right tool at this size and use shuffles().
Exhaustive mode is not available for interleaved journeys:
the ordering space is the product of the instances' orderings.
Interleaved runs multiply each instance's ordering space together, so exhaustiveness stops being meaningful well before it stops being affordable. Use shuffles() there.
A replay artifact stopped working¶
The replay trail is not viable: publish post is no longer reachable in this order
(an after()/when() dependency it relied on may have been removed).
A RUNABOUT_TRAIL artifact names steps and the order they ran in. If the journey has changed since the artifact was captured — a step renamed or deleted, a constraint tightened — the recorded order may no longer be legal. The artifact is stale, not the test.
The same failure reports (unknown step) when a token names a step that no longer exists, and (unknown instance) when an interleaved artifact is replayed against a different number of journey instances.
Re-run the journey normally to find the bug again and capture a fresh artifact. If you're mid-fix and the bug is already gone, that's the artifact doing its job.
Context and actor errors¶
| Message | Cause |
|---|---|
No actor named "manager" is registered. Known actors: agent, admin. |
A typo, or the actor was registered on a different trail. Actors live on the per-trail context — register them in a step or declare them with actors(), not in setUp(). |
No HTTP driver is bound to this context. |
The journey was run outside RunsJourneys::journey() — a runner constructed by hand has no test case to make requests through. |
Context key "post" holds null, expected App\Models\Post. |
instance() found the wrong type, usually because the step that remembers the key hasn't run yet in this ordering. Gate the reader with after(). |
Context key "post id" holds string, expected int. |
An id read straight out of a JSON response body arrives as a string on some drivers — cast at the remember() call. |
No actor has made a request yet in this trail. |
lastResponse() was called before any actor request, typically from an assertion on a step whose act doesn't make one. |
An admin actor is redirected to login¶
The actor logged in on the default guard, but the route is behind auth:admin. Name the guard: 'admin' => [$user, 'admin'] in actors(), or $ctx->actingAs($user, 'admin', guard: 'admin'). See apps with several guards.
Storefront code sees the admin as the current user¶
Something called actingAs($admin, 'admin') on the test case, which also makes admin the default guard for the rest of the test. Code that calls auth()->user() then gets the admin. Instead, register the admin as an actor on the admin guard. Runabout sets the user on that guard and does not change the default guard.
An invariant complains about the initial state¶
Post 1 appeared in state "published", which is not a legal initial state (draft).
If the row exists before the journey, observe it in a leading step so its initial
state is recorded before any step transitions it.
legalTransitions builds each row's history from what it observes, and invariants are checked after every step — not before the first one. For a row created inside the journey that's fine. For a row that existed beforehand, the invariant's first look comes after the opening step has already moved it, so that move is mistaken for the row's starting state.
Add ->fromStart() so the invariant also takes a reading before any step runs.
My seed or trail is being ignored¶
Only one mode runs, and they take precedence in this order:
exhaustive()trail(), thenRUNABOUT_TRAILseed(), thenRUNABOUT_SEEDshuffles()— the default
So RUNABOUT_SEED has no effect on a journey whose test already calls exhaustive() or trail(), and an in-code seed() beats the environment variable rather than the other way round. See Environment variables for the full table.
Also worth knowing: a seeded replay runs one trail, not the canonical order plus shuffles. If you're replaying and seeing a single trail, that's correct.
An actor still has permissions another actor revoked¶
Or it lacks permissions another actor granted. The actor reuses one user instance for the whole run. A permission list that the app cached on the instance, or a relation that it loaded, predates the change. Call freshActors() to reload the user before each request. You then do not need to clear the cache in aroundStep().
State leaks between trails¶
Every trail is reset — by default a transaction rolled back on the default connection. Anything created before the run is therefore gone after the first trail completes.
If trail 1 passes and trail 2 fails on missing data, the setup almost certainly lives in setUp() or a beforeEach. Move it into a first step so it's re-created per trail. The one deliberate exception is actors() users, which must exist before the run — see Resetting state.
The mirror image — trail 2 failing because trail 1's rows are still there — means the code under test commits its own transactions, so the wrapping rollback can't undo them. Switch to resetByTruncating().
If trail 2 fails on state that isn't in the database at all (a cached value, a rate limiter answering 429, a Context flag, a user who still has permissions another actor revoked), Process-lifetime state lists the state that survives a trail, and the fix for each.
A value set by one request is still visible to later ones¶
Laravel Context and scoped bindings outlive the request that set them in a test process, unlike production. Call isolateRequests() to clear them before every actor request.
My shrunk trail passes when I replay it¶
The replay isn't broken, and the bug probably isn't flaky. The failure depended on state an earlier trail left behind, so it only reproduced in the process that ran those trails. Shrinking replays candidate trails in that same process, after everything the run did before the failure, so they inherit the leaked state and still fail. RUNABOUT_TRAIL replays the minimal trail on its own in a fresh process, without that state, and it passes.
To confirm it:
- Replay the full trail with the
RUNABOUT_SEEDfrom the failure (shown for shuffled and repeat-heavy trails). If the full trail fails on its own and the minimal one passes, the minimal trail relied on state from the executions shrinking removed. - Run the journey with
->shuffles(0), so only the canonical trail runs. If the failure disappears, it needs an earlier trail.
The usual causes, and their fixes:
- Cache entries and rate-limiter counters in an in-memory store: flush them after each trail with
resetExternal(). - Laravel
Contextand scoped container instances set by one request:isolateRequests(). - Static memo caches and singletons in app code: reset them with
resetExternal()too. - The actor's user instance, which still carries the permissions or relations it had when the run started:
freshActors().
Memory grows across trails¶
Runabout itself keeps nothing per trail: a run's memory after a hundred trails is what it was after ten, however long the trails. (The coverage summary keeps one entry per distinct ordering, a few hundred bytes each.) Growth that scales with the trails run comes from the app under test, or the test case, holding on to something every request adds.
Measure it first, by sampling memory as each trail completes:
$this->journey(CommentsJourney::class)
->onTrail(function (Trail $trail): void {
gc_collect_cycles();
fwrite(STDERR, sprintf("%s steps, %.1f MB\n", count($trail->steps()), memory_get_usage() / 1e6));
})
->run();
The first trail or two rise while autoloading, routes and the container warm up. If memory keeps climbing after that, the usual causes are:
- The query log, if something calls
DB::enableQueryLog(). It keeps every query until the test ends. Leave it off for journeys, or clear it withDB::flushQueryLog()in aresetExternal()cleanup. - Listeners, macros or bindings registered inside a step or a request, such as
Event::listen()in a controller or a service provider that registers per request. Each trail adds another. Register them once. - Debugging and audit tools: Telescope, Debugbar, Clockwork, or an activity log that buffers entries or models in memory. Turn them off in the testing environment.
- Static caches and singletons in app code that collect per-request entries. Clear them in a
resetExternal()cleanup. - Log handlers that buffer, such as Monolog's
TestHandlerorBufferHandler.
A step "ended the trail's database transaction"¶
The step ran something that committed the trail's transaction, almost always a schema change on MySQL or MariaDB, which commits implicitly. See Schema changes inside a trail. If the reset itself reports Rolling back the trail's transaction … failed, the same thing happened somewhere Runabout doesn't check, such as inside a custom wrapTrail().
The failure wasn't shrunk¶
Shrinking is skipped, by design, when:
- the failing trail was exhaustive or already replayed — only
canonical,shuffledandrepeat-heavytrails are shrunk; - the failure is structural (a deadlock, a runaway, a broken definition, a step that ended the trail's transaction) — there's no meaningful shorter subsequence of a journey that was never valid;
- the trail is a single execution — there's nothing to remove;
RUNABOUT_SHRINK=0is set;- no shorter trail and no smaller values reproduced the same failure, in which case the original is reported unchanged.
If shrinking runs but the drawn values don't minimise, check whether the step reaches for $ctx->randomizer(). The raw handle marks that execution value-opaque: its draws still replay exactly, but the shrinker can't see through the handle to minimise them. Prefer pick() and randomInt() where you want small, readable values in the reported trail.
Every trail explores the same ordering¶
Set RUNABOUT_COVERAGE=1 and read the distinct-ordering count. If fifty trails produce one or two orderings, the journey is fully constrained — an after() chain that pins every step into a single legal sequence. That's a valid journey, but the shuffling is buying nothing, and the run is an ordinary feature test wearing a costume.
Loosen the chain to whatever the domain actually requires. after() should encode genuine prerequisites ("can't publish before drafting"), not the order you happened to write the steps in. See Trails & coverage.