.claude/skills/react-testing/SKILL.md
React component testing patterns including components, hooks, context, and forms. Covers Vitest Browser Mode with vitest-browser-react (preferred) and @testing-library/react. Use when testing React applications. For general UI testing patterns, see the front-end-testing skill.
npx skillsauth add jscriptcoder/jshack.me react-testingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
For general UI testing patterns (queries, events, async, accessibility), load the front-end-testing skill. For TDD workflow, load the tdd skill.
Always prefer vitest-browser-react over @testing-library/react. Tests run in a real browser, giving production-accurate rendering, events, and CSS.
npm install -D vitest @vitest/browser-playwright vitest-browser-react @vitejs/plugin-react
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
browser: {
enabled: true,
provider: playwright(),
headless: true,
instances: [{ browser: 'chromium' }],
},
},
});
import { render } from 'vitest-browser-react';
import { expect, test } from 'vitest';
test('should display machine info when provided', async () => {
const screen = await render(<MachineInfo ip="10.0.1.5" hostname="web-server" ports={3} />);
await expect.element(screen.getByText(/10\.0\.1\.5/)).toBeVisible();
await expect.element(screen.getByText(/web-server/)).toBeVisible();
});
Key differences from @testing-library/react:
render() is async — use awaitscreen scoped to the rendered componentexpect.element() for auto-retrying assertionsact() wrapper needed — CDP events + retry handle timingtest('should call onCommand when input submitted', async () => {
const handleCommand = vi.fn();
const screen = await render(<CommandInput onCommand={handleCommand} />);
await screen.getByRole('textbox').fill('ls -la /home');
await screen.getByRole('textbox').press('Enter');
expect(handleCommand).toHaveBeenCalledWith('ls -la /home');
});
test('should show connection error when machine is unreachable', async () => {
const screen = await render(<SshConnection ip="10.0.1.5" bricked={true} />);
await expect.element(screen.getByText(/connection timed out/i)).toBeVisible();
});
import { renderHook } from 'vitest-browser-react';
test('should toggle WiFi connection state', async () => {
const { result } = await renderHook(() => useWifiConnection());
expect(result.current.connected).toBe(false);
await act(() => {
result.current.connect({ essid: 'NETGEAR-5G', bssid: 'AA:BB:CC:DD:EE:FF' });
});
expect(result.current.connected).toBe(true);
});
test('should show terminal prompt when session is active', async () => {
const screen = await render(
<SessionProvider
initialSession={{ userType: 'user', machine: 'localhost', currentPath: '/home/user' }}
>
<Terminal />
</SessionProvider>,
);
await expect.element(screen.getByText(/user@localhost/)).toBeVisible();
});
For hooks that need context:
const { result } = await renderHook(() => useSession(), {
wrapper: ({ children }) => <SessionProvider>{children}</SessionProvider>,
});
The patterns below apply when using @testing-library/react with jsdom. Prefer vitest-browser-react for new projects.
React components are just functions that return JSX. Test them like functions: inputs (props) → output (rendered DOM).
// ✅ CORRECT - Test component behavior
it('should display machine hostname and IP', () => {
render(<MachineInfo ip="10.0.1.5" hostname="web-server" ports={3} />);
expect(screen.getByText(/web-server/)).toBeInTheDocument();
expect(screen.getByText(/10\.0\.1\.5/)).toBeInTheDocument();
});
// ❌ WRONG - Testing implementation
it('should set hostname state', () => {
const wrapper = mount(<MachineInfo ip="10.0.1.5" hostname="web-server" />);
expect(wrapper.state('hostname')).toBe('web-server'); // Internal state!
});
// ✅ CORRECT - Test how props affect rendered output
it('should execute command when submitted', async () => {
const handleCommand = vi.fn();
const user = userEvent.setup();
render(<CommandInput onCommand={handleCommand} />);
await user.type(screen.getByRole('textbox'), 'nmap 10.0.1.5');
await user.keyboard('{Enter}');
expect(handleCommand).toHaveBeenCalledWith('nmap 10.0.1.5');
});
// ✅ CORRECT - Test what user sees in different states
it('should show permission denied when guest runs root command', async () => {
const user = userEvent.setup();
render(<Terminal session={{ userType: 'guest' }} />);
await user.type(screen.getByRole('textbox'), 'reboot');
await user.keyboard('{Enter}');
await screen.findByText(/permission denied/i);
});
Built into @testing-library/react (import directly, no separate package needed):
import { renderHook } from '@testing-library/react';
it('should toggle WiFi connection state', () => {
const { result } = renderHook(() => useWifiConnection());
expect(result.current.connected).toBe(false);
act(() => {
result.current.connect({ essid: 'NETGEAR-5G', bssid: 'AA:BB:CC:DD:EE:FF' });
});
expect(result.current.connected).toBe(true);
});
Pattern:
result.current - Current return value of hookact() - Wrap state updatesrerender() - Re-run hook with new propsit('should accept initial session values', () => {
const { result, rerender } = renderHook(({ userType }) => useSession(userType), {
initialProps: { userType: 'guest' as UserType },
});
expect(result.current.userType).toBe('guest');
// Test with different initial value
rerender({ userType: 'root' as UserType });
expect(result.current.userType).toBe('root');
});
For hooks that need context providers:
const { result } = renderHook(() => useSession(), {
wrapper: ({ children }) => <SessionProvider>{children}</SessionProvider>,
});
expect(result.current.userType).toBe('guest');
act(() => {
result.current.switchUser('root', 'password123');
});
expect(result.current.userType).toBe('root');
const AllProviders = ({ children }) => (
<GameProvider>
<SessionProvider>
<NetworkProvider>{children}</NetworkProvider>
</SessionProvider>
</GameProvider>
);
const { result } = renderHook(() => useNetworkCommands(), {
wrapper: AllProviders,
});
// ✅ CORRECT - Wrap component in provider
const renderWithSession = (ui, { session = null, ...options } = {}) => {
return render(<SessionProvider initialSession={session}>{ui}</SessionProvider>, options);
};
it('should show root prompt when logged in as root', () => {
renderWithSession(<Terminal />, {
session: { userType: 'root', machine: 'localhost', currentPath: '/' },
});
expect(screen.getByText(/root@localhost/)).toBeInTheDocument();
});
it('should update command as user types', async () => {
const user = userEvent.setup();
render(<CommandInput />);
const input = screen.getByRole('textbox');
await user.type(input, 'ssh [email protected]');
expect(input).toHaveValue('ssh [email protected]');
});
it('should submit game setup with user input', async () => {
const handleSubmit = vi.fn();
const user = userEvent.setup();
render(<IntroScreen onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/workstation/i), 'hackbox');
await user.type(screen.getByLabelText(/username/i), 'ghost');
await user.type(screen.getByLabelText(/password/i), 'r00tpass');
await user.click(screen.getByRole('button', { name: /new game/i }));
expect(handleSubmit).toHaveBeenCalledWith({
workstationName: 'hackbox',
username: 'ghost',
rootPassword: 'r00tpass',
});
});
it('should show validation errors for empty fields', async () => {
const user = userEvent.setup();
render(<IntroScreen />);
// Submit empty form
await user.click(screen.getByRole('button', { name: /new game/i }));
// Validation errors appear
expect(screen.getByText(/workstation name is required/i)).toBeInTheDocument();
expect(screen.getByText(/username is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
❌ WRONG - Manual act() everywhere
act(() => {
render(<Terminal />);
});
await act(async () => {
await user.click(button);
});
✅ CORRECT - RTL handles it
render(<Terminal />);
await user.click(button);
Modern RTL auto-wraps:
render()userEvent methodsfireEventwaitFor, findByWhen you DO need manual act():
renderHook)❌ WRONG - Manual cleanup
afterEach(() => {
cleanup(); // Automatic since RTL 9!
});
✅ CORRECT - No cleanup needed
// Cleanup happens automatically after each test
❌ WRONG - Shared render in beforeEach
let input;
beforeEach(() => {
render(<CommandInput />);
input = screen.getByRole('textbox'); // Shared state across tests
});
it('test 1', () => {
// Uses shared input from beforeEach
});
✅ CORRECT - Factory function per test
const renderCommandInput = () => {
render(<CommandInput />);
return {
input: screen.getByRole('textbox'),
};
};
it('test 1', () => {
const { input } = renderCommandInput(); // Fresh state
});
For factory patterns, see testing skill.
❌ WRONG - Accessing component internals
const wrapper = shallow(<Terminal />);
expect(wrapper.state('currentPath')).toBe('/home'); // Internal state
expect(wrapper.instance().handleCommand).toBeDefined(); // Internal method
✅ CORRECT - Test rendered output
render(<Terminal />);
expect(screen.getByText(/user@localhost/)).toBeInTheDocument(); // What user sees
❌ WRONG - Shallow rendering
const wrapper = shallow(<Terminal />);
// Child components not rendered - incomplete test
✅ CORRECT - Full rendering
render(<Terminal />);
// Full component tree rendered - realistic test
Why: Shallow rendering hides integration bugs between parent/child components.
it('should show boot sequence then terminal', async () => {
render(<BootScreen onComplete={vi.fn()} />);
// Initially booting
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for boot to complete
await screen.findByText(/login/i);
// Loading gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
it('should catch errors with error boundary', () => {
// Suppress console.error for this test
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<BrokenComponent />
</ErrorBoundary>,
);
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
spy.mockRestore();
});
it('should render modal overlay in portal', () => {
render(<MissionModal isOpen={true} seed="abc123" />);
// Portal renders outside root, but Testing Library finds it
expect(screen.getByText(/mission briefing/i)).toBeInTheDocument();
});
Testing Library queries the entire document, so portals work automatically.
it('should show fallback then content', async () => {
render(
<Suspense fallback={<div>Loading...</div>}>
<LazyMissionPanel />
</Suspense>,
);
// Initially fallback
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for component
await screen.findByText(/available contracts/i);
});
React-specific checks:
vitest-browser-react with Vitest Browser Mode (real browser)@testing-library/react if Browser Mode not yet configuredrenderHook() for custom hookswrapper option for context providersact() calls (handled automatically)cleanup() calls (automatic)beforeEach renderexpect.element() for auto-retrying assertions (Browser Mode)tdd skill)front-end-testing skill)testing skill)development
TypeScript strict mode patterns including schema-first development, branded types, type vs interface guidance, and tsconfig strict flags. Use when writing TypeScript code, defining types or schemas, or reviewing type safety. For immutability and pure function patterns, see the functional skill.
development
Testing patterns for behavior-driven tests. Use when writing tests, creating test factories, structuring test files, or deciding what to test. Do NOT use for UI-specific testing (see front-end-testing or react-testing skills).
testing
Evaluates test quality using Dave Farley's 8 properties. Use when reviewing tests, assessing test suite quality, or analyzing test effectiveness against TDD best practices.
development
Test-Driven Development workflow. Use for ALL code changes - features, bug fixes, refactoring. TDD is non-negotiable.