NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Faking with Messages::fake()

Messages::fake() swaps in a recording fake and returns it as the handle for your assertions:

use RoundlyConsulting\Messages\Facades\Messages;

$fake = Messages::fake();

$alice->sendMessageTo($thread, 'Hi');                          // model traits are recorded too
Messages::thread($thread)->rename('Launch', by: $alice);

$fake->assertSent('Hi', to: $thread);
$fake->assertThreadRenamed($thread, to: 'Launch');
$fake->assertNothingDeleted();

The fake is a MessagesManager subtype, so constructor-injected managers receive it too. It still performs every operation — rows are written, events fire — and records each one that succeeds, whichever door it came through: the facade, an injected manager, the builders, the handles, the HasMessaging trait, Thread::markReadFor() and typing(), Participant::markAsRead() or the InteractsWithMessaging helpers. Work an action does on its own behalf — the participants start() adds, system messages — is not recorded separately, and actions you call directly bypass the fake.

Every assertion

Each assertion has an assertNothing… counterpart. Optional arguments narrow the match; models are compared with Model::is():

use RoundlyConsulting\Messages\Enums\ParticipantRole;

$fake->assertThreadCreated('Launch');              // start()->create(), or a direct() that created one
$fake->assertNothingCreated();

$fake->assertSent('Hi', to: $thread);              // both arguments optional
$fake->assertSentCount(2);
$fake->assertNothingSent();

$fake->assertThreadRenamed($thread, to: 'Launch crew');
$fake->assertNothingRenamed();

$fake->assertThreadArchived($thread);
$fake->assertNothingArchived();

$fake->assertMarkedRead($thread, by: $bob);
$fake->assertNothingMarkedRead();

$fake->assertTyping($thread, $bob);
$fake->assertNothingTyping();

$fake->assertParticipantAdded($thread, $carol);    // new rows only, not a re-add
$fake->assertNothingAdded();

$fake->assertParticipantRemoved($thread, $carol);  // remove() or leave()
$fake->assertNothingRemoved();

$fake->assertRoleChanged($thread, $carol, ParticipantRole::Admin);
$fake->assertNothingRoleChanged();

$fake->assertOwnershipTransferred($thread, to: $bob);
$fake->assertNothingTransferred();

$fake->assertEdited($message, 'fixed typo');
$fake->assertNothingEdited();

$fake->assertDeleted($message);
$fake->assertNothingDeleted();

$fake->assertPruned(days: 30);
$fake->assertNothingPruned();

assertThreadCreated() also counts a direct() that had to create its thread, and assertParticipantAdded() counts new rows only, never a re-add. For custom checks, recorded() returns every recorded MessagingCall, optionally filtered by operation:

use RoundlyConsulting\Messages\Enums\MessagingOperation;

$sends = $fake->recorded(MessagingOperation::Send);   // list<MessagingCall>, in call order

$sends[0]->thread;   // the Thread
$sends[0]->text;     // the body
$sends[0]->result;   // the Message the call returned

Factory states

use RoundlyConsulting\Messages\Enums\ParticipantRole;
use RoundlyConsulting\Messages\Models\Message;
use RoundlyConsulting\Messages\Models\Participant;
use RoundlyConsulting\Messages\Models\Thread;

$thread = Thread::factory()->public()->create();   // also ->private(), ->direct(), ->open()

Message::factory()->inThread($thread)->system()->create();

Participant::factory()->inThread($thread)->owner()->read()->create();
Participant::factory()->inThread($thread)->role(ParticipantRole::Admin)->unread()->create();

Participant factories default to a participant_type of user with a random id — pass participant_type and participant_id for a real model. read() leaves the row as Messages::markRead() would: the pointer on the thread’s newest message and read_at stamped; unread() clears both.

Helpers and Pest expectations

For host-app test suites, the InteractsWithMessaging trait adds fluent helpers — all through the manager, so the fake records them — and MessageExpectations registers Pest expectations:

// tests/Pest.php
RoundlyConsulting\Messages\Testing\MessageExpectations::register();

// in a test
uses(RoundlyConsulting\Messages\Testing\InteractsWithMessaging::class);

$thread = $this->startConversation($alice, $bob);
$this->actingAsParticipant($alice)->sendMessageAs($thread, 'Hi');

expect($thread)
    ->toHaveSentMessage('Hi')
    ->toHaveParticipant($bob)
    ->toHaveUnread($bob)
    ->toHaveRole($alice, ParticipantRole::Owner);

expect($reply)->toBeReplyTo($original);
  • actingAsParticipant($model) — remember the actor for sendMessageAs() and markReadAs().
  • startConversation(...$models) — a group thread, the first model as owner.
  • directThread($a, $b) — find or create the DM.
  • sendMessageAs($thread, $body, ?$actor) and markReadAs($thread, ?$actor) — throw if no actor is set or passed.
  • toHaveSentMessage(?$body), toHaveParticipant(), toHaveUnread(), toHaveRole() on a Thread; toBeReplyTo() on a Message.

A complete test

use RoundlyConsulting\Messages\Testing\InteractsWithMessaging;

uses(InteractsWithMessaging::class);

it('tracks unread state in a direct thread', function () {
    $alice = User::factory()->create();
    $bob = User::factory()->create();

    $thread = $this->directThread($alice, $bob);
    $this->actingAsParticipant($alice)->sendMessageAs($thread, 'Hi Bob');

    expect($thread)
        ->toHaveSentMessage('Hi Bob')
        ->toHaveUnread($bob);

    $this->markReadAs($thread, $bob);

    expect($bob->unreadCount())->toBe(0);
});

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.