Testing Model Events and Observers

+15 Mana ✨

Introduction

Observers sit between your application and the database, firing automatically on every create, update, and delete. That convenience is also a risk — silent side effects are easy to break. Tests are the safety net that lets you refactor observer logic without fear.

Key Concepts

  • Side effect: Any observable change outside the model itself — a notification sent, a log line written, a cache key cleared.
  • Faking: Replacing a real Laravel service (mail, notifications, events, queue) with a spy that records calls instead of executing them.
  • withoutEvents(): A helper that silences observers inside a closure or test, useful when setting up fixtures that shouldn't trigger observer logic.

Real World Context

When an observer fires a welcome email on User creation, every factory call in your test suite would hit the mail service. Tests become slow, flaky, and dependent on SMTP config. Faking the mailer lets your assertions focus on what the observer tried to do, not whether the side effect succeeded.

Deep Dive

Asserting an Observer's Side Effects

Here we verify that PostObserver::created dispatches a notification without actually sending one. Laravel's Notification::fake() swaps the notification service with a spy:

php
use App\Models\Post;
use App\Models\User;
use App\Notifications\NewPostNotification;
use Illuminate\Support\Facades\Notification;

it('notifies followers when a post is created', function () {
    Notification::fake();

    $author = User::factory()->create();
    $follower = User::factory()->create();
    $author->followers()->attach($follower);

    Post::factory()->for($author)->create(['title' => 'Hello world']);

    Notification::assertSentTo($follower, NewPostNotification::class);
});

After running this test, no real emails go out, but the assertion still catches regressions if the observer silently stops firing.

Verifying Cancellation

An observer that returns false from deleting vetoes the delete. Assert the behavior, not the implementation:

php
it('refuses to delete a post that has comments', function () {
    $post = Post::factory()->has(Comment::factory())->create();

    $result = $post->delete();

    expect($result)->toBeFalse();
    expect(Post::find($post->id))->not->toBeNull();
});

Notice we test the outcome (the post still exists) rather than inspecting the observer method directly — this stays green even if you later move the logic to a policy or a guard clause.

Silencing Observers in Setup

When seeding test fixtures, you often want to skip observer side effects:

php
Post::withoutEvents(function () {
    Post::factory()->count(100)->create();
});

This is the right call for setup code that just needs rows in the database, not behavior.

Common Pitfalls

  1. Forgetting Notification::fake() — Real notifications leak into your test runs, making them slow and environment-dependent.
  2. Asserting on private methods — Tests should observe behavior (the notification was sent, the cache was cleared), not the exact method name the observer calls. Otherwise refactoring breaks tests.
  3. Seeding with events enabled — Running a factory loop with live observers can fire thousands of side effects and even deadlock queued jobs.

Best Practices

  1. Fake early, assert late — Call Notification::fake(), Event::fake(), or Queue::fake() at the top of the test before any factory runs, then assert at the end.
  2. One observer behavior per test — Keep tests small and focused. A single it() block should cover one event and one expected side effect.
  3. Use expectsEvents sparingly — Prefer asserting on the downstream effect (a notification, a DB row) over asserting that a specific event fired. Downstream assertions survive refactors.

Summary

  • Observers are a prime source of hidden regressions; tests keep them honest.
  • Use Notification::fake(), Event::fake(), and Queue::fake() to stub external services.
  • Test the observable outcome, not the observer's internals.
  • Reach for withoutEvents() when seeding fixtures that shouldn't fire observer logic.
✓ Completed