skills/rn-testing/SKILL.md
Testing patterns for React Native with Jest and React Native Testing Library. Use when writing tests, mocking Expo modules, testing Zustand stores, or debugging test failures.
npx skillsauth add cjharmath/claude-agents-skills rn-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.
React Native testing requires extensive mocking of native modules, careful handling of async operations, and understanding of Zustand store testing patterns. This codebase has 30+ test files with established patterns.
Problem: Store state persists between tests, causing flaky tests.
import { useAssessmentStore } from '@/stores/assessmentStore';
const initialState = {
userAnswers: {},
completedAssessmentAnswers: {},
retakeAreas: new Set<string>(),
loading: false,
};
describe('Assessment Store', () => {
// Reset store before each test
beforeEach(() => {
useAssessmentStore.setState(initialState, true); // true = replace entire state
});
it('saves answer to store', async () => {
const store = useAssessmentStore.getState();
await store.saveAnswer('q1', 4);
expect(useAssessmentStore.getState().userAnswers['q1']).toBe(4);
});
it('enables retake for skill area', async () => {
const store = useAssessmentStore.getState();
await store.enableSkillAreaRetake('fundamentals');
expect(useAssessmentStore.getState().retakeAreas.has('fundamentals')).toBe(true);
});
});
Key points:
setState(initialState, true) to replace (not merge) stategetState() after async operationsProblem: Testing async Zustand actions with proper waiting.
import { act, waitFor } from '@testing-library/react-native';
it('loads completed answers', async () => {
const store = useAssessmentStore.getState();
// Wrap async store operations in act
await act(async () => {
await store.loadCompletedAssessmentAnswers('assessment-123');
});
// Verify state after async completes
await waitFor(() => {
const state = useAssessmentStore.getState();
expect(Object.keys(state.completedAssessmentAnswers).length).toBeGreaterThan(0);
});
});
// For complex flows, verify each step
it('completes retake flow', async () => {
const store = useAssessmentStore.getState();
// Step 1
await act(async () => {
await store.loadCompletedAssessmentAnswers('assessment-123');
});
expect(useAssessmentStore.getState().completedAssessmentAnswers).toBeDefined();
// Step 2
await act(async () => {
await store.enableSkillAreaRetake('fundamentals');
});
expect(useAssessmentStore.getState().retakeAreas.has('fundamentals')).toBe(true);
// Step 3
await act(async () => {
await store.saveAnswer('q1', 4);
});
expect(useAssessmentStore.getState().userAnswers['q1']).toBe(4);
});
Problem: Expo modules require mocks for Jest.
// __mocks__/expo-router.ts (or in jest.setup.js)
jest.mock('expo-router', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
dismiss: jest.fn(),
}),
useLocalSearchParams: () => ({}),
useSegments: () => [],
usePathname: () => '/',
Link: ({ children }: { children: React.ReactNode }) => children,
Stack: {
Screen: () => null,
},
}));
// expo-secure-store
jest.mock('expo-secure-store', () => ({
getItemAsync: jest.fn(),
setItemAsync: jest.fn(),
deleteItemAsync: jest.fn(),
}));
// expo-constants
jest.mock('expo-constants', () => ({
expoConfig: {
extra: {
apiUrl: 'http://test-api.local',
},
},
}));
// expo-haptics
jest.mock('expo-haptics', () => ({
impactAsync: jest.fn(),
notificationAsync: jest.fn(),
selectionAsync: jest.fn(),
}));
Check jest.setup.js - many mocks are already configured globally.
Problem: Components using React Query need QueryClientProvider.
// Use existing utility from codebase
import { createTestQueryClient, QueryWrapper } from '@/__tests__/utils/react-query-test-utils';
// Or create wrapper
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
}
function QueryWrapper({ children }: { children: React.ReactNode }) {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
// Usage in tests
import { render, waitFor } from '@testing-library/react-native';
it('fetches and displays data', async () => {
const { getByText } = render(
<QueryWrapper>
<MyComponent />
</QueryWrapper>
);
await waitFor(() => {
expect(getByText('Loaded data')).toBeTruthy();
});
});
import { renderHook, act, waitFor } from '@testing-library/react-native';
describe('useAuth', () => {
it('signs in user', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider, // If hook needs context
});
await act(async () => {
await result.current.signIn('token', mockUser);
});
expect(result.current.user).toEqual(mockUser);
expect(result.current.token).toBe('token');
});
});
// Hook with Zustand
describe('useAssessmentAnswers', () => {
beforeEach(() => {
useAssessmentStore.setState(initialState, true);
});
it('returns current answers', () => {
// Pre-populate store
useAssessmentStore.setState({ userAnswers: { q1: 4 } });
const { result } = renderHook(() => useAssessmentAnswers());
expect(result.current.answers).toEqual({ q1: 4 });
});
});
import { render, fireEvent, waitFor } from '@testing-library/react-native';
describe('SessionCard', () => {
const mockSession = {
id: '1',
title: 'Serve Practice',
totalDuration: 45, // Backend-calculated
};
it('displays session data from backend', () => {
const { getByText } = render(<SessionCard session={mockSession} />);
expect(getByText('Serve Practice')).toBeTruthy();
expect(getByText('45 min')).toBeTruthy();
});
it('calls onPress when tapped', () => {
const onPress = jest.fn();
const { getByTestId } = render(
<SessionCard session={mockSession} onPress={onPress} />
);
fireEvent.press(getByTestId('session-card'));
expect(onPress).toHaveBeenCalledWith(mockSession.id);
});
});
import { useRouter } from 'expo-router';
jest.mock('expo-router');
describe('SettingsScreen', () => {
const mockPush = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useRouter as jest.Mock).mockReturnValue({
push: mockPush,
back: jest.fn(),
});
});
it('navigates to profile on button press', () => {
const { getByText } = render(<SettingsScreen />);
fireEvent.press(getByText('Edit Profile'));
expect(mockPush).toHaveBeenCalledWith('/profile/edit');
});
});
// Testing with route params
jest.mock('expo-router', () => ({
useLocalSearchParams: () => ({ id: 'test-assessment-123' }),
useRouter: () => ({ push: jest.fn() }),
}));
Problem: "Warning: An update inside a test was not wrapped in act(...)"
// WRONG - state update happens after test
it('loads data', () => {
render(<DataComponent />);
// Component fetches data async, updates state after test ends
});
// CORRECT - wait for async completion
it('loads data', async () => {
const { getByText } = render(<DataComponent />);
// Wait for loading to complete
await waitFor(() => {
expect(getByText('Data loaded')).toBeTruthy();
});
});
// CORRECT - use findBy* (has built-in waitFor)
it('loads data', async () => {
const { findByText } = render(<DataComponent />);
const element = await findByText('Data loaded');
expect(element).toBeTruthy();
});
When to use:
When to avoid:
// Good snapshot candidate - stable UI component
it('renders correctly', () => {
const tree = render(<Button title="Submit" />);
expect(tree.toJSON()).toMatchSnapshot();
});
// Bad snapshot candidate - dynamic content
it('renders user list', () => {
// Don't snapshot - list content varies
// Instead, test specific behaviors
});
// Mock Orval-generated hooks
jest.mock('@/api/generated/assessments', () => ({
useGetAssessment: jest.fn(() => ({
data: mockAssessment,
isLoading: false,
error: null,
})),
useSubmitAssessment: jest.fn(() => ({
mutate: jest.fn(),
isLoading: false,
})),
}));
// Mock with different states
import { useGetAssessment } from '@/api/generated/assessments';
it('shows loading state', () => {
(useGetAssessment as jest.Mock).mockReturnValue({
data: null,
isLoading: true,
error: null,
});
const { getByTestId } = render(<AssessmentScreen />);
expect(getByTestId('loading-spinner')).toBeTruthy();
});
it('shows error state', () => {
(useGetAssessment as jest.Mock).mockReturnValue({
data: null,
isLoading: false,
error: new Error('Failed to load'),
});
const { getByText } = render(<AssessmentScreen />);
expect(getByText('Failed to load')).toBeTruthy();
});
__tests__/utils/react-query-test-utils.tsx # QueryClient wrapper
jest.setup.js # Global mocks
npm test # Run all tests
npm test -- --watch # Watch mode
npm test -- --coverage # Coverage report
npm test -- SessionCard # Run specific test file
npm test -- --updateSnapshot # Update snapshots
| Issue | Solution | |-------|----------| | "Cannot find module" | Check jest.setup.js module mappings | | act() warning | Wrap state updates in act(), use waitFor/findBy | | Store state bleeding | Add beforeEach with setState reset | | Async test timeout | Increase timeout or check for hanging promises | | Mock not working | Verify mock path matches import path exactly |
getState() behavior for store testsdevelopment
Styling patterns for React web applications. Use when working with Tailwind CSS, CSS Modules, theming, responsive design, or component styling.
development
Navigation and routing patterns for React web applications. Use when implementing React Router, Next.js routing, deep links, or handling navigation state.
development
Building and deploying React web applications. Use when configuring builds, deploying to Vercel/Netlify, setting up CI/CD, Docker, or managing environments.
development
Authentication patterns for React web applications. Use when implementing login flows, OAuth, JWT handling, session management, or protected routes in React web apps.