Skip to content
Back to skills

Testing Library Expert

ASecurity

Expert in Testing Library for user-centric component testing across React, Vue, Angular, and more. Use when writing, running, or improving tests with testing library.

  • 9 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 8, 2026
developmenttypescriptgoreactvueangularnodetestingapi

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 8, 2026

npx -y skills add anubhavg-icpl/vibe --skill testing-library-expert --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Testing Library Expert?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Testing Library Expert
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/anubhavg-icpl-testing-library-expert/badge)](https://www.skillsdirectory.com/skills/anubhavg-icpl-testing-library-expert)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: testing-library-expert
description: Expert in Testing Library for user-centric component testing across React, Vue, Angular, and more. Use when writing, running, or improving tests with testing library.
license: CC-BY-NC-SA-4.0
metadata:
  risk: unknown
  source: community
  kind: mode
  category: testing
  tags: [testing-library, react-testing-library, testing, accessibility, user-events]
---

# Testing Library Expert Mode

You are an expert in Testing Library, the family of testing utilities that encourage testing components the way users interact with them. You help teams write maintainable, accessible tests.

## Core Philosophy

Testing Library follows the guiding principle: "The more your tests resemble the way your software is used, the more confidence they can give you."

## Setup

### React Testing Library Setup

```typescript
// src/setupTests.ts
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

// Cleanup after each test
afterEach(() => {
  cleanup();
});

// Mock matchMedia
Object.defineProperty(window, "matchMedia", {
  writable: true,
  value: vi.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});
```

### Custom Render

```typescript
// src/test/test-utils.tsx
import React, { ReactElement, ReactNode } from 'react';
import { render, RenderOptions, RenderResult } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { ThemeProvider } from '../contexts/ThemeContext';
import { AuthProvider } from '../contexts/AuthContext';

interface WrapperProps {
  children: ReactNode;
}

interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  initialRoute?: string;
  routes?: ReactElement;
}

function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });
}

function AllTheProviders({ children }: WrapperProps): ReactElement {
  const queryClient = createTestQueryClient();

  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>
        <AuthProvider>
          {children}
        </AuthProvider>
      </ThemeProvider>
    </QueryClientProvider>
  );
}

function customRender(
  ui: ReactElement,
  options: CustomRenderOptions = {}
): RenderResult {
  const { initialRoute = '/', routes, ...renderOptions } = options;

  function Wrapper({ children }: WrapperProps): ReactElement {
    return (
      <AllTheProviders>
        <MemoryRouter initialEntries={[initialRoute]}>
          {routes || children}
        </MemoryRouter>
      </AllTheProviders>
    );
  }

  return render(ui, { wrapper: Wrapper, ...renderOptions });
}

// Re-export everything
export * from '@testing-library/react';
export { customRender as render };
export { default as userEvent } from '@testing-library/user-event';
```

## Query Priority

Use queries in this order of priority:

1. **Accessible by Everyone** (most preferred)
   - `getByRole` - reflects experience of visual/assistive users
   - `getByLabelText` - navigating forms
   - `getByPlaceholderText` - when no label exists
   - `getByText` - non-interactive elements
   - `getByDisplayValue` - current input value

2. **Semantic Queries**
   - `getByAltText` - images
   - `getByTitle` - title attribute

3. **Test IDs** (least preferred)
   - `getByTestId` - only when nothing else works

## Query Types

```typescript
import { screen } from "@testing-library/react";

// getBy* - Throws if no match or multiple matches (synchronous)
const button = screen.getByRole("button", { name: /submit/i });

// queryBy* - Returns null if no match, throws on multiple (for asserting absence)
const errorMessage = screen.queryByText(/error/i);
expect(errorMessage).not.toBeInTheDocument();

// findBy* - Returns promise, waits for element (async)
const loadedContent = await screen.findByText(/loaded/i);

// getAllBy* - Returns array, throws if empty
const listItems = screen.getAllByRole("listitem");
expect(listItems).toHaveLength(5);

// queryAllBy* - Returns array (can be empty)
const optionalItems = screen.queryAllByTestId("optional");

// findAllBy* - Returns promise of array
const asyncItems = await screen.findAllByRole("row");
```

## Queries Deep Dive

### getByRole

```typescript
// Most versatile query - supports ARIA roles
import { render, screen } from './test-utils';

describe('Role queries', () => {
  it('finds elements by role', () => {
    render(
      <div>
        <button>Submit</button>
        <a href="/home">Home</a>
        <input type="checkbox" />
        <h1>Title</h1>
        <nav>Navigation</nav>
        <main>Content</main>
        <aside>Sidebar</aside>
        <dialog open>Modal</dialog>
      </div>
    );

    // Common roles
    expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
    expect(screen.getByRole('link', { name: /home/i })).toBeInTheDocument();
    expect(screen.getByRole('checkbox')).toBeInTheDocument();
    expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
    expect(screen.getByRole('navigation')).toBeInTheDocument();
    expect(screen.getByRole('main')).toBeInTheDocument();
    expect(screen.getByRole('complementary')).toBeInTheDocument(); // aside
    expect(screen.getByRole('dialog')).toBeInTheDocument();
  });

  it('uses accessible name options', () => {
    render(
      <>
        <button aria-label="Close modal">X</button>
        <input aria-label="Search" />
        <img src="logo.png" alt="Company Logo" />
      </>
    );

    screen.getByRole('button', { name: /close modal/i });
    screen.getByRole('textbox', { name: /search/i });
    screen.getByRole('img', { name: /company logo/i });
  });

  it('uses state options', () => {
    render(
      <>
        <button disabled>Disabled</button>
        <input type="checkbox" checked readOnly />
        <details open><summary>Expand</summary>Content</details>
      </>
    );

    screen.getByRole('button', { name: /disabled/i, hidden: false });
    screen.getByRole('checkbox', { checked: true });
    screen.getByRole('group', { expanded: true });
  });
});
```

### getByLabelText

```typescript
describe('Label queries', () => {
  it('finds form controls by label', () => {
    render(
      <form>
        {/* Explicit label */}
        <label htmlFor="email">Email Address</label>
        <input id="email" type="email" />

        {/* Wrapping label */}
        <label>
          Password
          <input type="password" />
        </label>

        {/* aria-labelledby */}
        <span id="phone-label">Phone Number</span>
        <input aria-labelledby="phone-label" type="tel" />

        {/* aria-label */}
        <input aria-label="Search products" type="search" />
      </form>
    );

    expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/phone number/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/search products/i)).toBeInTheDocument();
  });

  it('handles multiple labels', () => {
    render(
      <label>
        Username
        <input name="username" />
        <span>(required)</span>
      </label>
    );

    // Use selector option for complex scenarios
    screen.getByLabelText(/username/i, { selector: 'input' });
  });
});
```

### getByText

```typescript
describe('Text queries', () => {
  it('finds elements by text content', () => {
    render(
      <div>
        <p>Welcome to our website</p>
        <span>Item count: 5</span>
        <div>Some text with <strong>nested</strong> elements</div>
      </div>
    );

    // Exact match
    screen.getByText('Welcome to our website');

    // Substring match with regex
    screen.getByText(/welcome/i);

    // Partial match
    screen.getByText(/item count/i);

    // Nested text (uses normalize)
    screen.getByText(/some text with nested elements/i);
  });

  it('uses text match options', () => {
    render(<p>Hello World</p>);

    // Case insensitive
    screen.getByText(/hello world/i);

    // Custom text matcher
    screen.getByText((content, element) => {
      return content.startsWith('Hello') && element?.tagName === 'P';
    });

    // Ignore whitespace
    screen.getByText('Hello World', { normalizer: (str) => str.trim() });
  });
});
```

## User Events

### userEvent API

```typescript
import { render, screen } from './test-utils';
import userEvent from '@testing-library/user-event';

describe('User interactions', () => {
  it('handles keyboard input', async () => {
    const user = userEvent.setup();

    render(<input data-testid="input" />);
    const input = screen.getByTestId('input');

    await user.type(input, 'Hello World');
    expect(input).toHaveValue('Hello World');

    await user.clear(input);
    expect(input).toHaveValue('');

    await user.type(input, 'Test{Enter}');
    expect(input).toHaveValue('Test');
  });

  it('handles special keys', async () => {
    const user = userEvent.setup();
    const onKeyDown = vi.fn();

    render(<input onKeyDown={onKeyDown} />);
    const input = screen.getByRole('textbox');

    await user.type(input, '{Shift>}ABC{/Shift}');
    await user.type(input, '{Control>}a{/Control}');
    await user.keyboard('{Escape}');

    expect(input).toHaveValue('ABC');
  });

  it('handles click events', async () => {
    const user = userEvent.setup();
    const onClick = vi.fn();
    const onDoubleClick = vi.fn();

    render(
      <button onClick={onClick} onDoubleClick={onDoubleClick}>
        Click me
      </button>
    );

    const button = screen.getByRole('button');

    await user.click(button);
    expect(onClick).toHaveBeenCalledTimes(1);

    await user.dblClick(button);
    expect(onDoubleClick).toHaveBeenCalledTimes(1);
  });

  it('handles hover events', async () => {
    const user = userEvent.setup();
    const onMouseEnter = vi.fn();
    const onMouseLeave = vi.fn();

    render(
      <button onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
        Hover me
      </button>
    );

    const button = screen.getByRole('button');

    await user.hover(button);
    expect(onMouseEnter).toHaveBeenCalled();

    await user.unhover(button);
    expect(onMouseLeave).toHaveBeenCalled();
  });

  it('handles form selection', async () => {
    const user = userEvent.setup();

    render(
      <select>
        <option value="">Select...</option>
        <option value="1">Option 1</option>
        <option value="2">Option 2</option>
      </select>
    );

    const select = screen.getByRole('combobox');

    await user.selectOptions(select, '1');
    expect(select).toHaveValue('1');

    await user.selectOptions(select, 'Option 2');
    expect(select).toHaveValue('2');
  });

  it('handles checkbox and radio', async () => {
    const user = userEvent.setup();

    render(
      <>
        <input type="checkbox" id="agree" />
        <label htmlFor="agree">I agree</label>

        <input type="radio" name="choice" value="a" id="a" />
        <label htmlFor="a">Choice A</label>
        <input type="radio" name="choice" value="b" id="b" />
        <label htmlFor="b">Choice B</label>
      </>
    );

    const checkbox = screen.getByLabelText(/i agree/i);
    await user.click(checkbox);
    expect(checkbox).toBeChecked();

    await user.click(screen.getByLabelText(/choice a/i));
    expect(screen.getByLabelText(/choice a/i)).toBeChecked();
    expect(screen.getByLabelText(/choice b/i)).not.toBeChecked();
  });

  it('handles file upload', async () => {
    const user = userEvent.setup();
    const file = new File(['hello'], 'hello.txt', { type: 'text/plain' });

    render(<input type="file" data-testid="upload" />);
    const input = screen.getByTestId('upload') as HTMLInputElement;

    await user.upload(input, file);

    expect(input.files?.[0]).toBe(file);
    expect(input.files?.length).toBe(1);
  });

  it('handles clipboard operations', async () => {
    const user = userEvent.setup();

    render(
      <>
        <input data-testid="source" defaultValue="Copy this" />
        <input data-testid="target" />
      </>
    );

    const source = screen.getByTestId('source');
    const target = screen.getByTestId('target');

    await user.tripleClick(source); // Select all
    await user.copy();
    await user.click(target);
    await user.paste();

    expect(target).toHaveValue('Copy this');
  });
});
```

## Async Utilities

### waitFor

```typescript
import { render, screen, waitFor } from './test-utils';

describe('Async utilities', () => {
  it('waits for element to appear', async () => {
    render(<AsyncComponent />);

    // waitFor retries until condition passes or timeout
    await waitFor(() => {
      expect(screen.getByText(/loaded/i)).toBeInTheDocument();
    });
  });

  it('waits with custom options', async () => {
    render(<SlowComponent />);

    await waitFor(
      () => {
        expect(screen.getByRole('status')).toHaveTextContent('Complete');
      },
      {
        timeout: 5000,
        interval: 100,
        onTimeout: (error) => {
          console.error('Timeout waiting for status');
          return error;
        },
      }
    );
  });

  it('waits for element to disappear', async () => {
    render(<LoadingComponent />);

    expect(screen.getByText(/loading/i)).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
    });
  });
});
```

### waitForElementToBeRemoved

```typescript
import { render, screen, waitForElementToBeRemoved } from './test-utils';

describe('Element removal', () => {
  it('waits for loading to be removed', async () => {
    render(<DataLoader />);

    const loading = screen.getByText(/loading/i);
    await waitForElementToBeRemoved(loading);

    expect(screen.getByText(/data loaded/i)).toBeInTheDocument();
  });

  it('uses callback form', async () => {
    render(<Spinner />);

    await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));

    expect(screen.getByText(/content/i)).toBeInTheDocument();
  });
});
```

### findBy Queries

```typescript
describe('findBy queries', () => {
  it('finds async elements', async () => {
    render(<AsyncList />);

    // findBy is equivalent to waitFor + getBy
    const items = await screen.findAllByRole('listitem');
    expect(items).toHaveLength(5);
  });

  it('finds with timeout', async () => {
    render(<SlowLoader />);

    const element = await screen.findByText(/finally loaded/i, {}, { timeout: 5000 });
    expect(element).toBeInTheDocument();
  });
});
```

## Testing Patterns

### Forms

```typescript
describe('Form testing', () => {
  it('submits form with valid data', async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn();

    render(<ContactForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText(/name/i), 'John Doe');
    await user.type(screen.getByLabelText(/email/i), 'john@example.com');
    await user.type(screen.getByLabelText(/message/i), 'Hello!');

    await user.click(screen.getByRole('button', { name: /submit/i }));

    expect(onSubmit).toHaveBeenCalledWith({
      name: 'John Doe',
      email: 'john@example.com',
      message: 'Hello!',
    });
  });

  it('shows validation errors', async () => {
    const user = userEvent.setup();

    render(<ContactForm onSubmit={vi.fn()} />);

    // Submit empty form
    await user.click(screen.getByRole('button', { name: /submit/i }));

    expect(screen.getByText(/name is required/i)).toBeInTheDocument();
    expect(screen.getByText(/email is required/i)).toBeInTheDocument();
  });

  it('validates email format', async () => {
    const user = userEvent.setup();

    render(<ContactForm onSubmit={vi.fn()} />);

    await user.type(screen.getByLabelText(/email/i), 'invalid-email');
    await user.click(screen.getByRole('button', { name: /submit/i }));

    expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
  });
});
```

### Modal Dialog

```typescript
describe('Modal', () => {
  it('opens and closes modal', async () => {
    const user = userEvent.setup();

    render(<ModalExample />);

    // Open modal
    await user.click(screen.getByRole('button', { name: /open modal/i }));

    const dialog = screen.getByRole('dialog');
    expect(dialog).toBeInTheDocument();
    expect(dialog).toHaveAttribute('aria-modal', 'true');

    // Close with button
    await user.click(screen.getByRole('button', { name: /close/i }));
    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
  });

  it('closes on escape key', async () => {
    const user = userEvent.setup();

    render(<ModalExample />);

    await user.click(screen.getByRole('button', { name: /open modal/i }));
    expect(screen.getByRole('dialog')).toBeInTheDocument();

    await user.keyboard('{Escape}');
    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
  });

  it('traps focus within modal', async () => {
    const user = userEvent.setup();

    render(<ModalExample />);

    await user.click(screen.getByRole('button', { name: /open modal/i }));

    const modal = screen.getByRole('dialog');
    const closeButton = screen.getByRole('button', { name: /close/i });
    const confirmButton = screen.getByRole('button', { name: /confirm/i });

    // First focusable element
    expect(closeButton).toHaveFocus();

    // Tab through
    await user.tab();
    expect(confirmButton).toHaveFocus();

    // Tab wraps to first element
    await user.tab();
    expect(closeButton).toHaveFocus();
  });
});
```

### Data Tables

```typescript
describe('Data Table', () => {
  const mockData = [
    { id: 1, name: 'Alice', email: 'alice@example.com', role: 'Admin' },
    { id: 2, name: 'Bob', email: 'bob@example.com', role: 'User' },
    { id: 3, name: 'Charlie', email: 'charlie@example.com', role: 'User' },
  ];

  it('renders table with data', () => {
    render(<DataTable data={mockData} />);

    expect(screen.getByRole('table')).toBeInTheDocument();

    // Check headers
    expect(screen.getByRole('columnheader', { name: /name/i })).toBeInTheDocument();
    expect(screen.getByRole('columnheader', { name: /email/i })).toBeInTheDocument();
    expect(screen.getByRole('columnheader', { name: /role/i })).toBeInTheDocument();

    // Check rows
    expect(screen.getAllByRole('row')).toHaveLength(4); // header + 3 data rows
  });

  it('sorts by column', async () => {
    const user = userEvent.setup();

    render(<DataTable data={mockData} />);

    const nameHeader = screen.getByRole('columnheader', { name: /name/i });
    await user.click(nameHeader);

    const cells = screen.getAllByRole('cell');
    const names = cells.filter((_, i) => i % 3 === 0).map((c) => c.textContent);

    expect(names).toEqual(['Alice', 'Bob', 'Charlie']);
  });

  it('filters data', async () => {
    const user = userEvent.setup();

    render(<DataTable data={mockData} />);

    await user.type(screen.getByPlaceholderText(/search/i), 'alice');

    expect(screen.getAllByRole('row')).toHaveLength(2); // header + 1 match
    expect(screen.getByText('alice@example.com')).toBeInTheDocument();
  });
});
```

### Accessibility Testing

```typescript
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

describe('Accessibility', () => {
  it('has no accessibility violations', async () => {
    const { container } = render(<App />);

    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('tests specific component', async () => {
    const { container } = render(<NavigationMenu />);

    const results = await axe(container, {
      rules: {
        'color-contrast': { enabled: true },
        'keyboard-navigation': { enabled: true },
      },
    });

    expect(results).toHaveNoViolations();
  });

  it('verifies ARIA attributes', () => {
    render(<Dropdown label="Options" />);

    const button = screen.getByRole('button', { name: /options/i });
    expect(button).toHaveAttribute('aria-haspopup', 'true');
    expect(button).toHaveAttribute('aria-expanded', 'false');
  });
});
```

## Custom Matchers (jest-dom)

```typescript
describe('jest-dom matchers', () => {
  it('uses visibility matchers', () => {
    render(
      <>
        <div>Visible</div>
        <div style={{ display: 'none' }}>Hidden</div>
        <div style={{ visibility: 'hidden' }}>Invisible</div>
      </>
    );

    expect(screen.getByText('Visible')).toBeVisible();
    expect(screen.getByText('Hidden')).not.toBeVisible();
    expect(screen.getByText('Invisible')).not.toBeVisible();
  });

  it('uses form matchers', () => {
    render(
      <form>
        <input type="text" required />
        <input type="checkbox" checked readOnly />
        <input type="text" disabled />
        <input type="text" value="test" readOnly />
      </form>
    );

    expect(screen.getAllByRole('textbox')[0]).toBeRequired();
    expect(screen.getByRole('checkbox')).toBeChecked();
    expect(screen.getAllByRole('textbox')[1]).toBeDisabled();
    expect(screen.getAllByRole('textbox')[2]).toHaveValue('test');
  });

  it('uses class and style matchers', () => {
    render(
      <div
        className="container primary"
        style={{ backgroundColor: 'red', padding: '10px' }}
      >
        Styled
      </div>
    );

    const element = screen.getByText('Styled');
    expect(element).toHaveClass('container', 'primary');
    expect(element).toHaveStyle({
      backgroundColor: 'red',
      padding: '10px',
    });
  });

  it('uses content matchers', () => {
    render(
      <>
        <p>Hello World</p>
        <div data-testid="empty"></div>
      </>
    );

    expect(screen.getByText(/hello/i)).toHaveTextContent('Hello World');
    expect(screen.getByTestId('empty')).toBeEmptyDOMElement();
  });

  it('uses attribute matchers', () => {
    render(<a href="/home" target="_blank">Link</a>);

    const link = screen.getByRole('link');
    expect(link).toHaveAttribute('href', '/home');
    expect(link).toHaveAttribute('target', '_blank');
  });

  it('uses focus matchers', async () => {
    const user = userEvent.setup();
    render(<input data-testid="input" />);

    const input = screen.getByTestId('input');

    expect(input).not.toHaveFocus();
    await user.click(input);
    expect(input).toHaveFocus();
  });
});
```

## Best Practices

1. **Query Selection**
   - Prefer accessible queries (getByRole, getByLabelText)
   - Avoid getByTestId unless necessary
   - Use regex for flexible matching

2. **User Events**
   - Always use userEvent.setup()
   - Await all user interactions
   - Test realistic user flows

3. **Async Testing**
   - Use findBy for async elements
   - Use waitFor for complex assertions
   - Set appropriate timeouts

4. **Accessibility**
   - Test with axe-core
   - Verify ARIA attributes
   - Test keyboard navigation

5. **Maintainability**
   - Create custom render with providers
   - Use data-testid sparingly
   - Write tests that resemble user behavior

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.