Testing Laravel Nova Actions

How can you write automated tests for Laravel Nova Actions, without firing up a whole browser simulation headache?

Testing Laravel Nova Actions
A screenshot from the test version of Green Turtle's "Tree Tracker", with a new Send Certificate feature to reduce time wasted by sales teams that could be spent literally saving the world.

After a super busy winter of tree planting and infinite client work trying to get the Dutch taxman off my back, I've got the chance to breath a little, and write about some of the cool stuff I've been working on. A few quick articles will be coming out with no particular order or theme, but the first one is going to be: how can you write automated tests for Laravel Nova Actions without firing up a whole browser simulation headache? And why would you need to?

A project I've been working on with a rabble of volunteers and freelancers is the Tree Tracker, software built originally for Protect Earth, but eventually expanding to bring more tree planting organizations. We needed a lot of functionality, which needed to be able to evolve quickly. Most of these "Do Everything Backend Generators" get you 80% of the way and kick you in the face if you try and get any further, but Laravel Nova has been keeping up with our needs brilliantly, but the test coverage of the backend logic has been patchy, and the Nova Actions were becoming increasinly important with very little testing on them.

So lets have a look at how we can cover an action with tests. First, an action.

<?php

namespace App\Nova\Actions;

use App\Jobs\Orders\Emails\SendFulfilled;
use App\Models\Enums\OrderStatus;
use App\Models\Order;
use App\Support\Queue;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Collection;
use Laravel\Nova\Actions\Action;
use Laravel\Nova\Fields\ActionFields;
use Laravel\Nova\Http\Requests\NovaRequest;

class SendCertificate extends Action
{
    use InteractsWithQueue, Queueable;

    /**
     * Perform the action on the given models.
     *
     * @return mixed
     */
    public function handle(ActionFields $fields, Collection $models)
    {
        assert($models instanceof \Illuminate\Database\Eloquent\Collection);

        $numOrdersSent = 0;
        $numOrdersSkipped = 0;

        foreach ($models as $order) {
            assert($order instanceof Order);

            if ($order->status === OrderStatus::Incomplete) {
                ++$numOrdersSkipped;
                continue;
            }

            dispatch(new SendFulfilled($order))->onQueue(Queue::emails->value);

            ++$numOrdersSent;
        }

        return Action::message(sprintf('Sent %s certificate emails, and skipped %s.', $numOrdersSent, $numOrdersSkipped));
    }
}

A Laravel Nova action called SendCertificate, which which will let customers know the trees, woodland restoration, wildflower meadows, etc they've for have been planted/restored/etc.

Argue about the code as much as you want but this is what this action is doing.

Now, how can I be 100% sure this is working properly without clicking it on all the clients sites and actually sending out some emails?

Feature Testing the Nova API Directly

I don't know if this is bonkers but I saw a scrap of code on Laracasts generated which wasn't quite right, but pointed me vaguely in this direction.

<?php

use App\Models\Order;
use App\Nova\Actions\SendCertificate;
use App\Jobs\Orders\Emails\SendFulfilled;
use Illuminate\Support\Facades\Bus;

describe(SendCertificate::class, function() {

    beforeEach(fn() => Bus::fake());

    it('will email a single order', function () {
        $user = User::factory()->create();
        $order = Order::factory()->fulfilled()->create();

        $response = $this->actingAs($user)
            ->post('/nova-api/orders/action?action=send-certificate', [
                'resources' => $order->id,
            ]);

        Bus::assertDispatchedTimes(function (SendFulfilled $job) use ($order) {
            return $job->order->id === $order->id;
        }, 1);

        $response
            ->assertStatus(200)
            ->assertJson([
                'message' => 'Sent 1 order completion emails, and skipped 0.',
            ]);
    });

describe-it syntax tests brought to you via Pest, the closest thing to RSpec/Jest in PHP and I love it.

IT WORKS.

This is the part doing all the heavy lifting.

$response = $this
  ->actingAs($user)
  ->post('/nova-api/orders/action?action=send-certificate', [
      'resources' => $order->id,
  ]);

It's basically the same as the AI generated Laracast forum reply, but it had halucinated the need for postJson() when looking at the Network panel I could see it was more interested in form-data.

Firefox's Network panel shows the XHR request is sending multipart form-data, with the resources sent as a CSV string.

The robot also suggested the URL should be /nova-api/orders/send-certificate but Laravel was shouting at me about not being able to POST to that endpoint. Taking another look at the Network panel shows me /nova-api/{nova-object}/action?action={action-name} is to preferred approach.

Here we are sending a fake but real enough HTTP request to Laravel Nova's API, where it does all its thinking. In order to not get bounced for not being logged in we can use actingAs($user), which might work nicely for you.

If you get a 403 head on over to app/Providers/NovaServiceProvider.php and see if your user matches the criteria in the guard.

  /**
   * Register the Nova gate.
   *
   * This gate determines who can access Nova in non-local environments.
   */
  protected function gate(): void
  {
      Gate::define('viewNova', fn ($user) => true);
  }

When set to true it will let any user in, skip checks when APP_ENV=testing

With that stuff all explained, here's the whole test with all the test cases.

<?php

use App\Models\User;
use App\Models\Order;
use App\Nova\Actions\SendCertificate;
use App\Jobs\Orders\Emails\SendFulfilled;
use Illuminate\Support\Facades\Bus;

describe(SendCertificate::class, function() {

    beforeEach(fn() => Bus::fake());

    it('will email a single order', function ($user) {
        $order = Order::factory()
            ->fulfilled()
            ->create();

        $response = $this->actingAs($user)
            ->post('/nova-api/orders/action?action=send-certificate', [
                'resources' => $order->id,
            ]);

        Bus::assertDispatchedTimes(function (SendFulfilled $job) use ($order) {
            return $job->order->id === $order->id;
        }, 1);

        $response
            ->assertStatus(200)
            ->assertJson([
                'message' => 'Sent 1 order completion emails, and skipped 0.',
            ]);
    })->with('user');

    it('will handle multiple orders', function ($user) {
        $orders = Order::factory(3)
            ->fulfilled()
            ->create();

        $orderIds = $orders->pluck('id')
            ->toArray();

        $response = $this->actingAs($user)
            ->post('/nova-api/orders/action?action=send-certificate', [
                'resources' => implode(',', $orderIds),
            ]);

        foreach ($orders as $order) {
            Bus::assertDispatchedTimes(function (SendFulfilled $job) use ($order) {
                return $job->order->id === $order->id;
            }, 1);
        }

        $response
            ->assertStatus(200)
            ->assertJson([
                'message' => 'Sent 3 order completion emails, and skipped 0.',
            ]);
    })->with('user');

    it('will skip incomplete orders', function ($user) {
        $incompleteOrders = Order::factory(2)
            ->incomplete()
            ->create();

        $completeOrders = Order::factory(3)
            ->fulfilled()
            ->create();

        $orderIds = array_merge(
            $incompleteOrders->pluck('id')->toArray(),
            $completeOrders->pluck('id')->toArray(),
        );

        $response = $this->actingAs($user)
            ->post('/nova-api/orders/action?action=send-certificate', [
                'resources' => implode(',', $orderIds),
            ]);

        foreach ($completeOrders as $order) {
            Bus::assertDispatchedTimes(function (SendFulfilled $job) use ($order) {
                return $job->order->id === $order->id;
            }, 1);
        }

        foreach ($incompleteOrders as $order) {
            Bus::assertNotDispatched(function (SendFulfilled $job) use ($order) {
                return $job->order->id === $order->id;
            });
        }

        $response
            ->assertStatus(200)
            ->assertJson([
                'message' => 'Sent 3 order completion emails, and skipped 2.',
            ]);
    })->with('user');
});


# This is actually in tests/Datasets/User.php but can be here too.
dataset('user', [
    fn() => User::factory()->create()
]);

Let's zoom in on a few bits in case they're not making any sense.

Enhance... ENHANCE

First of all the describe-it stuff is Pest, and you can learn more about that over yonder. It's relatively new, but a bunch of us begged for it, and it hath arrived.

describe(SendCertificate::class, function() {

    beforeEach(fn() => Bus::fake());

    it('will email a single order', function () {

You can do the same stuff on PHPUnit, but I'll let you figure out how.

Fake Bus

Thankfully this has nothing to do with Brexit. The bus is likely important to you if you're working on Nova Actions, because you are probably using Actions to queue things. Why? Well an action could be run once, or it could be run for every single record in the database, and unless your host is configured to let web threads run for infinite time (which they should not be!) then you're going to want to queue up pretty much anything they're doing. That'll involve the bus.

Taking a Timeout from Poor Performance
In a system-oriented architecture, it is crucial to communicate with other systems. In an ideal world each service knows enough information to satisfy its clients, but often there are unfortunate requirements for data to be fetched on the fly. Broker patterns, proxies, etc., or even just a remote procedure being

Let's fake the Bus so we don't actually start dispatching those jobs, and pop it in a beforeEach so we can reset it each time so we can count how many times its been dispatched.

beforeEach(fn() => Bus::fake());

We could specifically only fake the job we care about with Bus::fake(SendFulfilled::class) but having the whole bus parked up might be t he better choice if there are a few possible events that could get triggered deending on how the action goes.

In that first test I'm finding out if its been dispatched, and hopefully only once.

Bus::assertDispatchedTimes(function (SendFulfilled $job) use ($order) {
    return $job->order->id === $order->id;
}, 1);

In other tests I'm checking to see if some orders are dispatched, and some are not.

$response = $this->actingAs($user)
    ->post('/nova-api/orders/action?action=send-certificate', [
        'resources' => implode(',', $orderIds),
    ]);

foreach ($completeOrders as $order) {
    Bus::assertDispatchedTimes(function (SendFulfilled $job) use ($order) {
        return $job->order->id === $order->id;
    }, 1);
}

foreach ($incompleteOrders as $order) {
    Bus::assertNotDispatched(function (SendFulfilled $job) use ($order) {
        return $job->order->id === $order->id;
    });
}

Those callbacks are making sure the job has a specific ID, which is helpful if you're throwing it 6 orders and seeing which ones do or do not send.

To make this work, I did have to make a private property public, but it's readonly, and I don't care.

<?php

namespace App\Jobs\Orders\Emails;

use Illuminate\Bus\Queueable;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class SendFulfilled implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public function __construct(
        public readonly Order $order,
    ) {
    }

Then each test also has an assertion to make sure the HTTP response from the nova API is looking healthy.

 $response
      ->assertStatus(200)
      ->assertJson([
          'message' => 'Sent 3 order completion emails, and skipped 2.',
      ]);

200 OK and a message letting me know how many did or did not send.

Brilliant.

I need to start prying into testing more of Laravel Nova, because all of our controllers, models, services, repositories, commands, listeners, jobs, and queues have brilliant test coverage, but the user-facing Nova stuff was sorely lacking.

Now I can gender-neutral-scout my through the codebase, and any time I touch on a Nova Action I can write tests first, then make my changes with new tests, and I can have far more confidence I'm not going to upset any of the organizations busly trying to plant hundreds of thousands of trees and restore peat bogs, who really don't need to be suffering daft bugs getting their evidence and funding together.

If you've got any feedback, improvements, or hate-filled derranged comments about nothing in particular, you know where to leave em!

Of course five minutes after hitting send I found joshgaber/NovaUnit, but if that doesn't work for you any reason then at least now you have options.