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.
// 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):
getByRole- Accessible to everyonegetByLabelText- Form elementsgetByPlaceholderText- InputsgetByText- Non-interactive elementsgetByDisplayValue- Form current valuesgetByAltText- ImagesgetByTestId- Last resort
Component Testing (React)
Basic Component Test
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: 'john@example.com' };
it('renders user information', () => {
render(<UserCard user={user} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).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
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
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), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
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), 'test@example.com');
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
import { mount } from '@vue/test-utils';
import UserCard from './UserCard.vue';
describe('UserCard', () => {
const user = { id: '1', name: 'John Doe', email: 'john@example.com' };
it('renders user information', () => {
const wrapper = mount(UserCard, {
props: { user },
});
expect(wrapper.text()).toContain('John Doe');
expect(wrapper.text()).toContain('john@example.com');
});
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
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
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
// __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
// 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
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
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), 'john@example.com');
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
// vitest.setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
Performance Testing
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
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