LiveKit Testing Guide: How to Test Real-Time Video and Audio Apps
LiveKit is an open-source infrastructure for real-time audio, video, and data — used by teams building video conferencing, live streaming, voice agents, and collaborative applications. Testing LiveKit-powered apps is notoriously tricky: WebRTC is browser-dependent, media streams are asynchronous, and real room behavior requires multiple connected participants.
This guide shows you how to make LiveKit testing manageable: mocking the SDK for unit tests, running a local LiveKit server for integration tests, and handling the unique challenges of real-time testing.
Why LiveKit Testing Is Hard
Real-time systems have testing challenges that don't exist in traditional REST APIs:
- Non-deterministic timing: Events (participant joined, track published) arrive asynchronously
- Browser dependency: WebRTC APIs are browser-native — Node.js can't simulate them natively
- Multiple participants: Testing room behavior requires multiple connected clients
- Media tracks: Audio and video streams are stateful objects, not simple data
- Network conditions: Jitter, packet loss, and bandwidth affect behavior
Good testing architecture isolates these concerns so you can test business logic without wrestling with WebRTC.
Architecture for Testability
Separate your concerns:
// Don't do this (hard to test):
async function joinRoom() {
const room = new Room();
await room.connect(wsUrl, token);
room.on('trackSubscribed', (track, publication, participant) => {
const el = document.getElementById('video');
track.attach(el as HTMLVideoElement);
});
}
// Do this instead (testable):
// 1. RoomService — manages connection, exposes events
// 2. TrackManager — handles track attachment/detachment
// 3. ParticipantStore — manages participant state (Zustand, Jotai, etc.)
// 4. UI components — consume the store, no direct LiveKit callsThis separation lets you unit test business logic by injecting a mock room.
Unit Testing: Mocking the LiveKit SDK
// tests/mocks/livekit.ts
import { EventEmitter } from 'events';
import { ConnectionState, RoomEvent, TrackSource } from 'livekit-client';
export class MockRoom extends EventEmitter {
state = ConnectionState.Disconnected;
localParticipant = {
identity: 'local-user',
sid: 'PA_local',
isMicrophoneEnabled: false,
isCameraEnabled: false,
enableMicrophone: jest.fn().mockResolvedValue(undefined),
disableMicrophone: jest.fn().mockResolvedValue(undefined),
enableCamera: jest.fn().mockResolvedValue(undefined),
disableCamera: jest.fn().mockResolvedValue(undefined),
setMicrophoneEnabled: jest.fn().mockResolvedValue(undefined),
setCameraEnabled: jest.fn().mockResolvedValue(undefined),
publishTrack: jest.fn().mockResolvedValue({}),
unpublishTrack: jest.fn().mockResolvedValue(undefined),
};
participants = new Map();
connect = jest.fn().mockImplementation(async () => {
this.state = ConnectionState.Connected;
this.emit(RoomEvent.Connected);
});
disconnect = jest.fn().mockImplementation(() => {
this.state = ConnectionState.Disconnected;
this.emit(RoomEvent.Disconnected);
});
// Helper: simulate a remote participant joining
simulateParticipantJoined(identity: string) {
const participant = {
identity,
sid: `PA_${identity}`,
isSpeaking: false,
tracks: new Map(),
};
this.participants.set(identity, participant);
this.emit(RoomEvent.ParticipantConnected, participant);
return participant;
}
// Helper: simulate a track being published
simulateTrackPublished(participant: any, trackSid: string) {
const publication = { trackSid, source: TrackSource.Camera };
const track = { kind: 'video', attach: jest.fn(), detach: jest.fn() };
this.emit(RoomEvent.TrackSubscribed, track, publication, participant);
return { track, publication };
}
}Testing Room State Management
// src/stores/roomStore.test.ts
import { RoomEvent } from 'livekit-client';
import { MockRoom } from '../../tests/mocks/livekit';
import { RoomStore } from './roomStore';
describe('RoomStore', () => {
let mockRoom: MockRoom;
let store: RoomStore;
beforeEach(() => {
mockRoom = new MockRoom();
store = new RoomStore(mockRoom as any);
});
it('starts in disconnected state', () => {
expect(store.connectionState).toBe('disconnected');
expect(store.participants).toHaveLength(0);
});
it('updates state to connected after room.connect()', async () => {
await store.connect('ws://localhost:7880', 'test-token');
expect(store.connectionState).toBe('connected');
});
it('adds participant when ParticipantConnected fires', async () => {
await store.connect('ws://localhost:7880', 'test-token');
mockRoom.simulateParticipantJoined('alice');
expect(store.participants).toHaveLength(1);
expect(store.participants[0].identity).toBe('alice');
});
it('removes participant on ParticipantDisconnected', async () => {
await store.connect('ws://localhost:7880', 'test-token');
const alice = mockRoom.simulateParticipantJoined('alice');
mockRoom.emit(RoomEvent.ParticipantDisconnected, alice);
expect(store.participants).toHaveLength(0);
});
it('tracks speaking state', async () => {
await store.connect('ws://localhost:7880', 'test-token');
const alice = mockRoom.simulateParticipantJoined('alice');
mockRoom.emit(RoomEvent.ActiveSpeakersChanged, [{ ...alice, isSpeaking: true }]);
const aliceInStore = store.participants.find((p) => p.identity === 'alice');
expect(aliceInStore?.isSpeaking).toBe(true);
});
});Testing Media Controls
// src/services/mediaControls.test.ts
import { MockRoom } from '../../tests/mocks/livekit';
import { MediaControlsService } from './mediaControls';
describe('MediaControlsService', () => {
let mockRoom: MockRoom;
let service: MediaControlsService;
beforeEach(() => {
mockRoom = new MockRoom();
service = new MediaControlsService(mockRoom as any);
});
it('toggles microphone on and off', async () => {
await service.toggleMicrophone();
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
mockRoom.localParticipant.isMicrophoneEnabled = true;
await service.toggleMicrophone();
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
});
it('mutes all participants (moderator action)', async () => {
mockRoom.simulateParticipantJoined('alice');
mockRoom.simulateParticipantJoined('bob');
await service.muteAllParticipants();
// Should have sent data messages to all remote participants
expect(service.mutedParticipants).toContain('alice');
expect(service.mutedParticipants).toContain('bob');
});
});Integration Testing with Local LiveKit Server
LiveKit provides an open-source server that runs locally:
# With Docker
docker run -p 7880:7880 livekit/livekit-server --dev
<span class="hljs-comment"># Or download the binary
livekit-server --dev--dev mode skips JWT validation and uses a known key pair, making it easy to generate tokens for testing.
Generating Test Tokens
// tests/integration/token.ts
import { AccessToken } from 'livekit-server-sdk';
const TEST_API_KEY = 'devkey';
const TEST_API_SECRET = 'secret';
export function createTestToken(roomName: string, identity: string) {
const at = new AccessToken(TEST_API_KEY, TEST_API_SECRET, { identity });
at.addGrant({ roomJoin: true, room: roomName });
return at.toJwt();
}Integration Test: Two-Participant Room
// tests/integration/room.integration.test.ts
import { Room, RoomEvent, ConnectionState } from 'livekit-client';
import { createTestToken } from './token';
const LIVEKIT_URL = 'ws://localhost:7880';
const ROOM_NAME = `test-room-${Date.now()}`;
function connectParticipant(identity: string): Promise<Room> {
return new Promise((resolve, reject) => {
const room = new Room();
const token = createTestToken(ROOM_NAME, identity);
room.once(RoomEvent.Connected, () => resolve(room));
room.once(RoomEvent.Disconnected, () => reject(new Error('Disconnected')));
room.connect(LIVEKIT_URL, token).catch(reject);
});
}
describe('LiveKit room (integration)', () => {
let room1: Room;
let room2: Room;
beforeAll(async () => {
[room1, room2] = await Promise.all([
connectParticipant('user-1'),
connectParticipant('user-2'),
]);
}, 15_000);
afterAll(async () => {
room1?.disconnect();
room2?.disconnect();
});
it('both participants are connected', () => {
expect(room1.state).toBe(ConnectionState.Connected);
expect(room2.state).toBe(ConnectionState.Connected);
});
it('room1 sees room2 as a remote participant', () => {
expect(room1.participants.has('user-2')).toBe(true);
});
it('room2 sees room1 as a remote participant', () => {
expect(room2.participants.has('user-1')).toBe(true);
});
it('data messages arrive between participants', (done) => {
const testMessage = { type: 'chat', text: 'Hello from user-1' };
room2.once(RoomEvent.DataReceived, (data) => {
const received = JSON.parse(new TextDecoder().decode(data));
expect(received).toEqual(testMessage);
done();
});
const encoder = new TextEncoder();
room1.localParticipant.publishData(
encoder.encode(JSON.stringify(testMessage)),
);
}, 10_000);
});Testing LiveKit Server SDK (Backend)
If you're generating tokens or managing rooms server-side, test the livekit-server-sdk:
// src/api/rooms.test.ts
import { RoomServiceClient } from 'livekit-server-sdk';
jest.mock('livekit-server-sdk');
const mockListRooms = jest.fn();
const mockCreateRoom = jest.fn();
const mockDeleteRoom = jest.fn();
(RoomServiceClient as jest.MockedClass<typeof RoomServiceClient>).mockImplementation(() => ({
listRooms: mockListRooms,
createRoom: mockCreateRoom,
deleteRoom: mockDeleteRoom,
}));
describe('Rooms API', () => {
it('lists active rooms', async () => {
mockListRooms.mockResolvedValue([
{ name: 'room-1', numParticipants: 3 },
{ name: 'room-2', numParticipants: 1 },
]);
const rooms = await listActiveRooms();
expect(rooms).toHaveLength(2);
expect(rooms[0].name).toBe('room-1');
});
it('creates a room with correct metadata', async () => {
mockCreateRoom.mockResolvedValue({ name: 'new-room', sid: 'RM_123' });
await createRoom({ name: 'new-room', maxParticipants: 10 });
expect(mockCreateRoom).toHaveBeenCalledWith(
expect.objectContaining({ name: 'new-room', maxParticipants: 10 }),
);
});
});Testing Token Generation
Token generation is security-critical and fully unit-testable:
// src/auth/livekitToken.test.ts
import { AccessToken } from 'livekit-server-sdk';
import { generateRoomToken } from './livekitToken';
describe('generateRoomToken', () => {
it('generates a valid JWT', () => {
const token = generateRoomToken({ roomName: 'my-room', identity: 'user-1' });
expect(token).toBeDefined();
expect(token.split('.')).toHaveLength(3); // JWT format: header.payload.signature
});
it('includes room join grant', () => {
const token = generateRoomToken({ roomName: 'my-room', identity: 'user-1' });
// Decode (without verifying signature — for testing only)
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
expect(payload.video.roomJoin).toBe(true);
expect(payload.video.room).toBe('my-room');
});
it('grants admin permissions when requested', () => {
const token = generateRoomToken({
roomName: 'my-room',
identity: 'admin-user',
isAdmin: true,
});
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
expect(payload.video.roomAdmin).toBe(true);
});
it('sets correct identity in token', () => {
const token = generateRoomToken({ roomName: 'my-room', identity: 'specific-user' });
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
expect(payload.sub).toBe('specific-user');
});
it('token expires within expected window', () => {
const before = Math.floor(Date.now() / 1000);
const token = generateRoomToken({ roomName: 'my-room', identity: 'user-1', ttl: 3600 });
const after = Math.floor(Date.now() / 1000);
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
expect(payload.exp).toBeGreaterThanOrEqual(before + 3600);
expect(payload.exp).toBeLessThanOrEqual(after + 3600);
});
});CI Configuration
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
livekit:
image: livekit/livekit-server
ports:
- 7880:7880
options: --entrypoint livekit-server
env:
LIVEKIT_DEV: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test -- --testPathPattern="unit"
- run: npm test -- --testPathPattern="integration"
env:
LIVEKIT_URL: ws://localhost:7880Key Testing Principles
Mock the Room, not the WebRTC internals. The LiveKit Room class is your integration point. Mock it in unit tests — don't try to mock RTCPeerConnection or media devices. Those are browser internals that aren't worth testing at the unit level.
Test event-driven state transitions. LiveKit is event-driven. Your tests should simulate events (emit(RoomEvent.ParticipantConnected, ...)) and assert that your state correctly reflects them.
Use Promise-based event assertions with timeouts. Integration tests that wait for events should always have timeouts. Use jest.setTimeout(10000) or the test framework's timeout option for async event tests.
Test token generation as a security boundary. The JWT token determines what a participant can do. Test that admin tokens grant admin rights, that room names match, and that expiry is set correctly.
Don't test media quality. Whether video is high-definition, whether audio has echo cancellation — these are LiveKit's responsibility. Test that your application correctly enables/disables devices and responds to participant events.
For production LiveKit applications, HelpMeTest can monitor your room infrastructure — running scheduled tests that connect virtual participants and verify room creation, data message delivery, and token generation are all functioning correctly.