Testing & TDD

Testing WebSockets & Real-time Features

15 min Lesson 31 of 35

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:

  1. Broadcasts notifications to specific users
  2. Supports marking notifications as read in real-time
  3. Shows online/offline presence for users
  4. Handles notification deletion
  5. 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