Using Laravel events in unit tests

Robert Baelde
5 min readFeb 20, 2022

Laravel is a great framework for rapid development. The “magic” provided by the framework (next to a lot of other stuff that makes the Laravel community great) gives developers superpowers to ship fast! This magic however comes at a cost. Mainly:

  • (Unit)Tests are slower when the framework needs to boot. (This becomes a problem when your application grows in size)
  • Magic often can’t be recognized by an IDE. For example, can you find all classes that dispatch events in your application?

Let me start this article by acknowledging that this magic is great when working on a small project. Above mentioned costs will only play a factor in big projects, often within a business environment and maintained by a team of developers. In those cases, it’s useful to be sparse with using magic, in favor of the long-term maintainability of the project.

In this article, we’ll look at Laravels event bus and show how events can be emitted without the event helpers provided. This will allow us to run unit tests without booting the complete framework.

Dispatching Events within Laravel

Within Laravel there are two ways to dispatch events:

1. Events dispatching themselves, using the Dispatchable trait.

UserCreated::dispatch($user->id);

2. Using the event() helper method

event(new UserCreated($user->id));

The Dispatchable trait

The dispatchable trait works quite simple under the hood:

trait Dispatchable
{
/**
* Dispatch the event with the given arguments.
*
*
@return void
*/
public static function
dispatch()
{
return event(new static(...func_get_args()));
}
}

The static method creates a new event instance, using the arguments passed to it. It then passes this event to the events() helper.

Not only does this violate the Single responsibility principle of the event (The event shouldn’t know how to dispatch itself), it also disables autocompletion and type safety checks. Most IDE’s will tell you when you pass the wrong parameters when constructing a class. This gets hindered by the helper.

// Given this simple event:
class UserCreated(public readonly string $userId)
{
}
new UserCreated(); //IDE will give a warning that one parameter is expectednew UserCreated(1); //IDE will give a warning that a string is expectedUserCreated::dispatch(); // IDE doesn't give a warning here
UserCreated::dispatch(1); // IDE doesn't give a warning here

For this reason alone, I’d advocate staying away from the dispatchable trait, even in smaller apps.

The global event method

Another way of dispatching events, actually being used by the trait as well, is the global event() method. This method resolves the EventDispatcher from the frameworks container and uses this to dispatch the event passed to it.

function event(...$args)
{
return app('events')->dispatch(...$args);
}

Laravel’s Event testing helpers

Out of the box, Laravel gives you some awesome helpers that allow us to assert that specific events have been dispatched.

Event::fake(); // fake the event busEvent::assertDispatched(UserCreated::class); // assert that the event was dispatched

Event::fake() swaps out Laravel’s event bus with a fake implementation. This fake implementation allows us to assert dispatched events. And to stop events from propagating to other parts of the system.

Dispatching events without helper methods

Now we have an understanding of the basics of Laravels event helpers, we can explore how we can dispatch events without magic. To dispatch an event without magic, we need access to the event dispatcher. We can utilize dependency injection for this.

Given the following example:

use Illuminate\Contracts\Events\Dispatcher;

class RegisterUser
{
public function __construct(
protected Dispatcher $dispatcher
) {
}

public function execute(string $userId)
{
// Make a call to some repository here
$this->dispatcher->dispatch(new UserRegistered($userId));
}
}

When this class is resolved from the container, Laravel knows how to instantiate the configured event dispatcher, and passes it to the constructor of our class.

Within a controller, this might look like this:

class UserController
{
public function __invoke(Request $request, RegisterUser $registerUser)
{
$registerUser->execute($request->input('userId'));
return response()->json(['success' => true]);
}
}

Refactor our test

This small change in the way we dispatch events has some major effects on how we test the RegisterUser action.

Let's imagine we have the following test case, requiring the framework to boot to make it work:

class RegisterUserTest extends \Tests\TestCase
{
/** @test */
public function
it_dispatches_an_event_when_a_user_is_registered()
{
Event::fake();

$action = resolve(RegisterUser::class);
$action->execute('testUserId');

Event::assertDispatched(UserRegistered::class, function (UserRegistered $event){
$this->assertEquals('testUserId', $event->userId);
return true;
});
}
}

We can now refactor this to the following:

use Illuminate\Events\Dispatcher;
use Illuminate\Events\NullDispatcher;
use Illuminate\Support\Testing\Fakes\EventFake;

class RegisterUserTest extends \PHPUnit\Framework\TestCase
{
/** @test */
public function
it_dispatches_a_event_when_a_user_is_registered()
{
$fakeDispatcher = new EventFake(new NullDispatcher(new Dispatcher()));
$action = new RegisterUser($fakeDispatcher);
$action->execute('testUserId');

$fakeDispatcher->assertDispatched(UserRegistered::class, function (UserRegistered $event){
$this->assertEquals('testUserId', $event->userId);
return true;
});
}
}

In the first line of the test, we construct a fake event dispatcher instance. We use the classes Laravel binds when calling `Event::fake()`. EventFake allows us to call methods like assertDispatched on the event bus. Since this class can work as a proxy, it requires another dispatcher as its constructor argument.

Since we don’t want to cause any side effects from our unit tests, we pass an instance of the NullDispatcher to it. This classes dispatch method is a dead-end for events.

This dispatcher needs another dispatch instance though, this is because it still allows registering event subscribers and listeners. To fulfill this need, we new up the default Dispatcher instance.

To clean up our tests, we can extract the construction of our fake dispatcher to a trait:

use Illuminate\Events\NullDispatcher;
use Illuminate\Support\Testing\Fakes\EventFake;

trait InteractsWithEvents
{
private EventFake $dispatcher;

public function eventDispatcher(): EventFake
{
if (!isset($this->dispatcher)) {
$this->dispatcher = new EventFake(new NullDispatcher(new \Illuminate\Events\Dispatcher()));
}
return $this->dispatcher;
}
}

This results in a cleaner test:

class RegisterUserTest extends \PHPUnit\Framework\TestCase
{
use InteractsWithEvents;

/** @test */
public function
it_dispatches_a_event_when_a_user_is_registered()
{
$action = new RegisterUser($this->eventDispatcher());
$action->execute('testUserId');

$this->eventDispatcher()->assertDispatched(UserRegistered::class, function (UserRegistered $event){
$this->assertEquals('testUserId', $event->userId);
return true;
});
}
}

Conclusion

This method of event dispatching might be over-engineering for small to medium-sized applications, for bigger ones it is a great improvement.

Let's look back at the two points we touched on in the intro section:

  1. Unit tests: this method of event dispatching allows us to run unit tests without booting the framework. On my machine, a simple unit test when booting Laravel takes +- 60ms, whereas a test without booting the framework takes +-12ms.
    For one test, this isn’t a big difference, but do the math when you’d run more than thousands of unit tests.
  2. IDE support: Since every class that dispatches events now has the Dispatcher contract as a dependency, we can ask the IDE “Show us all classes that depend on the dispatcher”.

Of course, this methodology can be applied to more magic within the Laravel framework. Think for example of Queue-ing jobs, or interacting with the Cache or Database. In the end, being strict about Unit tests running without the framework will not only help in regards to speed. But overall will guide you to a more solid, scalable, and maintainable architecture.

In the end, with these kinds of improvements a team can make sure that Laravel is not only great for rapid development but is also an awesome tool for large long-living projects.

--

--