From adlc-react-frontend
Testing patterns for React using Vitest and Testing Library including component tests, hook tests, and async testing.
npx claudepluginhub sumanpapanaboina1983/adlc-accelerator-kit-pluginsThis skill uses the workspace's default tool permissions.
```ts
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Guides MCP server integration in Claude Code plugins via .mcp.json or plugin.json configs for stdio, SSE, HTTP types, enabling external services as tools.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import TodoItem from './TodoItem';
describe('TodoItem', () => {
const defaultProps = {
id: '1',
title: 'Test todo',
completed: false,
onToggle: vi.fn(),
onDelete: vi.fn(),
};
it('should render todo title', () => {
render(<TodoItem {...defaultProps} />);
expect(screen.getByText('Test todo')).toBeInTheDocument();
});
it('should render unchecked checkbox when not completed', () => {
render(<TodoItem {...defaultProps} />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
});
it('should render checked checkbox when completed', () => {
render(<TodoItem {...defaultProps} completed={true} />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeChecked();
});
it('should have strikethrough style when completed', () => {
render(<TodoItem {...defaultProps} completed={true} />);
const title = screen.getByText('Test todo');
expect(title).toHaveClass('line-through');
});
});
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import TodoItem from './TodoItem';
describe('TodoItem interactions', () => {
it('should call onToggle when checkbox is clicked', async () => {
const onToggle = vi.fn();
const user = userEvent.setup();
render(
<TodoItem
id="1"
title="Test todo"
completed={false}
onToggle={onToggle}
onDelete={vi.fn()}
/>
);
await user.click(screen.getByRole('checkbox'));
expect(onToggle).toHaveBeenCalledWith('1');
expect(onToggle).toHaveBeenCalledTimes(1);
});
it('should call onDelete when delete button is clicked', async () => {
const onDelete = vi.fn();
const user = userEvent.setup();
render(
<TodoItem
id="1"
title="Test todo"
completed={false}
onToggle={vi.fn()}
onDelete={onDelete}
/>
);
await user.click(screen.getByRole('button', { name: /delete/i }));
expect(onDelete).toHaveBeenCalledWith('1');
});
});
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import TodoInput from './TodoInput';
describe('TodoInput', () => {
it('should call onAdd with input value when form is submitted', async () => {
const onAdd = vi.fn();
const user = userEvent.setup();
render(<TodoInput onAdd={onAdd} />);
const input = screen.getByRole('textbox');
await user.type(input, 'New todo');
await user.click(screen.getByRole('button', { name: /add/i }));
expect(onAdd).toHaveBeenCalledWith('New todo');
});
it('should clear input after submission', async () => {
const user = userEvent.setup();
render(<TodoInput onAdd={vi.fn()} />);
const input = screen.getByRole('textbox');
await user.type(input, 'New todo');
await user.click(screen.getByRole('button', { name: /add/i }));
expect(input).toHaveValue('');
});
it('should show error when submitting empty input', async () => {
const onAdd = vi.fn();
const user = userEvent.setup();
render(<TodoInput onAdd={onAdd} />);
await user.click(screen.getByRole('button', { name: /add/i }));
expect(screen.getByText(/title is required/i)).toBeInTheDocument();
expect(onAdd).not.toHaveBeenCalled();
});
it('should trim whitespace from input', async () => {
const onAdd = vi.fn();
const user = userEvent.setup();
render(<TodoInput onAdd={onAdd} />);
await user.type(screen.getByRole('textbox'), ' New todo ');
await user.click(screen.getByRole('button', { name: /add/i }));
expect(onAdd).toHaveBeenCalledWith('New todo');
});
});
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import TodoList from './TodoList';
// Mock fetch
global.fetch = vi.fn();
describe('TodoList async', () => {
it('should show loading state initially', () => {
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => [],
});
render(<TodoList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('should show todos after loading', async () => {
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => [
{ id: '1', title: 'Test todo', completed: false },
],
});
render(<TodoList />);
await waitFor(() => {
expect(screen.getByText('Test todo')).toBeInTheDocument();
});
});
it('should show error message when fetch fails', async () => {
(fetch as any).mockResolvedValueOnce({
ok: false,
});
render(<TodoList />);
await waitFor(() => {
expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument();
});
});
it('should show empty state when no todos', async () => {
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => [],
});
render(<TodoList />);
await waitFor(() => {
expect(screen.getByText(/no todos yet/i)).toBeInTheDocument();
});
});
});
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('should update todo count after adding', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Wait for initial load
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
// Add a todo
await user.type(screen.getByRole('textbox'), 'New todo');
await user.click(screen.getByRole('button', { name: /add/i }));
// Wait for update
await waitFor(() => {
expect(screen.getByText('New todo')).toBeInTheDocument();
});
});
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('should initialize with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('should initialize with provided value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('should increment count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('should decrement count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
});
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useTodos } from './useTodos';
describe('useTodos', () => {
it('should fetch todos on mount', async () => {
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => [{ id: '1', title: 'Test', completed: false }],
});
const { result } = renderHook(() => useTodos());
// Initially loading
expect(result.current.isLoading).toBe(true);
// Wait for fetch
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.todos).toHaveLength(1);
expect(result.current.todos[0].title).toBe('Test');
});
});
Use queries in this order (most to least preferred):
// By role - BEST for interactive elements
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('checkbox');
screen.getByRole('heading', { level: 1 });
// By label - BEST for form inputs
screen.getByLabelText(/email address/i);
// By placeholder - when no label
screen.getByPlaceholderText(/search/i);
// By text - for non-interactive content
screen.getByText(/welcome/i);
// By alt text - for images
screen.getByAltText(/profile/i);
// By title - for elements with title attribute
screen.getByTitle(/close/i);
// Only when no other option
screen.getByTestId('custom-element');
const mockFn = vi.fn();
// With return value
mockFn.mockReturnValue('value');
// With implementation
mockFn.mockImplementation((arg) => arg * 2);
// Async
mockFn.mockResolvedValue({ data: 'value' });
mockFn.mockRejectedValue(new Error('Failed'));
// Mock entire module
vi.mock('./api', () => ({
fetchTodos: vi.fn().mockResolvedValue([]),
}));
// Mock specific export
vi.mock('./utils', async () => {
const actual = await vi.importActual('./utils');
return {
...actual,
formatDate: vi.fn().mockReturnValue('2024-01-01'),
};
});
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should handle API response', async () => {
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: 'test' }),
});
// ... test code
});
describe('Component', () => {
// Group related tests
describe('rendering', () => {
it('should render title', () => {});
it('should render description', () => {});
});
describe('interactions', () => {
it('should handle click', () => {});
it('should handle submit', () => {});
});
describe('async behavior', () => {
it('should show loading', () => {});
it('should show data', () => {});
it('should show error', () => {});
});
describe('edge cases', () => {
it('should handle empty data', () => {});
it('should handle null values', () => {});
});
});
Add this configuration to enforce 80% line coverage:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary', 'html', 'lcov'],
reportsDirectory: './coverage',
// Enforce 80% thresholds
thresholds: {
lines: 80,
branches: 70,
functions: 80,
statements: 80,
},
// What to include/exclude
include: ['src/**/*.{ts,tsx}'],
exclude: [
'node_modules/',
'src/test/',
'src/**/*.d.ts',
'src/main.tsx',
'src/vite-env.d.ts',
'src/**/*.stories.{ts,tsx}',
'src/**/*.test.{ts,tsx}',
'src/**/*.spec.{ts,tsx}',
],
// Fail build if thresholds not met
all: true,
},
},
});
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ci": "vitest run --coverage --reporter=verbose"
}
}
# Run tests with coverage
npm test -- --coverage --run
# View coverage report
open coverage/lcov-report/index.html
# Run tests and fail if below threshold
npm run test:coverage
# CI mode with verbose output
npm run test:ci
# Coverage for specific file
npm test -- --coverage --run src/components/TodoItem.tsx
| Report | Path |
|---|---|
| HTML Report | coverage/lcov-report/index.html |
| Text Summary | Terminal output |
| LCOV Report | coverage/lcov.info |
Always exclude:
*.test.ts, *.spec.ts)*.d.ts)main.tsx, index.tsx)*.stories.tsx)| Issue | Solution |
|---|---|
| Coverage < 80% | Add tests for uncovered branches and lines |
| Async code uncovered | Use waitFor() and test loading/error states |
| Event handlers uncovered | Test user interactions with userEvent |
| Conditional renders uncovered | Test all conditions (null, empty, error states) |
| Hooks uncovered | Use renderHook() for custom hook tests |
For stricter enforcement on critical files:
// vitest.config.ts
coverage: {
thresholds: {
// Global thresholds
lines: 80,
branches: 70,
functions: 80,
statements: 80,
// Per-file overrides
perFile: true,
100: ['src/utils/**/*.ts'], // Utils must be 100%
90: ['src/hooks/**/*.ts'], // Hooks must be 90%
},
}
# Text output shows uncovered lines
npm test -- --coverage --run --coverageReporters=text
# Example output:
# File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
# TodoItem.tsx | 75.00 | 60.00 | 80.00 | 75.00 | 23-25,42