Testing Client Components
Render components with render(), interact via userEvent, and assert with screen queries. Focus on what the user sees and does, not implementation details.
Component Testing with React Testing Library
Unit tests are excellent for isolated mathematical logic, but they cannot verify whether your UI components render inputs correctly, display loading skeletons, or react to user click events. Component testing bridges this gap by rendering React elements in a simulated browser context (JSDOM). Using React Testing Library (RTL) alongside Vitest, you verify your user interface from the user's perspective rather than asserting internal code parameters. In this lesson, we will cover how to configure RTL, query HTML elements cleanly, simulate user actions via user-event, and mock Next.js routing hooks.
1. Installing React Testing Library and User-Event Drivers
To write component tests, install the testing library packages:
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
Here:
@testing-library/reactmounts and query handles React component nodes.@testing-library/jest-domprovides custom matchers (liketoBeInTheDocument).@testing-library/user-eventsimulates realistic browser inputs (firing focus and mouse down events).
2. Configuring Vitest to Load Custom Matchers
To write tests without importing assertions manually inside every file, update your Vitest configuration setup to import @testing-library/jest-dom helpers globally:
// 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: './vitest.setup.ts', // Run custom setup scripts before executing tests
},
});
Create the setup file containing the DOM matcher configurations:
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
3. Querying DOM Nodes Cleanly (The RTL Priority)
RTL encourages querying elements based on accessibility features (ARIA roles, labels, text) rather than query selectors (like class names or custom test IDs). This ensures your tests fail if your website becomes inaccessible to screen readers.
Standard query methods in order of priority:
getByRole(e.g.screen.getByRole('button', { name: /save/i })) - Best for interactive elements.getByLabelText- Best for form inputs.getByText- Best for headings or text layouts.getByTestId(e.g.data-testid="...") - Only use this as a final fallback for complex elements where roles do not apply.
4. Simulating User Inputs via user-event
Always prefer using userEvent over the legacy fireEvent command. fireEvent triggers simulated event hooks directly, whereas userEvent triggers the exact sequence of browser actions a real user performs (e.g., clicking a button fires hover, mouse down, focus, and click events sequentially).
Here is a simple toggle button component:
// components/Toggler.tsx
'use client';
import { useState } from 'react';
export function Toggler() {
const [active, setActive] = useState(false);
return (
<div>
<span>Status: {active ? 'Active' : 'Inactive'}</span>
<button onClick={() => setActive(!active)}>Toggle Status</button>
</div>
);
}
Here is the test verifying its behaviour:
// components/Toggler.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Toggler } from './Toggler';
describe('Toggler Component tests', () => {
it('renders with default inactive state', () => {
render(<Toggler />);
expect(screen.getByText('Status: Inactive')).toBeInTheDocument();
});
it('updates text state on button click', async () => {
render(<Toggler />);
const user = userEvent.setup();
const button = screen.getByRole('button', { name: /toggle/i });
await user.click(button); // Trigger click transition
expect(screen.getByText('Status: Active')).toBeInTheDocument();
});
});
5. Mocking Next.js Routing Hooks (next/navigation)
Many of your Client Components import routing hooks (like useRouter, usePathname, or useSearchParams). Because JSDOM does not have a router context, these hooks return undefined and crash tests unless you mock them:
// components/RedirectButton.tsx
'use client';
import { useRouter } from 'next/navigation';
export function RedirectButton() {
const router = useRouter();
return <button onClick={() => router.push('/dashboard')}>Go Home</button>;
}
Mock the router module inside your test file:
// components/RedirectButton.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { RedirectButton } from './RedirectButton';
// Mock target next/navigation path
const pushMock = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: pushMock,
}),
}));
describe('RedirectButton tests', () => {
it('calls router.push when clicked', async () => {
render(<RedirectButton />);
const user = userEvent.setup();
const button = screen.getByRole('button', { name: /home/i });
await user.click(button);
expect(pushMock).toHaveBeenCalledWith('/dashboard');
});
});
6. Mocking Auth.js hooks (SessionProvider)
If your layouts conditionally display buttons based on session parameters, mock the useSession Client Hook:
// components/AuthStatus.tsx
'use client';
import { useSession } from 'next-auth/react';
export function AuthStatus() {
const { data: session } = useSession();
if (!session) return <span>Please sign in</span>;
return <span>Hello, {session.user?.name}</span>;
}
Mock next-auth/react inside the test:
// components/AuthStatus.test.tsx
import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import { AuthStatus } from './AuthStatus';
vi.mock('next-auth/react', () => ({
useSession: () => ({
data: { user: { name: 'Qasim Ali' } },
}),
}));
describe('AuthStatus component tests', () => {
it('renders session username when authenticated', () => {
render(<AuthStatus />);
expect(screen.getByText('Hello, Qasim Ali')).toBeInTheDocument();
});
});
7. Gotchas of Async UI Transitions
If your component performs async operations before updating the UI (like loading listings from an API fetch), the assertion will fail if called immediately:
// BAD: Will fail if search results load asynchronously
render(<SearchList />);
expect(screen.getByText('Result 1')).toBeInTheDocument();
Fix: Use findBy queries to wait for elements to appear in the DOM:
// GOOD: Waits up to 1000ms for element to resolve
const result = await screen.findByText('Result 1');
expect(result).toBeInTheDocument();
8. Common Gotchas
-
Using fireEvent instead of userEvent:
fireEventskips native browser focusing and keypress sequences, which can pass test assertions but fail under real user interactions in production. -
Queries Mismatch with screen.getBy: Calling
screen.getByText('Loading')on elements that dynamically disappear from the DOM will throw a crash error. Always usescreen.queryByText('Loading')when asserting that an element is NOT present in the DOM.
Key Takeaways
- Configure
vitest.setup.tsto initialize custom DOM matchers. - Query HTML elements based on accessibility features (e.g.
getByRole). - Trigger browser events realistically using
userEvent.setup(). - Mock out Next.js routing and authentication modules inside dynamic Client Components.
- Use
findByqueries to handle async UI updates.
Component testing validates modular UI layouts cleanly. However, to guarantee that your entire user flow—from database inputs down to browser redirection loops—works perfectly, you must run full browser integration tests. In the next lesson, we will cover End-to-End Testing with Playwright, configuring browser runner pipelines to test complete user checkout flows.