Testing Liveblocks: Real-Time Collaboration, Presence, and Webhooks
Liveblocks powers real-time collaboration features in products like Figma-style editors, multiplayer forms, live cursors, and shared document systems. Its combination of presence (who's online, where are their cursors), storage (conflict-free shared state), and comments makes it a compelling choice—but also a challenging one to test. Collaborative features involve multiple simultaneous users, real-time state propagation, and conflict resolution logic that can be difficult to reproduce deterministically.
This guide walks through a complete testing strategy for Liveblocks applications, covering React hook mocking, room state testing, webhook verification, and end-to-end collaboration validation.
What Needs Testing in a Liveblocks App
Liveblocks applications have four distinct layers:
- Room connection and lifecycle — does the room connect, does it handle errors gracefully, does it clean up on unmount?
- Presence — are cursor positions and user states broadcast and received correctly?
- Storage — do LiveObject, LiveList, and LiveMap mutations produce the correct merged state?
- Webhooks — do your server-side event handlers receive and process Liveblocks events correctly?
Each layer has different testing requirements. Storage and presence tests benefit from mocking the Liveblocks client entirely, while webhook tests need a real HTTP server. End-to-end collaboration tests require two simultaneous browser sessions.
Setting Up the Test Environment
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom
npm install --save-dev @liveblocks/client @liveblocks/reactLiveblocks provides @liveblocks/react/test-utils (introduced in v1.x) for creating test clients. For older versions or more control, manual mocking is straightforward.
Mocking Liveblocks React Hooks
The most practical approach for component testing is to mock the @liveblocks/react module. This lets you control what the hooks return without connecting to the Liveblocks API:
// src/test/mockLiveblocks.ts
import { vi } from "vitest";
export function mockLiveblocksHooks(overrides: {
others?: any[];
self?: any;
storage?: any;
isLoading?: boolean;
} = {}) {
const defaults = {
others: [],
self: {
connectionId: 1,
presence: { cursor: null, name: "Test User" },
},
storage: null,
isLoading: false,
};
const config = { ...defaults, ...overrides };
vi.mock("@liveblocks/react", async (importActual) => {
const actual = await importActual<typeof import("@liveblocks/react")>();
return {
...actual,
useOthers: vi.fn().mockReturnValue(config.others),
useSelf: vi.fn().mockReturnValue(config.self),
useMyPresence: vi.fn().mockReturnValue([
config.self?.presence ?? {},
vi.fn(),
]),
useStorage: vi.fn().mockReturnValue(config.storage),
useMutation: vi.fn().mockImplementation((fn: any) => vi.fn()),
useStatus: vi.fn().mockReturnValue("connected"),
RoomProvider: ({ children }: { children: React.ReactNode }) => children,
};
});
}Testing Presence Features
Presence is ephemeral state that propagates to all room members in real time. Test that your components correctly render presence from other users:
// src/components/LiveCursors.tsx
import { useOthers } from "@liveblocks/react";
interface CursorPresence {
cursor: { x: number; y: number } | null;
name: string;
color: string;
}
export function LiveCursors() {
const others = useOthers();
return (
<div data-testid="live-cursors">
{others.map((user) => {
const cursor = user.presence.cursor;
if (!cursor) return null;
return (
<div
key={user.connectionId}
data-testid={`cursor-${user.connectionId}`}
style={{
position: "absolute",
left: cursor.x,
top: cursor.y,
pointerEvents: "none",
}}
>
<svg width="16" height="16" viewBox="0 0 16 16">
<path d="M0 0l4 16 3-5 5-3L0 0z" fill={user.presence.color} />
</svg>
<span>{user.presence.name}</span>
</div>
);
})}
</div>
);
}// src/components/LiveCursors.test.tsx
import { render, screen } from "@testing-library/react";
import { vi, expect, test, describe, beforeEach } from "vitest";
vi.mock("@liveblocks/react", () => ({
useOthers: vi.fn(),
}));
import { useOthers } from "@liveblocks/react";
import { LiveCursors } from "./LiveCursors";
describe("LiveCursors", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("renders nothing when no other users are present", () => {
vi.mocked(useOthers).mockReturnValue([]);
render(<LiveCursors />);
const container = screen.getByTestId("live-cursors");
expect(container).toBeEmptyDOMElement();
});
test("renders a cursor for each user with a cursor position", () => {
vi.mocked(useOthers).mockReturnValue([
{
connectionId: 1,
presence: { cursor: { x: 100, y: 200 }, name: "Alice", color: "#ff0000" },
},
{
connectionId: 2,
presence: { cursor: { x: 300, y: 400 }, name: "Bob", color: "#00ff00" },
},
] as any);
render(<LiveCursors />);
expect(screen.getByTestId("cursor-1")).toBeInTheDocument();
expect(screen.getByTestId("cursor-2")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
});
test("does not render cursor for users without a cursor position", () => {
vi.mocked(useOthers).mockReturnValue([
{
connectionId: 3,
presence: { cursor: null, name: "Charlie", color: "#0000ff" },
},
] as any);
render(<LiveCursors />);
expect(screen.queryByTestId("cursor-3")).not.toBeInTheDocument();
});
test("cursor is positioned at the correct coordinates", () => {
vi.mocked(useOthers).mockReturnValue([
{
connectionId: 4,
presence: { cursor: { x: 150, y: 250 }, name: "Dana", color: "#purple" },
},
] as any);
render(<LiveCursors />);
const cursor = screen.getByTestId("cursor-4");
expect(cursor).toHaveStyle({ left: "150px", top: "250px" });
});
});Testing Liveblocks Storage and CRDT Mutations
Liveblocks Storage uses CRDTs—LiveObject, LiveList, and LiveMap—to handle concurrent mutations without conflicts. Test your mutation functions by mocking useMutation:
// src/hooks/useDocument.ts
import { useMutation, useStorage } from "@liveblocks/react";
export interface DocumentNode {
id: string;
type: "paragraph" | "heading" | "image";
content: string;
attrs: Record<string, string>;
}
export function useDocument() {
const nodes = useStorage((root) => root.nodes);
const insertNode = useMutation(({ storage }, node: DocumentNode) => {
const nodes = storage.get("nodes");
nodes.push({ ...node });
}, []);
const updateNodeContent = useMutation(
({ storage }, nodeId: string, content: string) => {
const nodes = storage.get("nodes");
const index = nodes.toArray().findIndex((n: DocumentNode) => n.id === nodeId);
if (index !== -1) {
nodes.get(index).set("content", content);
}
},
[]
);
const deleteNode = useMutation(({ storage }, nodeId: string) => {
const nodes = storage.get("nodes");
const index = nodes.toArray().findIndex((n: DocumentNode) => n.id === nodeId);
if (index !== -1) {
nodes.delete(index);
}
}, []);
return { nodes, insertNode, updateNodeContent, deleteNode };
}// src/hooks/useDocument.test.ts
import { renderHook, act } from "@testing-library/react";
import { vi, expect, test, describe } from "vitest";
vi.mock("@liveblocks/react", () => ({
useStorage: vi.fn(),
useMutation: vi.fn(),
}));
import { useStorage, useMutation } from "@liveblocks/react";
import { useDocument } from "./useDocument";
describe("useDocument", () => {
test("insertNode calls mutation with correct arguments", () => {
const mockInsert = vi.fn();
const mockUpdate = vi.fn();
const mockDelete = vi.fn();
vi.mocked(useStorage).mockReturnValue([]);
vi.mocked(useMutation)
.mockReturnValueOnce(mockInsert)
.mockReturnValueOnce(mockUpdate)
.mockReturnValueOnce(mockDelete);
const { result } = renderHook(() => useDocument());
const newNode = {
id: "node-1",
type: "paragraph" as const,
content: "Hello world",
attrs: {},
};
act(() => {
result.current.insertNode(newNode);
});
expect(mockInsert).toHaveBeenCalledWith(newNode);
});
test("nodes returns current storage value", () => {
const mockNodes = [
{ id: "n1", type: "heading", content: "Title", attrs: {} },
{ id: "n2", type: "paragraph", content: "Body", attrs: {} },
];
vi.mocked(useStorage).mockReturnValue(mockNodes);
vi.mocked(useMutation).mockReturnValue(vi.fn());
const { result } = renderHook(() => useDocument());
expect(result.current.nodes).toEqual(mockNodes);
});
});Testing Liveblocks Webhooks
Webhooks are where Liveblocks notifies your backend about room events—users joining and leaving, storage changes, comments, and more. Test your webhook handlers with a real HTTP server:
// src/server/webhooks.ts
import type { Request, Response } from "express";
import { WebhookHandler } from "@liveblocks/node";
const webhookHandler = new WebhookHandler(process.env.LIVEBLOCKS_WEBHOOK_SECRET!);
export async function handleLiveblocksWebhook(req: Request, res: Response) {
let event;
try {
event = webhookHandler.verifyRequest({
headers: req.headers as Record<string, string>,
rawBody: req.rawBody,
});
} catch (err) {
console.error("Webhook signature verification failed:", err);
return res.status(400).json({ error: "Invalid signature" });
}
switch (event.type) {
case "storageUpdated": {
await handleStorageUpdated(event.data.roomId, event.data);
break;
}
case "userEntered": {
await handleUserEntered(event.data.roomId, event.data.userId);
break;
}
case "userLeft": {
await handleUserLeft(event.data.roomId, event.data.userId);
break;
}
case "commentCreated": {
await handleCommentCreated(event.data);
break;
}
}
return res.status(200).json({ processed: true });
}
async function handleStorageUpdated(roomId: string, data: any) {
// Persist snapshot, notify analytics, etc.
}
async function handleUserEntered(roomId: string, userId: string) {
// Track active rooms, send welcome notifications, etc.
}
async function handleUserLeft(roomId: string, userId: string) {
// Clean up ephemeral state, update last-seen timestamps, etc.
}
async function handleCommentCreated(data: any) {
// Send email notifications to @mentioned users
}// src/server/webhooks.test.ts
import { describe, test, expect, vi, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import { WebhookHandler } from "@liveblocks/node";
vi.mock("@liveblocks/node", () => ({
WebhookHandler: vi.fn().mockImplementation(() => ({
verifyRequest: vi.fn(),
})),
}));
import { handleLiveblocksWebhook } from "./webhooks";
function buildApp() {
const app = express();
app.use(
express.raw({ type: "application/json" }),
(req: any, _res, next) => {
req.rawBody = req.body;
req.body = JSON.parse(req.body.toString());
next();
}
);
app.post("/webhook/liveblocks", handleLiveblocksWebhook);
return app;
}
describe("Liveblocks webhook handler", () => {
let mockVerifyRequest: ReturnType<typeof vi.fn>;
beforeEach(() => {
const handler = new WebhookHandler("secret");
mockVerifyRequest = vi.mocked(handler.verifyRequest);
vi.mocked(WebhookHandler).mockReturnValue({ verifyRequest: mockVerifyRequest } as any);
});
test("returns 400 when signature verification fails", async () => {
mockVerifyRequest.mockImplementation(() => {
throw new Error("Invalid signature");
});
const app = buildApp();
const res = await request(app)
.post("/webhook/liveblocks")
.set("Content-Type", "application/json")
.send(JSON.stringify({ type: "storageUpdated" }));
expect(res.status).toBe(400);
expect(res.body.error).toBe("Invalid signature");
});
test("returns 200 and processes storageUpdated event", async () => {
mockVerifyRequest.mockReturnValue({
type: "storageUpdated",
data: { roomId: "room-123", version: 42 },
});
const app = buildApp();
const res = await request(app)
.post("/webhook/liveblocks")
.set("Content-Type", "application/json")
.send(JSON.stringify({ type: "storageUpdated" }));
expect(res.status).toBe(200);
expect(res.body.processed).toBe(true);
});
test("processes userEntered event without error", async () => {
mockVerifyRequest.mockReturnValue({
type: "userEntered",
data: { roomId: "room-456", userId: "user-789" },
});
const app = buildApp();
const res = await request(app)
.post("/webhook/liveblocks")
.set("Content-Type", "application/json")
.send(JSON.stringify({ type: "userEntered" }));
expect(res.status).toBe(200);
});
test("processes commentCreated event without error", async () => {
mockVerifyRequest.mockReturnValue({
type: "commentCreated",
data: {
roomId: "room-789",
threadId: "thread-1",
commentId: "comment-1",
userId: "user-100",
},
});
const app = buildApp();
const res = await request(app)
.post("/webhook/liveblocks")
.set("Content-Type", "application/json")
.send(JSON.stringify({ type: "commentCreated" }));
expect(res.status).toBe(200);
});
});Testing Room Connection State
React components should gracefully handle all connection states:
// src/components/CollaborativeEditor.test.tsx
import { render, screen } from "@testing-library/react";
import { vi, test, describe } from "vitest";
vi.mock("@liveblocks/react", () => ({
useStatus: vi.fn(),
useStorage: vi.fn(),
useOthers: vi.fn(),
useSelf: vi.fn(),
useMutation: vi.fn(() => vi.fn()),
}));
import { useStatus, useStorage, useOthers, useSelf } from "@liveblocks/react";
import { CollaborativeEditor } from "./CollaborativeEditor";
describe("CollaborativeEditor connection states", () => {
beforeEach(() => {
vi.mocked(useOthers).mockReturnValue([]);
vi.mocked(useSelf).mockReturnValue({
connectionId: 1,
presence: { cursor: null, name: "Me" },
} as any);
vi.mocked(useStorage).mockReturnValue(null);
});
test("shows loading skeleton while connecting", () => {
vi.mocked(useStatus).mockReturnValue("connecting");
render(<CollaborativeEditor roomId="room-1" />);
expect(screen.getByTestId("editor-skeleton")).toBeInTheDocument();
});
test("shows editor content when connected", () => {
vi.mocked(useStatus).mockReturnValue("connected");
vi.mocked(useStorage).mockReturnValue({
nodes: [{ id: "n1", type: "paragraph", content: "Hello", attrs: {} }],
});
render(<CollaborativeEditor roomId="room-1" />);
expect(screen.getByText("Hello")).toBeInTheDocument();
});
test("shows reconnecting banner when connection is lost", () => {
vi.mocked(useStatus).mockReturnValue("reconnecting");
render(<CollaborativeEditor roomId="room-1" />);
expect(screen.getByText(/reconnecting/i)).toBeInTheDocument();
});
test("shows error message when connection fails", () => {
vi.mocked(useStatus).mockReturnValue("disconnected");
render(<CollaborativeEditor roomId="room-1" />);
expect(screen.getByText(/unable to connect/i)).toBeInTheDocument();
});
});End-to-End Collaboration Testing
The ultimate test for a collaborative application is running two simultaneous browser sessions and verifying that changes made by one user appear in real time for the other. This is where single-browser testing frameworks fall short—you need genuine parallel execution.
HelpMeTest runs tests with parallel execution built in, making it straightforward to write scenarios like:
- User A opens a shared document (authenticated as Alice)
- User B opens the same document in a separate browser session (authenticated as Bob)
- User A types "Hello from Alice" into the document
- Assert that Bob's session shows the new text within 2 seconds
- User B moves their cursor to position (300, 150)
- Assert that Alice's session shows Bob's cursor at the correct position
- User A adds a comment on a paragraph
- Assert that Bob receives a notification and can see the comment
HelpMeTest's AI-powered test generation can scaffold these multi-user scenarios from a plain English description. Its self-healing tests automatically adapt when component selectors change during UI development, so your collaboration tests don't break every time someone refactors the editor toolbar.
For teams running collaborative applications in production, HelpMeTest's continuous monitoring can run this two-user scenario every few minutes against your staging environment, catching collaboration regressions before users encounter them.
Testing the Liveblocks REST API
Liveblocks exposes a REST API for server-side room management. Test your API integration layer:
// src/server/rooms.ts
const LIVEBLOCKS_SECRET = process.env.LIVEBLOCKS_SECRET_KEY!;
const LIVEBLOCKS_API = "https://api.liveblocks.io/v2";
export async function getRoomStorage(roomId: string): Promise<any> {
const response = await fetch(`${LIVEBLOCKS_API}/rooms/${roomId}/storage`, {
headers: { Authorization: `Bearer ${LIVEBLOCKS_SECRET}` },
});
if (!response.ok) {
throw new Error(`Failed to fetch storage: ${response.status}`);
}
return response.json();
}
export async function deleteRoom(roomId: string): Promise<void> {
const response = await fetch(`${LIVEBLOCKS_API}/rooms/${roomId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${LIVEBLOCKS_SECRET}` },
});
if (response.status === 404) return; // already deleted
if (!response.ok) throw new Error(`Failed to delete room: ${response.status}`);
}
export async function createRoom(
roomId: string,
defaultAccesses: string[] = []
): Promise<{ id: string }> {
const response = await fetch(`${LIVEBLOCKS_API}/rooms`, {
method: "POST",
headers: {
Authorization: `Bearer ${LIVEBLOCKS_SECRET}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ id: roomId, defaultAccesses }),
});
if (!response.ok) {
throw new Error(`Failed to create room: ${response.status}`);
}
return response.json();
}// src/server/rooms.test.ts
import { describe, test, expect, vi, beforeEach } from "vitest";
import { getRoomStorage, deleteRoom, createRoom } from "./rooms";
describe("Liveblocks REST API client", () => {
beforeEach(() => {
global.fetch = vi.fn();
});
test("getRoomStorage returns parsed storage data", async () => {
const mockStorage = { data: { nodes: [] } };
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify(mockStorage), { status: 200 })
);
const storage = await getRoomStorage("room-abc");
expect(storage).toEqual(mockStorage);
});
test("getRoomStorage throws on non-OK response", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response("Not found", { status: 404 })
);
await expect(getRoomStorage("nonexistent")).rejects.toThrow(
"Failed to fetch storage: 404"
);
});
test("deleteRoom succeeds silently when room does not exist", async () => {
vi.mocked(fetch).mockResolvedValue(new Response("", { status: 404 }));
await expect(deleteRoom("ghost-room")).resolves.toBeUndefined();
});
test("createRoom sends correct payload and returns room id", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ id: "new-room" }), { status: 200 })
);
const result = await createRoom("new-room", ["room:write"]);
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("/rooms"),
expect.objectContaining({
method: "POST",
body: JSON.stringify({ id: "new-room", defaultAccesses: ["room:write"] }),
})
);
expect(result.id).toBe("new-room");
});
});CI Configuration
# .github/workflows/liveblocks-tests.yml
name: Liveblocks Tests
on:
push:
branches: [main, develop]
pull_request:
env:
LIVEBLOCKS_WEBHOOK_SECRET: test-secret-for-ci
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- name: Run all tests
run: npx vitest run --reporter=verbose --coverage
- uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.infoTesting Strategy Summary
Liveblocks testing works best when you think in layers. Mock the hooks entirely for component tests—you want to test rendering logic, not the Liveblocks SDK. Use explicit unit tests for webhook handlers with signature verification mocked. Test your mutation logic by examining what mutations receive as arguments rather than trying to simulate CRDT operations.
Reserve true end-to-end tests for collaboration scenarios that fundamentally require multiple simultaneous users. These tests are expensive to write and maintain when done manually, but tools like HelpMeTest make them tractable by handling parallel session management, providing AI-generated test scaffolding for multi-user flows, and running them continuously against your staging environment so regressions surface within minutes rather than days.
The goal is a test suite that gives you confidence to ship collaboration features quickly—not one that slows you down with brittle selectors and manual session management.