Testing Daily.co and 100ms Video SDKs: A Developer's Guide
Video calling features built on WebRTC SDKs like Daily.co and 100ms are notoriously difficult to test. The real-time nature, browser API dependencies, and multi-participant complexity create challenges that don't exist in typical web development. But skipping tests means discovering join failures, audio bugs, and token expiration issues in production.
This guide covers practical testing strategies for Daily.co and 100ms integrations: unit testing your application logic, integration testing room management APIs, and E2E testing video call flows.
The Testing Challenge with WebRTC
WebRTC SDKs depend on browser APIs (getUserMedia, RTCPeerConnection, MediaStream) that don't exist in Node.js test environments. This forces a layered testing approach:
- Unit tests — mock the SDK and test your application logic (join handlers, UI state, error handling)
- Integration tests — call the REST APIs for room creation and token generation; these work in any environment
- E2E tests — use Playwright with real browser contexts that have real WebRTC APIs
Testing Daily.co Integrations
Setting Up Mocks
// test/mocks/daily.ts
export const mockDailyCall = {
join: jest.fn().mockResolvedValue(undefined),
leave: jest.fn().mockResolvedValue(undefined),
destroy: jest.fn().mockResolvedValue(undefined),
setLocalAudio: jest.fn(),
setLocalVideo: jest.fn(),
participants: jest.fn().mockReturnValue({}),
on: jest.fn().mockReturnThis(),
off: jest.fn().mockReturnThis(),
meetingState: jest.fn().mockReturnValue('joined-meeting'),
localAudio: jest.fn().mockReturnValue(true),
localVideo: jest.fn().mockReturnValue(true),
};
jest.mock('@daily-co/daily-js', () => ({
createCallObject: jest.fn().mockReturnValue(mockDailyCall),
}));Unit Testing the Call Manager
// src/hooks/useVideoCall.test.ts
import { renderHook, act } from '@testing-library/react';
import { useVideoCall } from './useVideoCall';
import DailyIframe from '@daily-co/daily-js';
import { mockDailyCall } from '../../test/mocks/daily';
describe('useVideoCall', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('initializes call object and joins with room URL', async () => {
const { result } = renderHook(() => useVideoCall());
await act(async () => {
await result.current.joinRoom('https://myapp.daily.co/test-room', 'user-token');
});
expect(DailyIframe.createCallObject).toHaveBeenCalled();
expect(mockDailyCall.join).toHaveBeenCalledWith({
url: 'https://myapp.daily.co/test-room',
token: 'user-token',
});
expect(result.current.callState).toBe('joined');
});
it('handles join failure gracefully', async () => {
mockDailyCall.join.mockRejectedValue(new Error('Meeting expired'));
const { result } = renderHook(() => useVideoCall());
await act(async () => {
await result.current.joinRoom('https://myapp.daily.co/test-room', 'bad-token');
});
expect(result.current.callState).toBe('error');
expect(result.current.errorMessage).toBe('Meeting expired');
});
it('leaves call and cleans up on leave()', async () => {
const { result } = renderHook(() => useVideoCall());
await act(async () => {
await result.current.joinRoom('https://myapp.daily.co/test-room', 'token');
await result.current.leaveRoom();
});
expect(mockDailyCall.leave).toHaveBeenCalled();
expect(mockDailyCall.destroy).toHaveBeenCalled();
expect(result.current.callState).toBe('left');
});
it('mutes/unmutes local audio', async () => {
const { result } = renderHook(() => useVideoCall());
await act(async () => {
await result.current.joinRoom('https://myapp.daily.co/test-room', 'token');
result.current.toggleAudio();
});
expect(mockDailyCall.setLocalAudio).toHaveBeenCalledWith(false);
});
it('handles participant-joined event', async () => {
const { result } = renderHook(() => useVideoCall());
// Capture the event handler registered with .on('participant-joined', ...)
let participantJoinedHandler: Function;
mockDailyCall.on.mockImplementation((event: string, handler: Function) => {
if (event === 'participant-joined') participantJoinedHandler = handler;
return mockDailyCall;
});
await act(async () => {
await result.current.joinRoom('https://myapp.daily.co/test-room', 'token');
});
act(() => {
participantJoinedHandler!({
participant: { session_id: 'sess-123', user_name: 'Alice' },
});
});
expect(result.current.participants).toContainEqual(
expect.objectContaining({ user_name: 'Alice' })
);
});
});Integration Testing Daily.co Room API
// test/integration/daily-rooms.test.ts
import axios from 'axios';
const DAILY_API_KEY = process.env.DAILY_API_KEY!;
const BASE_URL = 'https://api.daily.co/v1';
const api = axios.create({
baseURL: BASE_URL,
headers: { Authorization: `Bearer ${DAILY_API_KEY}` },
});
describe('Daily.co Room API', () => {
let roomName: string;
afterEach(async () => {
// Clean up test rooms
if (roomName) {
await api.delete(`/rooms/${roomName}`).catch(() => {});
}
});
it('creates a room with expiration', async () => {
roomName = `test-room-${Date.now()}`;
const expiry = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
const { data } = await api.post('/rooms', {
name: roomName,
privacy: 'private',
properties: {
exp: expiry,
max_participants: 10,
},
});
expect(data.name).toBe(roomName);
expect(data.privacy).toBe('private');
expect(data.config.exp).toBe(expiry);
});
it('creates a meeting token for a room', async () => {
roomName = `test-room-${Date.now()}`;
await api.post('/rooms', { name: roomName, privacy: 'private' });
const { data } = await api.post('/meeting-tokens', {
properties: {
room_name: roomName,
user_name: 'Test User',
is_owner: false,
exp: Math.floor(Date.now() / 1000) + 3600,
},
});
expect(data.token).toBeDefined();
expect(typeof data.token).toBe('string');
expect(data.token.split('.').length).toBe(3); // JWT format
});
it('rejects joining a room after expiration', async () => {
roomName = `test-room-${Date.now()}`;
const expiry = Math.floor(Date.now() / 1000) - 1; // Already expired
await api.post('/rooms', {
name: roomName,
privacy: 'private',
properties: { exp: expiry },
});
// Creating a token for an expired room should fail or produce an expired token
const { data } = await api.post('/meeting-tokens', {
properties: {
room_name: roomName,
exp: expiry,
},
});
// Token is created but will be rejected at join time
expect(data.token).toBeDefined();
});
it('deletes a room', async () => {
roomName = `test-room-${Date.now()}`;
await api.post('/rooms', { name: roomName });
await api.delete(`/rooms/${roomName}`);
const response = await api.get(`/rooms/${roomName}`).catch(e => e.response);
expect(response.status).toBe(404);
roomName = ''; // Already deleted, skip afterEach cleanup
});
});Testing 100ms Integrations
Mocking the 100ms SDK
// test/mocks/hms.ts
export const mockHMSActions = {
join: jest.fn().mockResolvedValue(undefined),
leave: jest.fn().mockResolvedValue(undefined),
setLocalAudioEnabled: jest.fn().mockResolvedValue(undefined),
setLocalVideoEnabled: jest.fn().mockResolvedValue(undefined),
sendBroadcastMessage: jest.fn().mockResolvedValue(undefined),
};
export const mockHMSStore = {
getState: jest.fn(),
subscribe: jest.fn(),
};
jest.mock('@100mslive/react-sdk', () => ({
useHMSActions: jest.fn().mockReturnValue(mockHMSActions),
useHMSStore: jest.fn(),
selectPeers: jest.fn(),
selectIsConnectedToRoom: jest.fn(),
selectLocalPeer: jest.fn(),
HMSRoomProvider: ({ children }: { children: React.ReactNode }) => children,
}));Unit Testing 100ms Components
// src/components/VideoRoom.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { useHMSActions, useHMSStore, selectIsConnectedToRoom } from '@100mslive/react-sdk';
import { VideoRoom } from './VideoRoom';
import { mockHMSActions } from '../../test/mocks/hms';
const mockUseHMSStore = useHMSStore as jest.Mock;
describe('VideoRoom', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('joins room on mount with auth token', async () => {
mockUseHMSStore.mockImplementation((selector) => {
if (selector === selectIsConnectedToRoom) return false;
return null;
});
render(<VideoRoom authToken="test-token" userName="Alice" />);
await waitFor(() => {
expect(mockHMSActions.join).toHaveBeenCalledWith({
authToken: 'test-token',
userName: 'Alice',
settings: {
isAudioMuted: false,
isVideoMuted: false,
},
});
});
});
it('shows participants when connected', () => {
mockUseHMSStore.mockImplementation((selector) => {
if (selector === selectIsConnectedToRoom) return true;
return [
{ id: 'peer-1', name: 'Alice', isLocal: true },
{ id: 'peer-2', name: 'Bob', isLocal: false },
];
});
render(<VideoRoom authToken="test-token" userName="Alice" />);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
it('mutes audio when mute button is clicked', async () => {
mockUseHMSStore.mockReturnValue(true);
render(<VideoRoom authToken="test-token" userName="Alice" />);
fireEvent.click(screen.getByRole('button', { name: /mute audio/i }));
expect(mockHMSActions.setLocalAudioEnabled).toHaveBeenCalledWith(false);
});
it('leaves room when leave button is clicked', async () => {
mockUseHMSStore.mockImplementation((selector) => {
if (selector === selectIsConnectedToRoom) return true;
return null;
});
render(<VideoRoom authToken="test-token" userName="Alice" />);
fireEvent.click(screen.getByRole('button', { name: /leave/i }));
expect(mockHMSActions.leave).toHaveBeenCalled();
});
});Integration Testing 100ms Management API
// test/integration/100ms-rooms.test.ts
import axios from 'axios';
const HMS_TOKEN = process.env.HMS_MANAGEMENT_TOKEN!;
const BASE_URL = 'https://api.100ms.live/v2';
const api = axios.create({
baseURL: BASE_URL,
headers: { Authorization: `Management ${HMS_TOKEN}` },
});
describe('100ms Room API', () => {
let roomId: string;
afterEach(async () => {
if (roomId) {
await api.post(`/rooms/${roomId}`, { enabled: false }).catch(() => {});
}
});
it('creates a room', async () => {
const { data } = await api.post('/rooms', {
name: `test-room-${Date.now()}`,
description: 'Integration test room',
template_id: process.env.HMS_TEMPLATE_ID,
});
roomId = data.id;
expect(data.id).toBeDefined();
expect(data.enabled).toBe(true);
});
it('creates an auth token for a room', async () => {
const { data: room } = await api.post('/rooms', {
name: `test-room-${Date.now()}`,
template_id: process.env.HMS_TEMPLATE_ID,
});
roomId = room.id;
const { data } = await api.post('/auth-token', {
room_id: roomId,
user_id: 'test-user-1',
role: 'host',
type: 'app',
});
expect(data.token).toBeDefined();
expect(typeof data.token).toBe('string');
});
it('lists active sessions for a room', async () => {
const { data: room } = await api.post('/rooms', {
name: `test-room-${Date.now()}`,
template_id: process.env.HMS_TEMPLATE_ID,
});
roomId = room.id;
const { data } = await api.get('/sessions', {
params: { room_id: roomId },
});
expect(Array.isArray(data.data)).toBe(true);
// New room has no sessions
expect(data.data.length).toBe(0);
});
});E2E Testing Video Calls with Playwright
For E2E tests, use Playwright with --use-fake-ui-for-media-stream to avoid real camera/microphone permission prompts:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
launchOptions: {
args: [
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
],
},
permissions: ['camera', 'microphone'],
},
});// e2e/video-call.spec.ts
import { test, expect, chromium } from '@playwright/test';
test('two users can join the same video room', async () => {
// Launch two separate browser contexts (simulating two users)
const browser = await chromium.launch({
args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'],
});
const context1 = await browser.newContext({ permissions: ['camera', 'microphone'] });
const context2 = await browser.newContext({ permissions: ['camera', 'microphone'] });
const page1 = await context1.newPage();
const page2 = await context2.newPage();
// User 1 creates and joins
await page1.goto('http://localhost:3000/rooms/test-room');
await page1.fill('[data-testid="name-input"]', 'Alice');
await page1.click('[data-testid="join-btn"]');
await expect(page1.locator('[data-testid="call-container"]')).toBeVisible({ timeout: 15000 });
// User 2 joins the same room
await page2.goto('http://localhost:3000/rooms/test-room');
await page2.fill('[data-testid="name-input"]', 'Bob');
await page2.click('[data-testid="join-btn"]');
await expect(page2.locator('[data-testid="call-container"]')).toBeVisible({ timeout: 15000 });
// Each user sees the other participant
await expect(page1.locator('[data-testid="participant-tile"][data-name="Bob"]')).toBeVisible();
await expect(page2.locator('[data-testid="participant-tile"][data-name="Alice"]')).toBeVisible();
// Verify mute button works
await page1.click('[data-testid="mute-audio-btn"]');
await expect(page1.locator('[data-testid="mute-audio-btn"]')).toHaveAttribute('aria-label', 'Unmute audio');
await browser.close();
});
test('user cannot join with expired token', async ({ page }) => {
await page.goto('http://localhost:3000/rooms/test-room?token=expired-token');
await page.fill('[data-testid="name-input"]', 'Charlie');
await page.click('[data-testid="join-btn"]');
await expect(page.locator('[data-testid="error-message"]')).toContainText(
/token expired|meeting ended|unauthorized/i
);
await expect(page.locator('[data-testid="call-container"]')).not.toBeVisible();
});Monitoring Video Call Availability with HelpMeTest
WebRTC call flows are complex enough that small infrastructure changes (CDN updates, TURN server configuration, token expiry logic) can silently break them. HelpMeTest runs scheduled E2E tests against your video feature:
Go to the video room creation page
Create a new room
Copy the room link
Open the room link
Verify the "Join" button is visible
Click Join
Verify the camera preview appears within 5 seconds
Verify the participant count shows 1
Verify the mute and camera toggle buttons are visibleRunning this every 5 minutes catches broken room creation APIs, expired SDK configurations, and TURN server outages before users report them.
Summary
Testing video SDK integrations requires three layers:
- Unit tests — mock the Daily.co or 100ms SDK to test your React hooks/components in isolation; verify state transitions (joining → joined → left), event handling (participant-joined), and error states (join failure, token expiration)
- Integration tests — call the REST management APIs directly to verify room creation, token generation, and session listing work correctly; these don't require a browser
- E2E tests — use Playwright with
--use-fake-device-for-media-streamto test real WebRTC join flows in a headless browser; test multi-participant scenarios with multiple browser contexts
The most common production failures are token expiration and TURN server issues — both are only caught by real browser E2E tests, not mocked unit tests.