Savvi Studio

Testing Patterns Reference

Last Updated: November 23, 2025

This document covers Testing Library best practices, query selection, and interaction testing patterns for Storybook stories.


Table of Contents

  1. Testing Library Query Priority
  2. Query Types
  3. Play Functions
  4. Step Function
  5. Function Spying
  6. Mount Function
  7. Best Practices

Testing Library Query Priority

Use queries in this order of preference to ensure accessibility and maintainability:

1. getByRole (Best)

Tests accessibility and semantic meaning:

// ✅ Best - Tests accessibility
await canvas.getByRole('button', { name: /submit/i });
await canvas.getByRole('textbox', { name: /email/i });
await canvas.getByRole('heading', { name: /welcome/i });
await canvas.getByRole('link', { name: /sign up/i });

Common roles:

  • button - Buttons
  • textbox - Input fields
  • heading - Headings (h1-h6)
  • link - Links
  • checkbox - Checkboxes
  • radio - Radio buttons
  • combobox - Select dropdowns
  • dialog - Modal dialogs
  • alert - Alert messages

2. getByLabelText (Good)

Tests form accessibility:

// ✅ Good - Tests form labels
await canvas.getByLabelText('Email Address');
await canvas.getByLabelText(/password/i);

Best for:

  • Form inputs with associated labels
  • Ensuring proper label-input relationships

3. getByPlaceholderText (OK)

Tests visual content but less accessible:

// ⚠️ OK - Less accessible than label
await canvas.getByPlaceholderText('Enter your email');

Use when:

  • No label exists (though adding one is better)
  • Placeholder is the primary identifier

4. getByText (OK)

Tests visible text content:

// ⚠️ OK - Tests visible content
await canvas.getByText(/loading/i);
await canvas.getByText('Welcome back!');

Best for:

  • Non-interactive text (paragraphs, spans)
  • Error messages
  • Status indicators

5. getByTestId (Last Resort)

Tests implementation details:

// ❌ Last resort - Implementation detail
await canvas.getByTestId('user-menu');

Only use when:

  • No semantic or accessible query works
  • Third-party components without proper roles
  • Complex scenarios where other queries fail

Query Types

getBy* - Element Must Exist

Throws error if element not found:

// Element must exist immediately
const button = canvas.getByRole('button', { name: /submit/i });
await userEvent.click(button);

Use for:

  • Elements that should always be present
  • Synchronous operations

queryBy* - Element May Not Exist

Returns null if not found:

// Check if element exists
const errorMsg = canvas.queryByText(/error/i);
if (errorMsg) {
  // Handle error state
}

// Assert element doesn't exist
expect(canvas.queryByRole('alert')).not.toBeInTheDocument();

Use for:

  • Conditional rendering
  • Asserting element absence

findBy* - Wait for Async Element

Waits for element to appear, then throws if not found:

// Wait for async element (up to 1000ms by default)
const message = await canvas.findByText(/success/i);
expect(message).toBeInTheDocument();

Use for:

  • Elements that appear after async operations
  • Loading states
  • API responses

Configure timeout:

await canvas.findByText(/success/i, {}, { timeout: 3000 });

Play Functions

Play functions enable automated interaction testing.

Basic Structure

import { userEvent, within, expect } from '@storybook/test';

export const Interactive: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // Interactions
    await userEvent.type(canvas.getByLabelText(/email/i), 'user@example.com');
    await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
    
    // Assertions
    await expect(canvas.findByText(/success/i)).resolves.toBeInTheDocument();
  },
};

User Interactions

// Type text
await userEvent.type(input, 'Hello world');
await userEvent.type(input, 'Hello{Enter}'); // Special keys

// Click
await userEvent.click(button);
await userEvent.dblClick(button);

// Select
await userEvent.selectOptions(select, 'option1');
await userEvent.selectOptions(select, ['option1', 'option2']); // Multiple

// Upload file
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
await userEvent.upload(input, file);

// Keyboard
await userEvent.keyboard('{Escape}');
await userEvent.keyboard('{Control>}A{/Control}'); // Ctrl+A

// Hover
await userEvent.hover(element);
await userEvent.unhover(element);

// Tab
await userEvent.tab(); // Move focus forward
await userEvent.tab({ shift: true }); // Move focus backward

Assertions

// Presence
expect(element).toBeInTheDocument();
expect(element).not.toBeInTheDocument();

// Visibility
expect(element).toBeVisible();
expect(element).not.toBeVisible();

// Text content
expect(element).toHaveTextContent('Hello');
expect(element).toHaveTextContent(/hello/i);

// Values
expect(input).toHaveValue('text');
expect(checkbox).toBeChecked();
expect(radio).not.toBeChecked();

// Attributes
expect(button).toBeDisabled();
expect(button).toBeEnabled();
expect(link).toHaveAttribute('href', '/page');

// Classes
expect(element).toHaveClass('active');
expect(element).not.toHaveClass('disabled');

// Accessibility
expect(button).toHaveAccessibleName('Submit');
expect(input).toHaveAccessibleDescription('Enter your email');

Step Function

Use steps to organize complex interactions and improve readability:

export const ComplexFlow: Story = {
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement);
    
    await step('Fill out form', async () => {
      await userEvent.type(
        canvas.getByLabelText(/email/i),
        'user@example.com'
      );
      await userEvent.type(
        canvas.getByLabelText(/password/i),
        'secure123'
      );
    });
    
    await step('Submit form', async () => {
      await userEvent.click(
        canvas.getByRole('button', { name: /submit/i })
      );
    });
    
    await step('Verify success', async () => {
      await expect(
        canvas.findByText(/welcome/i)
      ).resolves.toBeInTheDocument();
    });
  },
};

Benefits:

  • Better test organization
  • Clear step-by-step flow
  • Easier debugging (shows which step failed)
  • Improved readability in Storybook UI

Function Spying

Use fn() to spy on function calls and arguments:

import { fn } from '@storybook/test';

const meta = {
  component: Form,
  args: {
    onSubmit: fn(), // Creates a spy function
  },
} satisfies Meta<typeof Form>;

export const SubmittedForm: Story = {
  play: async ({ args, canvasElement }) => {
    const canvas = within(canvasElement);
    
    // Fill and submit form
    await userEvent.type(canvas.getByLabelText(/email/i), 'test@example.com');
    await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
    
    // Verify function was called
    await expect(args.onSubmit).toHaveBeenCalled();
    await expect(args.onSubmit).toHaveBeenCalledTimes(1);
    
    // Verify arguments
    await expect(args.onSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
    });
    
    // Verify partial match
    await expect(args.onSubmit).toHaveBeenCalledWith(
      expect.objectContaining({
        email: expect.any(String),
      })
    );
  },
};

Common matchers:

expect(fn).toHaveBeenCalled()
expect(fn).toHaveBeenCalledTimes(n)
expect(fn).toHaveBeenCalledWith(args)
expect(fn).toHaveBeenLastCalledWith(args)
expect(fn).toHaveBeenNthCalledWith(n, args)

Mount Function

Execute code before component renders:

export const WithPreparation: Story = {
  async play({ mount, args }) {
    // Prepare data before mount
    const mockData = await fetchMockData();
    
    // Mount with prepared data
    const canvas = await mount(
      <MyComponent {...args} data={mockData} />
    );
    
    // Interact after mount
    await userEvent.click(
      await canvas.findByRole('button')
    );
  },
};

Use cases:

  • Async data preparation
  • Dynamic component configuration
  • Conditional rendering based on async state

Best Practices

1. Prefer Semantic Queries

// ✅ Good - Semantic, accessible
await canvas.getByRole('button', { name: /submit/i });

// ❌ Avoid - Test ID when not necessary
await canvas.getByTestId('submit-button');

2. Use Case-Insensitive Matching

// ✅ Good - Case-insensitive regex
await canvas.getByText(/submit/i);
await canvas.getByRole('button', { name: /submit/i });

// ❌ Avoid - Case-sensitive (brittle)
await canvas.getByText('Submit');

3. Wait for Async Changes

// ✅ Good - Wait for async state
await userEvent.click(button);
await expect(canvas.findByText(/success/i)).resolves.toBeInTheDocument();

// ❌ Avoid - Immediate check after async action
await userEvent.click(button);
expect(canvas.getByText(/success/i)).toBeInTheDocument(); // May fail

4. Test User-Visible Behavior

// ✅ Good - Tests user-visible outcome
await userEvent.click(button);
await expect(canvas.findByText(/saved/i)).resolves.toBeInTheDocument();

// ❌ Avoid - Tests implementation details
await userEvent.click(button);
expect(mockSetState).toHaveBeenCalledWith({ saved: true });

5. Use Clear Selectors

// ✅ Good - Specific, clear selector
await canvas.getByRole('button', { name: /submit form/i });

// ❌ Avoid - Vague selector
await canvas.getByRole('button'); // Which button?

6. Organize Complex Tests with Steps

// ✅ Good - Organized with steps
await step('User login', async () => { /* ... */ });
await step('Navigate to settings', async () => { /* ... */ });
await step('Update preferences', async () => { /* ... */ });

// ❌ Avoid - Long unorganized sequence
await userEvent.type(/* ... */);
await userEvent.click(/* ... */);
// ... 20 more lines

7. Handle Loading States

// ✅ Good - Wait for loading to complete
await userEvent.click(loadButton);
await canvas.findByText(/loading/i); // Wait for loading to appear
await expect(
  canvas.findByText(/data loaded/i)
).resolves.toBeInTheDocument(); // Wait for completion

// ❌ Avoid - Don't wait for loading states
await userEvent.click(loadButton);
expect(canvas.getByText(/data loaded/i)).toBeInTheDocument(); // May fail

Common Patterns

Form Submission

export const FormSubmit: Story = {
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement);
    
    await step('Fill form', async () => {
      await userEvent.type(canvas.getByLabelText(/email/i), 'user@test.com');
      await userEvent.type(canvas.getByLabelText(/password/i), 'pass123');
    });
    
    await step('Submit', async () => {
      await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
    });
    
    await step('Verify success', async () => {
      await expect(canvas.findByText(/success/i)).resolves.toBeInTheDocument();
    });
  },
};

Error Handling

export const WithError: Story = {
  parameters: {
    msw: {
      handlers: [
        trpcMsw.endpoint.mutation(() => {
          throw new Error('Server error');
        }),
      ],
    },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
    
    await expect(
      canvas.findByRole('alert')
    ).resolves.toHaveTextContent(/error/i);
  },
};
export const Navigation: Story = {
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement);
    
    await step('Open menu', async () => {
      await userEvent.click(canvas.getByRole('button', { name: /menu/i }));
    });
    
    await step('Click settings link', async () => {
      await userEvent.click(await canvas.findByRole('link', { name: /settings/i }));
    });
  },
};


For task-specific implementation details, see the Storybook integration task files in docs/storybook-integration/.