Testing & TDD
Testing WebSockets & Real-time Features
Testing WebSockets & Real-time Features
Real-time features powered by WebSockets and event broadcasting are essential for modern web applications. Testing these features requires special techniques to simulate connections, events, and real-time interactions.
Understanding Laravel Broadcasting
Laravel provides a unified API for broadcasting events across various drivers (Pusher, Ably, Redis, etc.). We'll focus on testing these features effectively.
Broadcasting Basics
<?php
// Event class that broadcasts
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Queue\SerializesModels;
class OrderStatusUpdated implements ShouldBroadcast
{
use InteractsWithSockets, SerializesModels;
public function __construct(
public Order $order
) {}
public function broadcastOn(): Channel
{
return new Channel('orders.' . $this->order->id);
}
public function broadcastAs(): string
{
return 'status.updated';
}
public function broadcastWith(): array
{
return [
'id' => $this->order->id,
'status' => $this->order->status,
'updated_at' => $this->order->updated_at->toISOString(),
];
}
}
Testing Broadcast Events
Basic Event Broadcasting Test
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\Order;
use App\Events\OrderStatusUpdated;
use Illuminate\Support\Facades\Event;
use Illuminate\Foundation\Testing\RefreshDatabase;
class OrderBroadcastingTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function order_status_update_broadcasts_event()
{
Event::fake();
$order = Order::factory()->create(['status' => 'pending']);
// Trigger the action that broadcasts
$order->update(['status' => 'processing']);
// Assert the event was broadcast
Event::assertDispatched(OrderStatusUpdated::class, function ($event) use ($order) {
return $event->order->id === $order->id
&& $event->order->status === 'processing';
});
}
/** @test */
public function event_broadcasts_on_correct_channel()
{
$order = Order::factory()->create();
$event = new OrderStatusUpdated($order);
$channel = $event->broadcastOn();
$this->assertEquals('orders.' . $order->id, $channel->name);
}
/** @test */
public function event_broadcasts_with_correct_data()
{
$order = Order::factory()->create([
'id' => 123,
'status' => 'completed'
]);
$event = new OrderStatusUpdated($order);
$data = $event->broadcastWith();
$this->assertEquals([
'id' => 123,
'status' => 'completed',
'updated_at' => $order->updated_at->toISOString(),
], $data);
}
/** @test */
public function event_broadcasts_as_correct_name()
{
$order = Order::factory()->create();
$event = new OrderStatusUpdated($order);
$this->assertEquals('status.updated', $event->broadcastAs());
}
}
Testing Private Channels
Private channels require authentication. Here's how to test them:
<?php
// Private channel event
class UserNotification implements ShouldBroadcast
{
public function __construct(
public User $user,
public string $message
) {}
public function broadcastOn(): Channel
{
return new PrivateChannel('users.' . $this->user->id);
}
}
// Testing private channels
/** @test */
public function private_channel_requires_authentication()
{
$user = User::factory()->create();
// Guest cannot access private channel
$this->get('/broadcasting/auth', [
'channel_name' => 'private-users.' . $user->id
])->assertUnauthorized();
// Authenticated user can access their channel
$this->actingAs($user)
->get('/broadcasting/auth', [
'channel_name' => 'private-users.' . $user->id
])->assertOk();
}
/** @test */
public function user_cannot_access_another_users_private_channel()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$this->actingAs($user1)
->get('/broadcasting/auth', [
'channel_name' => 'private-users.' . $user2->id
])->assertForbidden();
}
Testing Presence Channels
Presence channels track who's subscribed to a channel:
<?php
// routes/channels.php
Broadcast::channel('chat.{roomId}', function (User $user, int $roomId) {
if ($user->canAccessRoom($roomId)) {
return [
'id' => $user->id,
'name' => $user->name,
'avatar' => $user->avatar_url,
];
}
});
// Testing presence channels
/** @test */
public function user_can_join_authorized_presence_channel()
{
$user = User::factory()->create();
$room = ChatRoom::factory()->create();
$room->members()->attach($user);
$response = $this->actingAs($user)
->post('/broadcasting/auth', [
'channel_name' => 'presence-chat.' . $room->id,
'socket_id' => '123.456'
]);
$response->assertOk()
->assertJson([
'channel_data' => [
'user_id' => $user->id,
'user_info' => [
'id' => $user->id,
'name' => $user->name,
'avatar' => $user->avatar_url,
]
]
]);
}
/** @test */
public function user_cannot_join_unauthorized_presence_channel()
{
$user = User::factory()->create();
$room = ChatRoom::factory()->create();
// User is NOT a member
$response = $this->actingAs($user)
->post('/broadcasting/auth', [
'channel_name' => 'presence-chat.' . $room->id,
'socket_id' => '123.456'
]);
$response->assertForbidden();
}
Testing WebSocket Messages
Using Broadcast Fake
<?php
use Illuminate\Support\Facades\Broadcast;
/** @test */
public function message_is_broadcast_when_sent()
{
Broadcast::fake();
$user = User::factory()->create();
$room = ChatRoom::factory()->create();
$this->actingAs($user)
->post('/api/chat/' . $room->id . '/messages', [
'message' => 'Hello, World!'
])->assertCreated();
Broadcast::assertBroadcastOn(
'presence-chat.' . $room->id,
MessageSent::class
);
}
/** @test */
public function broadcast_contains_correct_message_data()
{
Event::fake();
$user = User::factory()->create();
$room = ChatRoom::factory()->create();
$this->actingAs($user)
->post('/api/chat/' . $room->id . '/messages', [
'message' => 'Test message'
]);
Event::assertDispatched(MessageSent::class, function ($event) {
return $event->message->body === 'Test message';
});
}
Testing Real-time Notifications
<?php
// Notification that broadcasts
namespace App\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
class OrderShipped extends Notification implements ShouldBroadcast
{
public function __construct(
public Order $order
) {}
public function via($notifiable): array
{
return ['database', 'broadcast'];
}
public function toBroadcast($notifiable): BroadcastMessage
{
return new BroadcastMessage([
'order_id' => $this->order->id,
'message' => 'Your order has been shipped!',
'tracking_number' => $this->order->tracking_number,
]);
}
public function broadcastType(): string
{
return 'order.shipped';
}
}
// Testing notification broadcasting
use Illuminate\Support\Facades\Notification;
/** @test */
public function notification_is_broadcast_to_user()
{
Notification::fake();
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);
// Trigger notification
$order->ship('TRACK123');
Notification::assertSentTo($user, OrderShipped::class);
}
/** @test */
public function broadcast_notification_contains_correct_data()
{
$user = User::factory()->create();
$order = Order::factory()->create([
'id' => 123,
'tracking_number' => 'TRACK123'
]);
$notification = new OrderShipped($order);
$data = $notification->toBroadcast($user);
$this->assertEquals([
'order_id' => 123,
'message' => 'Your order has been shipped!',
'tracking_number' => 'TRACK123',
], $data->data);
}
Integration Testing with Laravel Echo
Testing Echo Client Integration
<?php
// JavaScript test with Jest
describe('Real-time Order Updates', () => {
let echo;
beforeEach(() => {
echo = new Echo({
broadcaster: 'pusher',
key: process.env.PUSHER_APP_KEY,
cluster: process.env.PUSHER_APP_CLUSTER,
forceTLS: true
});
});
afterEach(() => {
echo.disconnect();
});
test('receives order status updates', (done) => {
const orderId = 123;
echo.channel(`orders.${orderId}`)
.listen('status.updated', (event) => {
expect(event.id).toBe(orderId);
expect(event.status).toBe('processing');
done();
});
// Trigger the event from the backend
axios.patch(`/api/orders/${orderId}`, {
status: 'processing'
});
});
test('handles connection errors gracefully', (done) => {
echo.connector.pusher.connection.bind('error', (error) => {
expect(error).toBeDefined();
done();
});
// Trigger connection error
echo.connector.pusher.connection.emit('error', {
error: 'Connection failed'
});
});
});
Testing with Pusher Mock
<?php
use Pusher\Pusher;
use Mockery;
/** @test */
public function event_is_pushed_to_pusher()
{
// Mock Pusher
$pusherMock = Mockery::mock(Pusher::class);
$pusherMock->shouldReceive('trigger')
->once()
->with(
'orders.123',
'status.updated',
Mockery::on(function ($data) {
return $data['status'] === 'processing';
})
)
->andReturn(true);
$this->app->instance(Pusher::class, $pusherMock);
$order = Order::factory()->create(['id' => 123]);
$order->update(['status' => 'processing']);
}
Testing Redis Broadcasting
<?php
use Illuminate\Support\Facades\Redis;
/** @test */
public function event_is_published_to_redis()
{
Redis::shouldReceive('connection->publish')
->once()
->with(
Mockery::type('string'),
Mockery::on(function ($payload) {
$data = json_decode($payload, true);
return $data['event'] === 'App\\Events\\OrderStatusUpdated';
})
);
$order = Order::factory()->create();
broadcast(new OrderStatusUpdated($order));
}
End-to-End Real-time Testing
<?php
use Laravel\Dusk\Browser;
/** @test */
public function user_receives_real_time_notification()
{
$this->browse(function (Browser $browser) {
$user = User::factory()->create();
$browser->loginAs($user)
->visit('/dashboard')
->waitFor('#notifications');
// Trigger notification from backend
$user->notify(new OrderShipped(Order::factory()->create()));
// Wait for real-time notification to appear
$browser->waitForText('Your order has been shipped!', 5)
->assertSee('TRACK123');
});
}
/** @test */
public function chat_messages_appear_in_real_time()
{
$this->browse(function (Browser $sender, Browser $receiver) {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$room = ChatRoom::factory()->create();
$sender->loginAs($user1)->visit('/chat/' . $room->id);
$receiver->loginAs($user2)->visit('/chat/' . $room->id);
$sender->type('message', 'Hello from user 1!')
->press('Send');
// Receiver should see the message in real-time
$receiver->waitForText('Hello from user 1!', 5)
->assertSee($user1->name);
});
}
Testing WebSocket Connection Lifecycle
<?php
/** @test */
public function tracks_user_presence_in_chat_room()
{
Event::fake();
$user = User::factory()->create();
$room = ChatRoom::factory()->create();
// Simulate user joining
$this->actingAs($user)
->post('/api/chat/' . $room->id . '/join')
->assertOk();
Event::assertDispatched(UserJoinedRoom::class, function ($event) use ($user, $room) {
return $event->user->id === $user->id
&& $event->room->id === $room->id;
});
// Simulate user leaving
$this->actingAs($user)
->post('/api/chat/' . $room->id . '/leave')
->assertOk();
Event::assertDispatched(UserLeftRoom::class, function ($event) use ($user, $room) {
return $event->user->id === $user->id
&& $event->room->id === $room->id;
});
}
Performance Testing Real-time Features
<?php
/** @test */
public function handles_multiple_concurrent_broadcasts()
{
Event::fake();
$users = User::factory()->count(100)->create();
$startTime = microtime(true);
foreach ($users as $user) {
broadcast(new UserNotification($user, 'Test message'));
}
$endTime = microtime(true);
$duration = $endTime - $startTime;
Event::assertDispatchedTimes(UserNotification::class, 100);
// Assert broadcasts complete within acceptable time
$this->assertLessThan(2.0, $duration, 'Broadcasting took too long');
}
/** @test */
public function queued_broadcasts_are_processed_correctly()
{
Queue::fake();
$order = Order::factory()->create();
broadcast(new OrderStatusUpdated($order))->toOthers();
Queue::assertPushed(BroadcastEvent::class, function ($job) {
return $job->event instanceof OrderStatusUpdated;
});
}
Best Practices for Testing Real-time Features:
- Use Event::fake(): For unit tests, fake events to avoid actual broadcasting
- Test channel authorization: Ensure private/presence channels are properly secured
- Verify payload structure: Check that broadcast data matches client expectations
- Test connection states: Handle connected, disconnected, and error states
- Performance matters: Test broadcast performance under load
Common Pitfalls:
- Forgetting to fake broadcasts in tests (causing actual API calls)
- Not testing channel authorization properly
- Ignoring race conditions in real-time updates
- Not handling reconnection logic
- Missing timeout tests for slow connections
Practice Exercise:
Create a comprehensive test suite for a real-time notification system that:
- Broadcasts notifications to specific users
- Supports marking notifications as read in real-time
- Shows online/offline presence for users
- Handles notification deletion
- Tests private channel authorization
Summary
Testing real-time features requires:
- Event Faking: Use Laravel's Event and Broadcast fakes for unit tests
- Channel Testing: Verify public, private, and presence channel behavior
- Authorization: Test that channels are properly secured
- Data Verification: Ensure broadcast payloads match expectations
- Integration Tests: Use Dusk or JavaScript tests for full E2E scenarios
- Performance: Test system behavior under concurrent load