home / skills / madappgang / claude-code / testing-frontend

This skill helps you write robust frontend tests with React and Vue using Vitest and Testing Library for reliable UI behavior.

npx playbooks add skill madappgang/claude-code --skill testing-frontend

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
11.3 KB
---
name: testing-frontend
version: 1.0.0
description: Use when writing component tests, testing user interactions, mocking APIs, or setting up Vitest/React Testing Library/Vue Test Utils for frontend applications.
keywords:
  - frontend testing
  - Vitest
  - React Testing Library
  - Vue Test Utils
  - component testing
  - user event testing
  - mocking
  - accessibility testing
plugin: dev
updated: 2026-01-20
---

# Frontend Testing Patterns

## Overview

Testing patterns for frontend applications using Vitest and React Testing Library / Vue Test Utils.

## Testing Philosophy

### User-Centric Testing

Test behavior, not implementation. Query elements the way users would find them.

```tsx
// BAD: Testing implementation
expect(wrapper.state('isOpen')).toBe(true);
expect(wrapper.find('.modal-class').exists()).toBe(true);

// GOOD: Testing behavior
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Modal Title')).toBeVisible();
```

### Query Priority

Use queries in this order (most to least preferred):

1. `getByRole` - Accessible to everyone
2. `getByLabelText` - Form elements
3. `getByPlaceholderText` - Inputs
4. `getByText` - Non-interactive elements
5. `getByDisplayValue` - Form current values
6. `getByAltText` - Images
7. `getByTestId` - Last resort

## Component Testing (React)

### Basic Component Test

```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserCard } from './UserCard';

describe('UserCard', () => {
  const user = { id: '1', name: 'John Doe', email: '[email protected]' };

  it('renders user information', () => {
    render(<UserCard user={user} />);

    expect(screen.getByText('John Doe')).toBeInTheDocument();
    expect(screen.getByText('[email protected]')).toBeInTheDocument();
  });

  it('calls onSelect when clicked', async () => {
    const onSelect = vi.fn();
    const userEvt = userEvent.setup();

    render(<UserCard user={user} onSelect={onSelect} />);

    await userEvt.click(screen.getByRole('button'));

    expect(onSelect).toHaveBeenCalledWith(user);
  });
});
```

### Testing Async Components

```tsx
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { UserList } from './UserList';

// Mock API
vi.mock('@/api', () => ({
  getUsers: vi.fn(),
}));

describe('UserList', () => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  const wrapper = ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );

  beforeEach(() => {
    queryClient.clear();
  });

  it('shows loading state', () => {
    api.getUsers.mockImplementation(() => new Promise(() => {}));

    render(<UserList />, { wrapper });

    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });

  it('shows users when loaded', async () => {
    api.getUsers.mockResolvedValue([
      { id: '1', name: 'John' },
      { id: '2', name: 'Jane' },
    ]);

    render(<UserList />, { wrapper });

    await waitFor(() => {
      expect(screen.getByText('John')).toBeInTheDocument();
      expect(screen.getByText('Jane')).toBeInTheDocument();
    });
  });

  it('shows error message on failure', async () => {
    api.getUsers.mockRejectedValue(new Error('Network error'));

    render(<UserList />, { wrapper });

    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
  });
});
```

### Testing Forms

```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

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

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

    await user.type(screen.getByLabelText(/email/i), '[email protected]');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /submit/i }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: '[email protected]',
      password: 'password123',
    });
  });

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

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

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

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

  it('disables submit button while submitting', async () => {
    const user = userEvent.setup();

    render(
      <LoginForm
        onSubmit={() => new Promise((resolve) => setTimeout(resolve, 100))}
      />
    );

    await user.type(screen.getByLabelText(/email/i), '[email protected]');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /submit/i }));

    expect(screen.getByRole('button', { name: /submitting/i })).toBeDisabled();
  });
});
```

## Component Testing (Vue)

### Basic Component Test

```ts
import { mount } from '@vue/test-utils';
import UserCard from './UserCard.vue';

describe('UserCard', () => {
  const user = { id: '1', name: 'John Doe', email: '[email protected]' };

  it('renders user information', () => {
    const wrapper = mount(UserCard, {
      props: { user },
    });

    expect(wrapper.text()).toContain('John Doe');
    expect(wrapper.text()).toContain('[email protected]');
  });

  it('emits select event when clicked', async () => {
    const wrapper = mount(UserCard, {
      props: { user },
    });

    await wrapper.find('button').trigger('click');

    expect(wrapper.emitted('select')).toBeTruthy();
    expect(wrapper.emitted('select')[0]).toEqual([user]);
  });
});
```

### Testing with Pinia

```ts
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import UserList from './UserList.vue';
import { useUserStore } from '@/stores/userStore';

describe('UserList', () => {
  it('renders users from store', () => {
    const wrapper = mount(UserList, {
      global: {
        plugins: [
          createTestingPinia({
            initialState: {
              user: {
                users: [
                  { id: '1', name: 'John' },
                  { id: '2', name: 'Jane' },
                ],
              },
            },
          }),
        ],
      },
    });

    expect(wrapper.text()).toContain('John');
    expect(wrapper.text()).toContain('Jane');
  });

  it('calls fetchUsers on mount', () => {
    mount(UserList, {
      global: {
        plugins: [createTestingPinia()],
      },
    });

    const store = useUserStore();
    expect(store.fetchUsers).toHaveBeenCalled();
  });
});
```

## Hook Testing

```tsx
import { renderHook, act, waitFor } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter());

    expect(result.current.count).toBe(0);
  });

  it('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter({ initial: 10 }));

    expect(result.current.count).toBe(10);
  });

  it('increments counter', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('respects max limit', () => {
    const { result } = renderHook(() => useCounter({ initial: 9, max: 10 }));

    act(() => {
      result.current.increment();
      result.current.increment();
    });

    expect(result.current.count).toBe(10);
  });
});
```

## Mocking

### API Mocking

```tsx
// __mocks__/api.ts
export const api = {
  getUsers: vi.fn(),
  createUser: vi.fn(),
  updateUser: vi.fn(),
  deleteUser: vi.fn(),
};

// In test
vi.mock('@/api');

beforeEach(() => {
  vi.clearAllMocks();
});

it('fetches users', async () => {
  api.getUsers.mockResolvedValue([{ id: '1', name: 'John' }]);

  render(<UserList />);

  await waitFor(() => {
    expect(api.getUsers).toHaveBeenCalledTimes(1);
  });
});
```

### Module Mocking

```tsx
// Mock entire module
vi.mock('@/utils/date', () => ({
  formatDate: vi.fn(() => '2024-01-01'),
}));

// Mock specific export
vi.mock('@/config', async () => {
  const actual = await vi.importActual('@/config');
  return {
    ...actual,
    API_URL: 'http://test-api.com',
  };
});
```

### Timer Mocking

```tsx
describe('Debounced search', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('debounces search input', async () => {
    const onSearch = vi.fn();
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

    render(<SearchInput onSearch={onSearch} debounceMs={300} />);

    await user.type(screen.getByRole('searchbox'), 'test');

    expect(onSearch).not.toHaveBeenCalled();

    vi.advanceTimersByTime(300);

    expect(onSearch).toHaveBeenCalledWith('test');
  });
});
```

## Integration Testing

### Testing Page Flow

```tsx
describe('User Registration Flow', () => {
  it('completes registration successfully', async () => {
    const user = userEvent.setup();

    render(<App />);

    // Navigate to registration
    await user.click(screen.getByRole('link', { name: /register/i }));

    // Fill form
    await user.type(screen.getByLabelText(/name/i), 'John Doe');
    await user.type(screen.getByLabelText(/email/i), '[email protected]');
    await user.type(screen.getByLabelText(/password/i), 'SecurePass123!');

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

    // Verify redirect to dashboard
    await waitFor(() => {
      expect(screen.getByText(/welcome, john/i)).toBeInTheDocument();
    });
  });
});
```

## Test Organization

### File Structure

```
src/
├── components/
│   └── UserCard/
│       ├── UserCard.tsx
│       ├── UserCard.test.tsx
│       └── index.ts
├── hooks/
│   └── useAuth/
│       ├── useAuth.ts
│       └── useAuth.test.ts
└── __tests__/
    └── integration/
        └── auth.test.tsx
```

### Test Setup

```ts
// vitest.setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();
});
```

## Performance Testing

```tsx
import { render } from '@testing-library/react';
import { performance } from 'perf_hooks';

describe('UserList Performance', () => {
  it('renders 1000 items within 100ms', () => {
    const items = Array.from({ length: 1000 }, (_, i) => ({
      id: String(i),
      name: `User ${i}`,
    }));

    const start = performance.now();
    render(<UserList items={items} />);
    const duration = performance.now() - start;

    expect(duration).toBeLessThan(100);
  });
});
```

## Accessibility Testing

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

expect.extend(toHaveNoViolations);

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

    const results = await axe(container);

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

---

*Frontend testing patterns for React and Vue applications*

Overview

This skill provides practical frontend testing patterns for TypeScript projects using Vitest with React Testing Library or Vue Test Utils. It focuses on user-centric tests, API and module mocking, hook and component testing, and setup for common test utilities. Use it to standardize component, integration, and accessibility tests across a codebase.

How this skill works

The skill describes concrete test patterns, example code, and setup guidance for rendering components, simulating user interactions, and asserting behavior rather than implementation. It covers mocking strategies (APIs, modules, timers), async/query handling, test organization, and utilities like React Query providers, Pinia test plugins, and performance/accessibility checks. Examples show how to use vitest, user-event, render utilities, and test hooks cleanly.

When to use it

  • Writing component tests for React or Vue components
  • Testing user interactions and form flows with user-event
  • Mocking network calls, modules, or timers in unit and integration tests
  • Setting up Vitest, React Testing Library, Vue Test Utils, or Pinia testing plugins
  • Adding accessibility or performance assertions to CI checks

Best practices

  • Test behavior, not implementation — prefer queries users would use (getByRole, getByLabelText)
  • Prioritize accessible queries: role → label → placeholder → text → testid as last resort
  • Mock external APIs and modules with vi.mock and clear mocks before each test
  • Wrap components with required providers (QueryClientProvider, Pinia) in tests using lightweight wrappers
  • Use fake timers for debounce/throttle tests and advance timers deterministically
  • Keep tests isolated: cleanup afterEach and reset shared global state

Example use cases

  • Unit test a UserCard that emits events or calls callbacks when clicked
  • Test async UserList that fetches from an API and shows loading/error states
  • Validate a LoginForm: typing, validation messages, submit behavior and disabled state
  • Hook tests for stateful logic (counters, fetch hooks) using renderHook and act
  • Integration flow testing like registration or onboarding across multiple pages

FAQ

How do I test components that use React Query or Pinia?

Wrap the component with the appropriate provider in the render call (QueryClientProvider for React Query, createTestingPinia for Pinia). Provide a fresh client or initial state and clear caches between tests.

When should I use getByTestId?

Only use getByTestId as a last resort when accessible queries (role, label, text, alt) are not available or practical. Prefer queries that reflect how users interact with the UI.

How do I mock timers for debounce tests?

Use vi.useFakeTimers() in beforeEach, create user-event with advanceTimers option if needed, call vi.advanceTimersByTime(ms) to trigger debounced behavior, and restore real timers after each test.