Pular para o conteúdo principal
Versão: 2.0

Testing

Workflow\V2​

Workflow\V2\WorkflowStub::fake() provides a deterministic inline test path for the supported V2 fake surface.

  • ready workflow and activity tasks execute inline instead of waiting for a queue worker
  • nested child workflows execute as real nested V2 runs under that same fake mode
  • activity mocks still write durable activity_executions, task, and history rows
  • backend capability checks are bypassed for the fake path, so sync remains usable in tests
  • WorkflowStub::assertDispatched(), assertDispatchedTimes(), assertNotDispatched(), and assertNothingDispatched() cover V2 activity dispatches
  • WorkflowStub::assertSignalSent(), assertSignalSentTimes(), and assertSignalNotSent() cover V2 signals sent through WorkflowStub::signal() or signalWithStart()
  • WorkflowStub::assertUpdateSent(), assertUpdateSentTimes(), and assertUpdateNotSent() cover V2 updates sent through WorkflowStub::update(), attemptUpdate(), or submitUpdate()
  • delayed timer tasks stay durable queued work until they become due; after you advance time, WorkflowStub::runReadyTasks() drains already-due tasks inline

First-release testing scope​

The following are not part of the first-release V2 fake surface and may be added as future additive contracts:

  • there is no V2 equivalent of the legacy resume() bridge
  • there is no dedicated child-workflow mock or dispatch-assert helper — child workflows execute as real nested V2 runs under fake mode, so test them through their observable output rather than mocking
  • there is no delayed-callback hook for injecting signals or updates at virtual time offsets during a single fake execution — advance time and call WorkflowStub::runReadyTasks() between explicit signal/update calls instead

WorkflowStub::mock() enforces this scope at runtime: passing a Workflow subclass throws a LogicException with a clear message directing you to test child workflows through their observable output instead. Only Activity classes (and unresolved string keys) are accepted as mock targets.

use function Workflow\V2\activity;
use Workflow\V2\Workflow;

final class MyWorkflow extends Workflow
{
public function handle(): array
{
return [
'result' => activity(MyActivity::class, 'Taylor'),
'workflow_id' => $this->workflowId(),
'run_id' => $this->runId(),
];
}
}
use Workflow\V2\Testing\ActivityFakeContext;
use Workflow\V2\WorkflowStub;

public function testWorkflow(): void
{
WorkflowStub::fake();

WorkflowStub::mock(MyActivity::class, function (ActivityFakeContext $context, string $name): string {
$this->assertSame('Taylor', $name);
$this->assertSame('my-workflow-id', $context->workflowId());

return "Hello, {$name}!";
});

$workflow = WorkflowStub::make(MyWorkflow::class, 'my-workflow-id');
$workflow->start();

$this->assertTrue($workflow->refresh()->completed());
$this->assertSame('Hello, Taylor!', $workflow->output()['result']);

WorkflowStub::assertDispatched(MyActivity::class, function (string $name): bool {
return $name === 'Taylor';
});
}

Use WorkflowStub::assertDispatched(), assertDispatchedTimes(), assertNotDispatched(), and assertNothingDispatched() to assert the recorded V2 activity dispatches.

For timer-backed workflows, advance time with Laravel's travel helpers and then drain the due task queue explicitly:

use function Workflow\V2\timer;
use Workflow\V2\Workflow;
use Workflow\V2\WorkflowStub;

final class MyTimerWorkflow extends Workflow
{
public function handle(): array
{
timer(60);

return ['done' => true];
}
}

public function testTimerWorkflow(): void
{
WorkflowStub::fake();

$workflow = WorkflowStub::make(MyTimerWorkflow::class, 'timer-workflow');
$workflow->start();

$this->travel(60)->seconds();

WorkflowStub::runReadyTasks();

$this->assertTrue($workflow->refresh()->completed());
$this->assertSame(['done' => true], $workflow->output());
}

WorkflowStub::runReadyTasks() only drains tasks that are already due. It does not fast-forward wall clock time or force future-delayed timers to fire early.

Sending Signals in Fake Mode​

Signal-waiting workflows can receive signals in fake mode. When you call $workflow->signal(...), the engine records the durable signal command and creates a workflow task. In fake mode that task executes inline, so the workflow resumes synchronously:

use function Workflow\V2\activity;
use Workflow\V2\Attributes\Signal;
use function Workflow\V2\await;
use Workflow\V2\Workflow;
use Workflow\V2\WorkflowStub;

#[Signal('name-provided')]
final class ApprovalWorkflow extends Workflow
{
public function handle(): array
{
$name = await('name-provided');
$greeting = activity(GreetingActivity::class, $name);

return ['name' => $name, 'greeting' => $greeting];
}
}

public function testSignalWorkflow(): void
{
WorkflowStub::fake();
WorkflowStub::mock(GreetingActivity::class, 'Hello, Taylor!');

$workflow = WorkflowStub::make(ApprovalWorkflow::class, 'approval-1');
$workflow->start();

// Workflow suspends at await('name-provided')
$this->assertSame('waiting', $workflow->refresh()->status());

// Send the signal — resumes the workflow inline
$workflow->signal('name-provided', 'Taylor');

$this->assertTrue($workflow->refresh()->completed());
$this->assertSame('Taylor', $workflow->output()['name']);

WorkflowStub::assertSignalSent('name-provided');
WorkflowStub::assertSignalSentTimes('name-provided', 1);
WorkflowStub::assertSignalNotSent('other-signal');
}

Use WorkflowStub::assertSignalSent(), assertSignalSentTimes(), and assertSignalNotSent() to verify which signals were sent during the test. The callback form receives the instance id and signal arguments:

WorkflowStub::assertSignalSent(
'name-provided',
fn (string $instanceId, string $name): bool =>
$instanceId === 'approval-1' && $name === 'Taylor'
);

Sending Updates in Fake Mode​

Updates also apply inline in fake mode. When you call $workflow->attemptUpdate(...) or $workflow->update(...), the engine records the durable update command, creates a workflow task that applies the update, and executes it inline:

use Workflow\UpdateMethod;
use Workflow\V2\Attributes\Signal;
use function Workflow\V2\await;
use Workflow\V2\Workflow;
use Workflow\V2\WorkflowStub;

#[Signal('done')]
final class SettingsWorkflow extends Workflow
{
private bool $enabled = false;

public function handle(): array
{
await('done');

return ['enabled' => $this->enabled];
}

#[UpdateMethod]
public function toggle(bool $enabled): array
{
$this->enabled = $enabled;

return ['enabled' => $this->enabled];
}
}

public function testUpdateWorkflow(): void
{
WorkflowStub::fake();

$workflow = WorkflowStub::make(SettingsWorkflow::class, 'settings-1');
$workflow->start();

$this->assertSame('waiting', $workflow->refresh()->status());

$result = $workflow->attemptUpdate('toggle', true);

$this->assertTrue($result->accepted());

WorkflowStub::assertUpdateSent('toggle');
WorkflowStub::assertUpdateSentTimes('toggle', 1);
WorkflowStub::assertUpdateNotSent('other-update');

// Complete the workflow
$workflow->signal('done');
$this->assertTrue($workflow->refresh()->completed());
$this->assertTrue($workflow->output()['enabled']);
}

Use WorkflowStub::assertUpdateSent(), assertUpdateSentTimes(), and assertUpdateNotSent() to verify which updates were sent during the test. The callback form receives the instance id and update arguments:

WorkflowStub::assertUpdateSent(
'toggle',
fn (string $instanceId, bool $enabled): bool =>
$instanceId === 'settings-1' && $enabled === true
);

Testing Start Outcomes and Duplicate-Start Policy​

Use attemptStart() to verify duplicate-start behavior without throwing exceptions. The StartResult exposes typed outcome helpers:

use Workflow\V2\StartOptions;
use Workflow\V2\WorkflowStub;

public function testRejectDuplicateStart(): void
{
WorkflowStub::fake();
WorkflowStub::mock(MyActivity::class, 'result');

$workflow = WorkflowStub::make(MyWorkflow::class, 'order-123');

$first = $workflow->start('Taylor');
$this->assertTrue($first->startedNew());

$second = WorkflowStub::load('order-123');
$duplicate = $second->attemptStart('Taylor');

$this->assertTrue($duplicate->rejected());
$this->assertTrue($duplicate->rejectedDuplicate());
$this->assertSame('instance_already_started', $duplicate->rejectionReason());
}

To test the return-existing-active policy, pass StartOptions::returnExistingActive():

public function testReturnExistingActiveStart(): void
{
WorkflowStub::fake();

$workflow = WorkflowStub::make(MySignalWorkflow::class, 'order-123');
$workflow->start();

$this->assertSame('waiting', $workflow->refresh()->status());

$second = WorkflowStub::load('order-123');
$result = $second->attemptStart(StartOptions::returnExistingActive());

$this->assertTrue($result->accepted());
$this->assertTrue($result->returnedExistingActive());
$this->assertSame($workflow->runId(), $result->runId());
}

Both policies record durable command and history events, so you can also assert on WorkflowCommand and WorkflowHistoryEvent rows for deeper verification.

Testing History Budget​

The HistoryBudget fields (history_event_count, history_size_bytes, continue_as_new_recommended) are surfaced on the run summary projection and are available through the RunDetailView. In your workflow code, the Workflow base class exposes these as $this->historyLength(), $this->historySize(), and $this->shouldContinueAsNew():

use Workflow\V2\Workflow;

final class LongRunningWorkflow extends Workflow
{
public function handle(): void
{
while (true) {
// ... process work ...

if ($this->shouldContinueAsNew()) {
Workflow::continueAsNew($this->carryForwardState());
}
}
}
}

To test history budget thresholds, configure the thresholds low and verify the recommendation:

public function testHistoryBudgetRecommendsContinueAsNew(): void
{
config()->set('workflows.v2.history_budget.continue_as_new_event_threshold', 5);

WorkflowStub::fake();

// Run a workflow that produces enough history events to trip the threshold
$workflow = WorkflowStub::make(ManyActivitiesWorkflow::class, 'budget-test');
$workflow->start();

$summary = $workflow->summary();
$this->assertTrue($summary->continue_as_new_recommended);
}

Legacy Workflow\WorkflowStub​

Legacy v1

The examples in this section use the v1 runtime (Workflow\Workflow, use function Workflow\activity, generator-style execute() with yield, Workflow\Models\StoredWorkflow). Keep these patterns for tests that still exercise v1 workflows during migration. New tests should use Workflow\V2\WorkflowStub::fake() with Workflow\V2\Workflow and straight-line handle() methods as shown above.

Workflows​

You can execute workflows synchronously in your test environment and mock activities and child workflows to define expected behaviors and outputs without running the actual implementations.

use function Workflow\activity;
use Workflow\Workflow;

class MyWorkflow extends Workflow
{
public function execute()
{
$result = yield activity(MyActivity::class);

return $result;
}
}

The above workflow can be tested by first calling WorkflowStub::fake() and then mocking the activity.

public function testWorkflow()
{
WorkflowStub::fake();

WorkflowStub::mock(MyActivity::class, 'result');

$workflow = WorkflowStub::make(MyWorkflow::class);
$workflow->start();

$this->assertSame($workflow->output(), 'result');
}

You can also provide a callback instead of a result value to WorkflowStub::mock().

The workflow $context along with any arguments for the current activity will also be passed to the callback.

public function testWorkflow()
{
WorkflowStub::fake();

WorkflowStub::mock(MyActivity::class, function ($context) {
return 'result';
});

$workflow = WorkflowStub::make(MyWorkflow::class);
$workflow->start();

$this->assertSame($workflow->output(), 'result');
}

You can assert which activities or child workflows were dispatched by using the assertDispatched, assertNotDispatched, and assertNothingDispatched methods:

WorkflowStub::assertDispatched(MyActivity::class);

// Assert the activity was dispatched twice...
WorkflowStub::assertDispatched(MyActivity::class, 2);

WorkflowStub::assertNotDispatched(MyActivity::class);

WorkflowStub::assertNothingDispatched();

You may pass a closure to the assertDispatched or assertNotDispatched methods in order to assert that an activity or child workflow was dispatched that passes a given "truth test". The arguments for the activity or child workflow will be passed to the callback.

WorkflowStub::assertDispatched(TestOtherActivity::class, function ($string) {
return $string === 'other';
});

Skipping Time​

By manipulating the system time with $this->travel() or $this->travelTo(), you can simulate time-dependent workflows. This strategy allows you to test timeouts, delays, and other time-sensitive logic within your workflows.

use function Workflow\{activity, timer};
use Workflow\Workflow;

class MyTimerWorkflow extends Workflow
{
public function execute()
{
yield timer(60);

$result = yield activity(MyActivity::class);

return $result;
}
}

The above workflow waits 60 seconds before executing the activity. Using $this->travel() and $workflow->resume() allows us to skip this waiting period in the legacy runtime.

public function testTimeTravelWorkflow()
{
WorkflowStub::fake();

WorkflowStub::mock(MyActivity::class, 'result');

$workflow = WorkflowStub::make(MyTimerWorkflow::class);
$workflow->start();

$this->travel(120)->seconds();

$workflow->resume();

$this->assertSame($workflow->output(), 'result');
}

The helpers $this->travel() and $this->travelTo() methods use Carbon::setTestNow() under the hood.

Activities​

Testing activities is similar to testing Laravel jobs. You manually create the activity and then call the handle() method.

$workflow = WorkflowStub::make(MyWorkflow::class);

$activity = new MyActivity(0, now()->toDateTimeString(), StoredWorkflow::findOrFail($workflow->id()));

$result = $activity->handle();

Notice that legacy activities still execute through the runtime handle() job method. In Workflow\V2, user-defined workflow and activity code also lives in handle().