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 thismockListeners()
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!
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?
Great article indeed! Thank you for sharing your knowledge.
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?
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