qa-react — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited qa-react (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
This skill equips the tester-react agent with everything needed to write, run, and maintain React frontend tests in a fullstack Golang + React codebase. It covers unit tests for components and hooks, integration tests for pages, API mocking with MSW, accessibility checks, and optional E2E flows with Playwright. The guiding philosophy: test what the user experiences, not how the code is wired.
| Tool | Role | Version Guidance |
|---|---|---|
| Vitest | Test runner | Vite-compatible, ESM-native, fast HMR-aware |
| React Testing Library (RTL) | Component rendering + queries | @testing-library/react |
| user-event | Realistic user interaction simulation | @testing-library/user-event v14+ |
| MSW | Network-level API mocking | msw v2+ — intercepts fetch/axios at Service Worker level |
| jest-axe | Automated accessibility assertions | jest-axe via toHaveNoViolations |
| Playwright | E2E browser tests (optional) | For critical user flows only |
| @testing-library/react-hooks | Hook testing (legacy) | Prefer renderHook from @testing-library/react v14+ |
The single most important principle: tests should resemble how users interact with your app. If a test breaks because you renamed an internal state variable but the UI behavior didn't change, the test is wrong.
useState / useReducer values directlycontainer.querySelector unless absolutely no RTL query worksact() directly when RTL's waitFor / findBy handles ituserEventwaitFor and findBy* for async state transitionsUse the highest-priority query that works for your case:
getByRole('button', { name: /submit/i })<label>. Example: getByLabelText(/email address/i)getByPlaceholderText(/search/i)getByText(/welcome back/i)data-testid when no semantic query works. Example: getByTestId('chart-container')| Need | Prefix | Throws on miss? | Async? |
|---|---|---|---|
| Element must exist | getBy | Yes | No |
| Element must NOT exist | queryBy | No (returns null) | No |
| Element will appear (async) | findBy | Yes (waits) | Yes |
| Multiple elements | getAllBy / queryAllBy / findAllBy | Respective behavior | Respective |
Always use @testing-library/user-event over fireEvent. It simulates complete user interaction sequences (focus, keydown, keypress, keyup, input, change) rather than dispatching isolated synthetic events.
import userEvent from '@testing-library/user-event';
// Setup — create a user instance per test
const user = userEvent.setup();
// Click
await user.click(screen.getByRole('button', { name: /save/i }));
// Type into input
await user.type(screen.getByLabelText(/username/i), 'john_doe');
// Clear and retype
await user.clear(screen.getByLabelText(/email/i));
await user.type(screen.getByLabelText(/email/i), '[email protected]');
// Select option
await user.selectOptions(screen.getByRole('combobox', { name: /country/i }), 'US');
// Keyboard
await user.keyboard('{Enter}');
await user.tab();
// Upload file
const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' });
await user.upload(screen.getByLabelText(/upload/i), file);waitFor — poll until assertion passesawait waitFor(() => {
expect(screen.getByText(/data loaded/i)).toBeInTheDocument();
});findBy — shorthand for waitFor + getByconst heading = await screen.findByRole('heading', { name: /dashboard/i });
expect(heading).toBeInTheDocument();waitForElementToBeRemoved — wait for loading spinners to disappearawait waitForElementToBeRemoved(() => screen.queryByText(/loading/i));waitFor(() => ..., { timeout: 3000 })setTimeout or manual delays in testsfindBy query is sufficient, prefer it over waitFor + getByMSW intercepts HTTP requests at the network level. Tests hit the same fetch/axios calls as production — no mocking of modules.
src/test/setup.ts)import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
// Default handlers — shared across all tests
export const handlers = [
http.get('/api/v1/user/me', () => {
return HttpResponse.json({ id: 1, name: 'Test User', email: '[email protected]' });
}),
http.get('/api/v1/health', () => {
return HttpResponse.json({ status: 'ok' });
}),
];
export const server = setupServer(...handlers);
// Vitest global setup
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());import { server } from '@/test/setup';
import { http, HttpResponse } from 'msw';
test('shows error when API fails', async () => {
server.use(
http.get('/api/v1/user/me', () => {
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
}),
);
render(<ProfilePage />);
expect(await screen.findByText(/unauthorized/i)).toBeInTheDocument();
});| Scenario | Pattern |
|---|---|
| Success response | HttpResponse.json(data, { status: 200 }) |
| Error response | HttpResponse.json({ error: msg }, { status: 4xx/5xx }) |
| Empty response | HttpResponse.json(null, { status: 204 }) |
| Network error | HttpResponse.error() |
| Delayed response | await delay(ms) before returning |
| Request body validation | Read await request.json() and assert / branch |
| Paginated list | Read URL search params, return sliced data |
src/test/handlers/ organized by API domain (e.g., user.ts, projects.ts, auth.ts)server in setup.tsimport { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn();
beforeEach(() => {
mockOnSubmit.mockClear();
});
it('renders email and password fields', () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
it('calls onSubmit with credentials when form is filled and submitted', async () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'secret123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(mockOnSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'secret123',
});
});
it('shows validation error when email is empty', async () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
expect(mockOnSubmit).not.toHaveBeenCalled();
});
});Use renderHook from @testing-library/react (not the deprecated @testing-library/react-hooks package).
import { renderHook, waitFor } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('starts with initial value', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
});import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
it('fetches user data', async () => {
const { result } = renderHook(() => useUser(1), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.name).toBe('Test User');
});Page tests render a full page component with router context, mocked API, and all providers.
import { render, screen } from '@/test/test-utils'; // custom render with providers
import { DashboardPage } from './DashboardPage';
describe('DashboardPage', () => {
it('loads and displays project list', async () => {
render(<DashboardPage />);
// Wait for loading to complete
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
// Assert data is rendered
expect(screen.getByRole('heading', { name: /my projects/i })).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
it('navigates to project detail when clicked', async () => {
render(<DashboardPage />);
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
await user.click(screen.getByText(/project alpha/i));
expect(await screen.findByRole('heading', { name: /project alpha/i })).toBeInTheDocument();
});
});src/test/test-utils.tsx)import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
);
}
const customRender = (ui: React.ReactElement, options?: Omit<RenderOptions, 'wrapper'>) =>
render(ui, { wrapper: AllProviders, ...options });
export * from '@testing-library/react';
export { customRender as render };Use snapshots sparingly and only for stable, presentational UI that changes rarely.
it('matches snapshot for static footer', () => {
const { container } = render(<Footer />);
expect(container.firstChild).toMatchSnapshot();
});toMatchInlineSnapshot) for small outputs--updateimport { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<LoginForm onSubmit={vi.fn()} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});jest-axe on every form component and every page-level component| Category | Target | Rationale |
|---|---|---|
Business logic hooks (use*) | 80%+ | Core logic, high ROI |
| Form components | 80%+ | User input paths must be solid |
| Page components | 70%+ | Integration coverage, some paths are hard to reach |
| Presentational/layout components | 50%+ | Low logic, snapshot may suffice |
| Utility functions | 90%+ | Pure functions, easy to test exhaustively |
| E2E critical paths | 100% of defined flows | If you write an E2E test, it must pass |
Run coverage: npx vitest run --coverage
Use Playwright only for high-value end-to-end user flows that cannot be adequately tested with RTL + MSW. Examples: login flow, checkout, multi-step wizard.
import { test, expect } from '@playwright/test';
test('user can log in and see dashboard', async ({ page }) => {
await page.goto('/login');
await page.getByLabel(/email/i).fill('[email protected]');
await page.getByLabel(/password/i).fill('password123');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});e2e/ at project root, not alongside components@critical for CI gatingsrc/test/setup.ts)This file is loaded by Vitest before every test file. It configures:
expect with .toBeInTheDocument(), .toHaveTextContent(), etc.expect with .toHaveNoViolations()import '@testing-library/jest-dom/vitest';
import { server } from './handlers';
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());Referenced in vitest.config.ts:
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
css: false,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/test/**', 'src/**/*.d.ts', 'src/main.tsx'],
},
},
});| Convention | Rule |
|---|---|
| Test file location | Same directory as source: Button.tsx -> Button.test.tsx |
| Test file naming | ComponentName.test.tsx for components, useHook.test.ts for hooks |
| Test utility location | src/test/test-utils.tsx for custom render, src/test/handlers/ for MSW handlers |
| E2E test location | e2e/ at project root |
| Describe blocks | describe('ComponentName', () => { ... }) — one per file, named after component |
| Test names | Start with verb describing user action or state: "renders...", "shows...", "calls...", "navigates..." |
vi.mock() for API calls. Reserve vi.mock() for non-HTTP dependencies (e.g., window.matchMedia, IntersectionObserver)afterEachreferences/. Changes to SKILL.md or scripts/ require user approval.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.