Testing
Testing flows ensures your user journeys work correctly and remain stable as your application evolves. This guide covers testing patterns for flows, steps, navigation, branching, and persistence.
useFlow works with standard React testing tools:
npm install -D vitest @testing-library/react @testing-library/user-event happy-domnpm install -D jest @testing-library/react @testing-library/jest-domBasic flow testing
Section titled “Basic flow testing”Testing flow rendering
Section titled “Testing flow rendering”Test that flows render the correct initial step:
import { render, screen } from "@testing-library/react";import { describe, it, expect } from "vitest";import { Flow } from "@useflow/react";import { onboardingFlow } from "./flow";
describe("OnboardingFlow", () => { it("should render the initial step", () => { render( <Flow flow={onboardingFlow}> {({ renderStep }) => renderStep({ welcome: <div>Welcome Step</div>, profile: <div>Profile Step</div>, complete: <div>Complete Step</div>, }) } </Flow> );
expect(screen.getByText("Welcome Step")).toBeInTheDocument(); expect(screen.queryByText("Profile Step")).not.toBeInTheDocument(); });});Testing navigation
Section titled “Testing navigation”Test forward and backward navigation:
import { render, screen, fireEvent } from "@testing-library/react";import { useFlowState } from "@useflow/react";
function NavigationTest() { const { stepId, next, back } = useFlowState(); return ( <div> <div data-testid="current-step">{stepId}</div> <button onClick={() => next()}>Next</button> <button onClick={back}>Back</button> </div> );}
it("should navigate forward", () => { render( <Flow flow={onboardingFlow}> {({ renderStep }) => ( <> {renderStep({ welcome: <div>Welcome</div>, profile: <div>Profile</div>, })} <NavigationTest /> </> )} </Flow> );
expect(screen.getByTestId("current-step")).toHaveTextContent("welcome");
fireEvent.click(screen.getByText("Next"));
expect(screen.getByTestId("current-step")).toHaveTextContent("profile");});
it("should navigate backward", () => { render( <Flow flow={onboardingFlow}> {({ renderStep }) => ( <> {renderStep({ welcome: <div>Welcome</div>, profile: <div>Profile</div>, })} <NavigationTest /> </> )} </Flow> );
// Navigate forward first fireEvent.click(screen.getByText("Next")); expect(screen.getByTestId("current-step")).toHaveTextContent("profile");
// Then navigate back fireEvent.click(screen.getByText("Back")); expect(screen.getByTestId("current-step")).toHaveTextContent("welcome");});Testing context
Section titled “Testing context”Testing context updates
Section titled “Testing context updates”Test that context updates correctly:
function ContextTest() { const { context, setContext } = useFlowState<{ name: string }>(); return ( <div> <div data-testid="name">{context.name}</div> <button onClick={() => setContext({ name: "John" })}>Set Name</button> </div> );}
it("should update context", () => { render( <Flow flow={myFlow} initialContext={{ name: "" }}> {({ renderStep }) => ( <> {renderStep({ welcome: <div>Welcome</div> })} <ContextTest /> </> )} </Flow> );
expect(screen.getByTestId("name")).toHaveTextContent("");
fireEvent.click(screen.getByText("Set Name"));
expect(screen.getByTestId("name")).toHaveTextContent("John");});Testing context persistence across steps
Section titled “Testing context persistence across steps”Test that context persists when navigating:
it("should persist context across steps", () => { function Step1() { const { setContext, next } = useFlowState(); return ( <button onClick={() => { setContext({ email: "test@example.com" }); next(); }} > Continue </button> ); }
function Step2() { const { context } = useFlowState<{ email: string }>(); return <div data-testid="email">{context.email}</div>; }
render( <Flow flow={myFlow} initialContext={{ email: "" }}> {({ renderStep }) => renderStep({ step1: <Step1 />, step2: <Step2 />, }) } </Flow> );
fireEvent.click(screen.getByText("Continue"));
expect(screen.getByTestId("email")).toHaveTextContent("test@example.com");});Testing branching
Section titled “Testing branching”Testing context-driven navigation
Section titled “Testing context-driven navigation”Test that resolve functions route correctly:
type BranchingContext = { userType: "business" | "personal";};
const branchingFlow = defineFlow({ id: "branching", start: "userType", steps: { userType: { next: ["business", "personal"], }, business: { next: "complete" }, personal: { next: "complete" }, complete: {}, },}).with<BranchingContext>((steps) => ({ resolvers: { userType: (ctx) => ctx.userType === "business" ? steps.business : steps.personal, },}));Testing component-driven navigation
Section titled “Testing component-driven navigation”Test explicit navigation targets:
it("should navigate to explicit target", () => { function SetupStep() { const { next } = useFlowState(); return ( <div> <button onClick={() => next("advanced")}>Advanced</button> <button onClick={() => next("quick")}>Quick</button> </div> ); }
render( <Flow flow={setupFlow}> {({ renderStep }) => renderStep({ setup: <SetupStep />, advanced: <div>Advanced Setup</div>, quick: <div>Quick Setup</div>, }) } </Flow> );
fireEvent.click(screen.getByText("Advanced"));
expect(screen.getByText("Advanced Setup")).toBeInTheDocument();});Testing callbacks
Section titled “Testing callbacks”Testing event callbacks
Section titled “Testing event callbacks”Test that callbacks fire with correct data:
import { vi } from "vitest";
it("should call onNext callback", () => { const onNext = vi.fn();
function TestStep() { const { next } = useFlowState(); return <button onClick={() => next()}>Next</button>; }
render( <Flow flow={myFlow} onNext={onNext}> {({ renderStep }) => renderStep({ first: <TestStep />, second: <div>Second</div>, }) } </Flow> );
fireEvent.click(screen.getByText("Next"));
expect(onNext).toHaveBeenCalledWith( expect.objectContaining({ from: "first", to: "second", }) );});
it("should call onComplete callback", () => { const onComplete = vi.fn();
function FinalStep() { const { next } = useFlowState(); return <button onClick={() => next()}>Finish</button>; }
render( <Flow flow={myFlow} onComplete={onComplete}> {({ renderStep }) => renderStep({ final: <FinalStep />, }) } </Flow> );
fireEvent.click(screen.getByText("Finish"));
expect(onComplete).toHaveBeenCalledWith( expect.objectContaining({ context: expect.any(Object), }) );});Testing persistence
Section titled “Testing persistence”Testing save/restore
Section titled “Testing save/restore”Test that state persists and restores:
import { render, screen, fireEvent, waitFor } from "@testing-library/react";import { createMemoryStore, createPersister } from "@useflow/react";import { vi } from "vitest";
it("should save and restore state", async () => { const store = createMemoryStore(); const setSpy = vi.spyOn(store, 'set'); const persister = createPersister({ store });
// First render: Navigate and save const { unmount } = render( <Flow flow={myFlow} persister={persister}> {({ renderStep }) => ( <> {renderStep({ step1: <div>Step 1</div>, step2: <div>Step 2</div>, })} <NavigationTest /> </> )} </Flow> );
fireEvent.click(screen.getByText("Next")); expect(screen.getByTestId("current-step")).toHaveTextContent("step2");
// Wait for save to complete await waitFor(() => { expect(setSpy).toHaveBeenCalled(); });
unmount();
// Second render: Should restore to step2 render( <Flow flow={myFlow} persister={persister}> {({ renderStep }) => ( <> {renderStep({ step1: <div>Step 1</div>, step2: <div>Step 2</div>, })} <NavigationTest /> </> )} </Flow> );
await waitFor(() => { expect(screen.getByTestId("current-step")).toHaveTextContent("step2"); });});Testing custom stores
Section titled “Testing custom stores”Test custom store implementations:
it("should use custom store", async () => { const mockStore = { get: vi.fn().mockResolvedValue(null), set: vi.fn().mockResolvedValue(undefined), remove: vi.fn().mockResolvedValue(undefined), };
const persister = createPersister({ store: mockStore });
render( <Flow flow={myFlow} persister={persister}> {({ renderStep }) => renderStep({ step1: <div>Step 1</div>, }) } </Flow> );
await waitFor(() => { expect(mockStore.get).toHaveBeenCalledWith(myFlow.id, expect.any(Object)); });});Testing with user events
Section titled “Testing with user events”Use @testing-library/user-event for realistic interactions:
import userEvent from "@testing-library/user-event";
it("should handle form input and navigation", async () => { const user = userEvent.setup();
function ProfileStep() { const { context, setContext, next } = useFlowState(); return ( <div> <input placeholder="Name" value={context.name || ""} onChange={(e) => setContext({ name: e.target.value })} /> <button onClick={() => next()}>Continue</button> </div> ); }
function CompleteStep() { const { context } = useFlowState<{ name: string }>(); return <div>Welcome, {context.name}!</div>; }
render( <Flow flow={myFlow} initialContext={{ name: "" }}> {({ renderStep }) => renderStep({ profile: <ProfileStep />, complete: <CompleteStep />, }) } </Flow> );
const input = screen.getByPlaceholderText("Name"); await user.type(input, "Alice");
expect(input).toHaveValue("Alice");
await user.click(screen.getByText("Continue"));
expect(screen.getByText("Welcome, Alice!")).toBeInTheDocument();});Integration testing
Section titled “Integration testing”Testing complete flows
Section titled “Testing complete flows”Test entire user journeys:
it("should complete full onboarding flow", async () => { const user = userEvent.setup(); const onComplete = vi.fn();
render( <Flow flow={onboardingFlow} onComplete={onComplete}> {({ renderStep }) => renderStep({ welcome: <WelcomeStep />, profile: <ProfileStep />, preferences: <PreferencesStep />, complete: <CompleteStep />, }) } </Flow> );
// Welcome step expect(screen.getByText(/welcome/i)).toBeInTheDocument(); await user.click(screen.getByText(/get started/i));
// Profile step await user.type(screen.getByPlaceholderText("Name"), "Alice"); await user.type(screen.getByPlaceholderText("Email"), "alice@example.com"); await user.click(screen.getByText(/continue/i));
// Preferences step await user.click(screen.getByLabelText(/email notifications/i)); await user.click(screen.getByText(/finish/i));
// Complete expect(onComplete).toHaveBeenCalledWith( expect.objectContaining({ context: expect.objectContaining({ name: "Alice", email: "alice@example.com", emailNotifications: true, }), }) );});Testing utilities
Section titled “Testing utilities”Custom render helper
Section titled “Custom render helper”Create a helper for common test setup:
import { render } from "@testing-library/react";import { createMemoryStore, createPersister } from "@useflow/react";import type { FlowPersister } from "@useflow/react";import { Flow } from "@useflow/react";
export function renderFlow( flow: any, steps: Record<string, JSX.Element>, options: { initialContext?: any; persister?: FlowPersister; callbacks?: { onNext?: (...args: any[]) => void; onComplete?: (...args: any[]) => void; }; } = {}) { const { initialContext, persister, callbacks } = options;
return render( <Flow flow={flow} initialContext={initialContext} persister={persister} {...callbacks} > {({ renderStep }) => renderStep(steps)} </Flow> );}
// UsagerenderFlow( myFlow, { step1: <Step1 />, step2: <Step2 />, }, { initialContext: { name: "" }, callbacks: { onComplete: vi.fn(), }, });Mock persister helper
Section titled “Mock persister helper”Create mock persisters for testing:
import { vi } from "vitest";import { createMemoryStore } from "@useflow/react";import type { FlowPersister } from "@useflow/react";
export function createMockPersister(overrides = {}): FlowPersister { return { save: vi.fn().mockResolvedValue(null), restore: vi.fn().mockResolvedValue(null), remove: vi.fn().mockResolvedValue(undefined), store: createMemoryStore(), ...overrides, };}
// Usageconst persister = createMockPersister({ restore: vi.fn().mockResolvedValue({ stepId: "profile", context: { name: "Alice" }, }),});Best practices
Section titled “Best practices”-
Test User Journeys, Not Implementation
Focus on user behavior rather than internal functions:
it("should allow user to skip profile step", async () => {const user = userEvent.setup();render(<OnboardingFlow />);await user.click(screen.getByText(/skip/i));expect(screen.getByText(/preferences/i)).toBeInTheDocument();});it("should call skip() function", () => {const { skip } = renderHook(() => useFlowState());skip();expect(skip).toHaveBeenCalled();}); -
Use Realistic Test Data
Use data that reflects production scenarios:
initialContext={{email: "user@example.com",phone: "+1234567890",preferences: {notifications: true,newsletter: false}}}initialContext={{email: "test",phone: "123"}} -
Test Error States
Always test validation and error handling:
it("should show validation error for invalid email", async () => {const user = userEvent.setup();render(<ProfileStep />);await user.type(screen.getByPlaceholderText("Email"), "invalid");await user.click(screen.getByText(/continue/i));expect(screen.getByText(/invalid email/i)).toBeInTheDocument();}); -
Clean Up After Tests
Prevent state leakage between tests:
import { cleanup } from "@testing-library/react";import { afterEach } from "vitest";afterEach(() => {cleanup();localStorage.clear();sessionStorage.clear();}); -
Use Descriptive Test Names
Make tests self-documenting:
it("should route business users to company details step");it("should navigate correctly");
Snapshot testing
Section titled “Snapshot testing”Use snapshots for stable UI:
it("should match welcome step snapshot", () => { const { container } = render( <Flow flow={myFlow}> {({ renderStep }) => renderStep({ welcome: <WelcomeStep />, }) } </Flow> );
expect(container).toMatchSnapshot();});Next steps
Section titled “Next steps”- TypeScript - Type-safe testing
- Callbacks - Test flow callbacks
- Persistence - Test persistence
- Branching - Test branching logic