Solitary or Sociable? Testing Events and Listeners using Laravel

Testing with Laravel is very easy, but it can be a nightmare when the tests depend on Events and Listeners. In this post I’m gonna show you how you can simplify and improve those tests.

Laravel is one of the most popular PHP frameworks nowadays, and I’d say its Event handler is one of the most powerful features. Using events in Laravel is usually very easy, but when your app gets bigger you can get in trouble with previously written tests.

When testing events in Laravel, you can fake() events in a very simple way, like the documentation says. The problem is not with the events, but with the listeners, because usually a listener does a single action, but with more than one listener you start writing repeatable tests.

Solitary or Sociable?

The first time I saw the terms Solitary and Sociable tests was on the Martin Fowler’s post “UnitTest”, but actually this came up with Jay Fields on his book “Working Effectively with Unit Tests”.

According to Martin Fowler:

“Indeed using sociable unit tests was one of the reasons we were criticized for our use of the term “unit testing”. I think that the term “unit testing” is appropriate because these tests are tests of the behavior of a single unit. We write the tests assuming everything other than that unit is working correctly.”

And this is why I decided to write about this subject, because testing Events and Listeners in Laravel, using Sociable tests, can be dangerous. You can be testing more than one thing per test. And you shouldn’t.

Testing Events and Listeners

You don’t have to write unit tests for everything because you can write a Feature test (or a Sociable test if you want to) that can test the whole action at the same time, from the request to the response. This is good, but the problem is when this “action” does more than one thing at the time, like events and listeners.

You can have a Feature test that test if an Order is created when you POST to the /orders endpoint, for example. This is very easy with Laravel:

/** @test */
public function it_creates_a_new_order()
{
    $user = factory(User::class)->create();
    $data = factory(Order::class)->make();
    
    $this->assertEquals(0, Order::count());
    
    $this->actingAs($user)
        ->json('POST', '/orders', $data->toArray())
        ->assertStatus(201);

    $this->assertEquals(1, Order::count());
}

Everything is working fine, then you introduce an OrderCreated event, that generates the order number after its creation.

The Scenario

If you are familiar with Laravel development this is not something new for you. Let’s get part of a EventServiceProvider file:

\App\Events\Orders\OrderCreated::class => [
    \App\Listeners\Orders\GenerateNumber::class,
],

We have one event: OrderCreated that, for now, has only one listener: GenerateNumber. When a new order is created (already tested) we have to generate an order number for it.

/** @test */
public function it_generates_an_order_number_when_the_order_is_created()
{
    $user = factory(User::class)->create();
    $data = factory(Order::class)->make();

    $this->actingAs($user)
        ->json('POST', '/orders', $data->toArray())
        ->assertStatus(201);

    $order = Order::first();
    $this->assertNotEmpty($order->number);
}

Ok, so we are Feature testing if the order is created and when it’s created if we are generating the order number correctly. At this point, both tests should be passing. Good job!

Then you are working on a new feature, that forces an order to have more than one product (let’s call it Item here). So you migrated the database to allow that change, changing some columns, creating a new table – order_items maybe? -, etc. Then now you have to create an order item for each product. Then we add one more listener: CreateOrderItems.

\App\Events\Orders\OrderCreated::class => [
    \App\Listeners\Orders\GenerateNumber::class,
    \App\Listeners\Orders\CreateOrderItems::class,
],

In resume, when an order is created we (1) generate the order number and then (2) create order items. Seems good.

Big Tests

Let’s say on CreateOrderItems you do a basic database query to get a Rate object to calculate the final price for that order item. Nice! But now your it_generates_an_order_number_when_the_order_is_created() test is falling. Why? Because you didn’t create that Rate object on it, right?

This is when you start getting into problems because your first thought is to change your previous test to insert that Rate you need. But now you are testing two different things in the same test case, duplicating tests and make them bigger and bigger. This is not Unit test anymore, because you’re not testing a unit, but two units in the same test case.

If you’re testing the order number generation, so you should not include any “Rate” stuff there. They don’t belong to the same feature, right?

Refactoring Tests

The first thing I’d say is test each listener independently, like a class, what they really are. Literally a unit test, for each listener you have. For our scenario, let’s write tests for both listeners: GenerateNumberTest and CreateOrderItemTest.

namespace Anywhere;

class GenerateNumberTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function it_generates_an_order_number()
    {
        Event::fake(); // Because you are creating an Order here

        $order = factory(Order::class)->create();
        $this->assertEmpty($order->number);

        $event = \Mockery::mock(OrderCreated::class);
        $event->order = $order;

        $listener = app()->make(GenerateNumber::class);
        $listener->handle($event);

        $this->assertNotEmpty($order->number);
    }
}

Once we are creating an Order if we don’t Event::fake() here the OrderCreated event is gonna be fired, and we don’t want that, because we are not testing events, but a listener. Then we instantiate a GenerateNumber object, but we have to send an instance of the OrderCreated event to its handle() method.

If you aren’t familiar with the concept of Mock or Mockery in PHP this is a good opportunity to understand it in the real life.

As we have to send the event to the handle() method, let’s just mock it. It’s like creating a fake class. I’m saying here that I’ll need an object OrderCreated but it doesn’t matter what it does, but that it needs to give me an Order instance as a public $order property.

namespace Anywhere;

class CreateOrderItems extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function it_creates_order_items_when_an_order_is_created()
    {
        Event::fake(); // Because you are creating an Order here

        $order = factory(Order::class)->create();
        $this->assertEmpty($order->items); // item() is a HasMany relation

        $event = \Mockery::mock(OrderCreated::class);
        $event->order = $order;

        $listener = app()->make(CreateOrderItems::class);
        $listener->handle($event);

        $this->assertNotEmpty($order->items);
    }
}

So what? We are testing both listeners independently, what’s good. Then any feature test should be enough testing only if the OrderCreated event was dispatched, because the order number generation and the order items creation are already unit tested by other test cases.

/** @test */
public function it_dispatches_the_order_created_event()
{
    Event::fake(); // Let's fake the OrderCreated event here
    
    $user = factory(User::class)->create();
    $data = factory(Order::class)->make();

    $this->actingAs($user)
        ->json('POST', '/orders', $data->toArray())
        ->assertStatus(201);

    Event::assertDispatched(OrderCreated::class);
}

Bonus: I still need Sociable Tests

Ok, fine. But how are you going to test them? You can fake events, but not listeners. I can’t test just the GenerateNumber listener without calling CreateOrderItems. Laravel does not allow that.

Who said that?

The answer is a combination of Mocking and the Laravel’s Service Container. When you fire an event Laravel gets one instance for each GenerateNumber and CreateOrderItems classes, right? Then just mock them and replace the instance in the Service Container:


/**
 * @param array|string $listeners
 */
protected function mockListeners($listeners)
{
    $listeners = Arr::wrap($listeners);

    foreach ($listeners as $listener) {
        $mock = \Mockery::mock($listener);
        $mock->shouldReceive('handle');

        app()->instance($listener, $mock);
    }
}

Here I’m mocking any listener by its class name, with a handle() method that does nothing and returns nothing and then replacing the listener instance in the Service Container. This way I’m avoiding all those listeners calls because they’re just a mock.

TIP: Create a Trait like EventHelpers – or any other better name – and add this mockListeners() method to it, then you can reuse it in any test case you want to.

Do you remember our second Feature test case it_generates_an_order_number_when_the_order_is_created that was failing? Now we can make it work by mocking the CreateOrderItems listener:

/** @test */
public function it_generates_an_order_number_when_the_order_is_created()
{
    $this->mockListeners(CreateOrderItems::class); // Don't call me!

    $user = factory(User::class)->create();
    $data = factory(Order::class)->make();

    $this->actingAs($user)
        ->json('POST', '/orders', $data->toArray())
        ->assertStatus(201);

    $order = Order::first();
    $this->assertNotEmpty($order->number);
}

Conclusion

There is no right rule when talking about testing. Each case is different and you are going to find people that say one approach is better and the other one is not. Don’t think like them, because you can use both different opinions in your favor.

My opinion about writing tests is that is better to write smaller tests than a few big tests because it’s easier to think in small pieces of code. So I would say that about events and listeners is better to write unit tests for each listener and test if that request is dispatching some event like we did above. This will make your tests cleaner and doing exactly what they say they do.

Of course there will be some cases that you have to test the whole process, in a real request/response flow, so mocking listeners can be a very interesting alternative to make your tests “unit”.

So if you are testing the order number generation, for example, please, don’t worry about creating order items. This is not your test’s fault, it’s yours. Better coding!

Published by

Junior Grossi

senior software engineer & stutterer conference speaker. happy husband & dad. maintains Corcel PHP, elePHPant.me and PHPMG. Engineering Manager @ Paddle

4 thoughts on “Solitary or Sociable? Testing Events and Listeners using Laravel”

  1. Hi!
    Thanks for this article!
    When using mockListeners() when you add a new listener for an event, you have to go to each of the other listener’s test and add it mockListeners() argument… How do you deal with this inflexibility?

  2. Thanks for writing this up! Very helpful!

    I followed your last snippet and found that even if I added additional listeners that I know aren’t being triggered, the test still passes. This is my code:

    “`
    public function mockListeners($listeners)
    {
    $listeners = Arr::wrap($listeners);

    foreach ($listeners as $listener) {
    $mock = Mockery::mock($listener);
    $mock->shouldReceive(‘handle’);

    app()->instance($listener, $mock);
    }
    }
    “`

    “`
    public function testShouldRegisterTeamAndTriggerListeners()
    {
    $this->mockListeners([GenerateNewTeamPresets::class, ChargeSucceeded::class]);

    $user = factory(User::class)->create();
    $team = factory(Team::class)->make([
    ‘slug’ => ‘foo’,
    ]);

    $response = $this->actingAs($user)->json(‘POST’, ‘/register/organization’, $team->toArray() + [
    ‘terms’ => true,
    ‘country’ => ‘US’
    ]);

    $response->assertStatus(201);

    $team = Team::first();

    $response->assertJson([
    ‘redirect’ => route(‘team.dashboard’, $team->slug)
    ]);
    }
    “`

    Did I miss something?

    1. if might mock only the desired listeners. if you add another listener to an event you also have to mock it if desired, otherwise the service container will call it as the real object, not the mocked one

Leave a Reply

Your email address will not be published. Required fields are marked *