Testing
An action is a plain class with one entry point, which makes it the easiest part of an application to test: build it, run it, assert on what came back. The package adds a little on top for the two cases that are not that simple, queued actions and actions you want to keep out of a test entirely.
Testing the action itself
Prefer this over everything else on this page. No mocking, no queue, no model: call it.
public function test_it_returns_the_invoice_total(): void { $total = CalculateInvoiceTotal::create()->run(['lines' => $lines, 'vat' => 0.21]); $this->assertSame(1452.0, $total); }
Side effects are asserted with the framework's own fakes, because an action has no opinion about them:
Mail::fake(); $user->action(SendWelcomeEmail::class)->run(); Mail::assertSent(WelcomeMail::class);
And validation is asserted by expecting the exception:
$this->expectException(ValidationException::class); SendWelcomeEmail::create()->run(['email' => 'not-an-email', 'subject' => 'Hi']);
Testing a queued action
Queue::fake() stops the job from running and hands it to you, and because the action instance rides inside the job you can assert both what was queued and how it was configured:
use EduLazaro\Laractions\Jobs\ActionJob; Queue::fake(); $user->action(SendWelcomeEmail::class) ->queue('emails') ->delay(30) ->retry(3) ->dispatch(); Queue::assertPushed(ActionJob::class, function ($job) { return $job->action instanceof SendWelcomeEmail && $job->queue === 'emails' && $job->delay === 30 && $job->tries === 3; });
To assert what the action does rather than that it was queued, run the job inside the closure. That executes the action exactly as a worker would, model rehydration and validation included:
Mail::fake(); Queue::fake(); $user->action(SendWelcomeEmail::class)->dispatch(); Queue::assertPushed(ActionJob::class, function ($job) { $job->handle(); return true; }); Mail::assertSent(WelcomeMail::class);
Remember that with QUEUE_CONNECTION=sync and no Queue::fake(), a dispatch runs inline, so a test that forgets the fake will pass for the wrong reason. Asserting on ActionJob is what tells the two apart.
Replacing an action in a test
Sometimes the action under test is not the one you care about: it calls another one that talks to a payment gateway. Bind a fake in the container. Both create() and $model->action() resolve through it, so a binding replaces the action wherever it is created:
class FakeChargeCard extends ChargeCard { public function handle(int $amount): string { return 'ch_fake'; } } $this->app->bind(ChargeCard::class, fn () => new FakeChargeCard());
Extending the real action is what keeps this honest: the fake inherits the signature, so a change to handle() breaks the fake instead of leaving a test that passes against an interface nobody implements any more.
mockAction(), and why the container is better
HasActions also offers mockAction(), which replaces an action on a single model instance:
$user->mockAction(SendWelcomeEmail::class, new class { public function run() { return 'mocked'; } }); $user->action(SendWelcomeEmail::class)->run(); // 'mocked'
The double does not have to implement anything in particular: if it has an on() method it receives the model like a real action would, and if it does not, it is returned as it is. Substitution only happens while the application is running tests, so a mockAction() call that escapes into application code cannot change behaviour in production.
It is deprecated all the same, and binding in the container is the better tool, because an action gets created in four ways and mockAction() only intercepts one of them:
| Where the action is created | mockAction() |
Container binding |
|---|---|---|
$model->action(...) |
Yes, on that instance only | Yes |
Action::create(...) |
No | Yes |
$actor->act(...) |
No | Yes |
| Inside another action | No | Yes |
The per instance granularity is the one thing mockAction() can do that a binding cannot: mock the action on this user and not on that one. If you do not need that, bind.
Asserting traces
Traces are Eloquent rows, so they are asserted like any other:
$admin->act(RefundOrder::class)->on($order)->trace()->run(); $this->assertDatabaseHas('action_traces', [ 'action' => RefundOrder::class, 'actor_id' => $admin->id, 'target_id' => $order->id, ]);
Only successful runs are recorded, which makes this a useful assertion in reverse too: a test that expects a failed action can assert the table stayed empty. See Actors and tracing.