Testing Daily.co and 100ms Video SDKs: A Developer's Guide

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:

  1. Unit tests — mock the SDK and test your application logic (join handlers, UI state, error handling)
  2. Integration tests — call the REST APIs for room creation and token generation; these work in any environment
  3. 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 visible

Running 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:

  1. 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)
  2. 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
  3. E2E tests — use Playwright with --use-fake-device-for-media-stream to 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.

Read more

Start now free