From react-native-hifi
Guides Test-Driven Development for React Native using Jest (jest-expo) and @testing-library/react-native. Use before implementing features, bugfixes, or refactors.
How this skill is triggered — by the user, by Claude, or both
Slash command
/react-native-hifi:test-driven-developmentThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Write the test first. Watch it fail. Write minimal code to pass.
Write the test first. Watch it fail. Write minimal code to pass.
Core principle: If you didn't watch the test fail, you don't know if it tests the right thing.
Violating the letter of the rules is violating the spirit of the rules.
jest-expo preset (standard for Expo / React Native). Do NOT install Jest 30+ directly — use the Jest version bundled with jest-expo. Run tests with npx jest (the jest-expo preset is configured in package.json).@testing-library/react-native (React Native Testing Library)
render, screen — render components and query the outputfireEvent, userEvent — simulate user interactionswaitFor, findBy* queries — handle async state updatesrenderHook — test custom hooks in isolationreact-native-testing skill. It contains up-to-date API docs, query variant tables, interaction guides, and anti-patterns that override potentially stale training data.@testing-library/react-native v13+) + @testing-library/jest-native extended matchersagent-device skill to automate iOS simulators and Android emulators.Always:
Exceptions (ask your human partner):
Thinking "skip TDD just this once"? Stop. That's rationalization.
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
Write code before the test? Delete it. Start over.
No exceptions:
Implement fresh from tests. Period.
digraph tdd_cycle {
rankdir=LR;
red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"];
verify_red [label="Verify fails\ncorrectly", shape=diamond];
green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"];
verify_green [label="Verify passes\nAll green", shape=diamond];
refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"];
next [label="Next", shape=ellipse];
red -> verify_red;
verify_red -> green [label="yes"];
verify_red -> red [label="wrong\nfailure"];
green -> verify_green;
verify_green -> refactor [label="yes"];
verify_green -> green [label="no"];
refactor -> verify_green [label="stay\ngreen"];
verify_green -> next;
next -> red;
}
Write one minimal test showing what should happen.
```typescript test('displays welcome message with user name', () => { render();expect(screen.getByText('Welcome, Alice!')).toBeOnTheScreen(); });
Clear name, tests user-visible behavior, one thing
</Good>
<Bad>
```typescript
test('component works', () => {
const { root } = render(<WelcomeScreen userName="Alice" />);
expect(root.props.userName).toBe('Alice');
});
Vague name, tests props/internals instead of rendered output
Requirements:
MANDATORY. Never skip.
npx jest path/to/component.test.tsx
Confirm:
Test passes? You're testing existing behavior. Fix test.
Test errors? Fix error, re-run until it fails correctly.
Write simplest code to pass the test.
```typescript function WelcomeScreen({ userName }: { userName: string }) { return ( Welcome, {userName}! ); } ``` Just enough to pass ```typescript function WelcomeScreen({ userName, theme, onDismiss, animationConfig, analyticsTracker, }: WelcomeScreenProps) { // YAGNI - none of this is tested yet } ``` Over-engineeredDon't add features, refactor other code, or "improve" beyond the test.
MANDATORY.
npx jest path/to/component.test.tsx
Confirm:
Test fails? Fix code, not test.
Other tests fail? Fix now.
After green only:
Keep tests green. Don't add behavior.
Next failing test for next feature.
import { render, screen } from '@testing-library/react-native';
test('shows error message when validation fails', () => {
render(<LoginForm errors={{ email: 'Invalid email' }} />);
expect(screen.getByText('Invalid email')).toBeOnTheScreen();
});
import { render, screen, userEvent } from '@testing-library/react-native';
test('calls onSubmit with email when button pressed', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByPlaceholderText('Email'), '[email protected]');
await user.press(screen.getByRole('button', { name: 'Sign In' }));
expect(onSubmit).toHaveBeenCalledWith({ email: '[email protected]' });
});
import { render, screen, waitFor } from '@testing-library/react-native';
test('displays profile data after loading', async () => {
render(<ProfileScreen userId="123" />);
expect(screen.getByText('Loading...')).toBeOnTheScreen();
// waitFor retries until assertion passes or times out
await waitFor(() => {
expect(screen.getByText('Alice Johnson')).toBeOnTheScreen();
});
expect(screen.queryByText('Loading...')).not.toBeOnTheScreen();
});
// Or use findBy* queries (built-in waitFor):
test('displays profile data after loading', async () => {
render(<ProfileScreen userId="123" />);
const name = await screen.findByText('Alice Johnson');
expect(name).toBeOnTheScreen();
});
import { renderHook, waitFor } from '@testing-library/react-native';
test('useCounter increments count', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('useFetchUser returns user data', async () => {
const { result } = renderHook(() => useFetchUser('123'));
await waitFor(() => {
expect(result.current.data).toEqual({ id: '123', name: 'Alice' });
});
});
import { render, screen, userEvent } from '@testing-library/react-native';
import { useRouter } from 'expo-router';
jest.mock('expo-router', () => ({
useRouter: jest.fn(),
}));
test('navigates to details on item press', async () => {
const push = jest.fn();
(useRouter as jest.Mock).mockReturnValue({ push });
const user = userEvent.setup();
render(<ItemList items={[{ id: '1', title: 'Item 1' }]} />);
await user.press(screen.getByText('Item 1'));
expect(push).toHaveBeenCalledWith('/items/1');
});
function renderWithProviders(ui: React.ReactElement) {
return render(
<ThemeProvider>
<AuthProvider>
{ui}
</AuthProvider>
</ThemeProvider>
);
}
test('shows logout button when authenticated', () => {
// Mock auth state or provide test values
renderWithProviders(<SettingsScreen />);
expect(screen.getByRole('button', { name: 'Log Out' })).toBeOnTheScreen();
});
Prefer queries that reflect how users find elements:
| Priority | Query | Use For |
|---|---|---|
| 1st | getByRole | Buttons, headings, text inputs |
| 2nd | getByText | Static text content |
| 3rd | getByPlaceholderText | Text inputs |
| 4th | getByDisplayValue | Filled inputs |
| 5th | getByLabelText | Labeled form elements |
| Last | getByTestID | Only when no semantic query works |
getByTestID is a last resort. If you reach for it first, rethink your component's accessibility.
| Quality | Good | Bad |
|---|---|---|
| Minimal | One thing. "and" in name? Split it. | test('validates email and domain and whitespace') |
| Clear | Name describes behavior | test('test1') |
| Shows intent | Demonstrates desired API | Obscures what code should do |
| User-centric | Tests what user sees/does | Tests component internals or state |
"I'll write tests after to verify it works"
Tests written after code pass immediately. Passing immediately proves nothing:
Test-first forces you to see the test fail, proving it actually tests something.
"I already manually tested all the edge cases"
Manual testing is ad-hoc. You think you tested everything but:
Automated tests are systematic. They run the same way every time.
"Deleting X hours of work is wasteful"
Sunk cost fallacy. The time is already gone. Your choice now:
The "waste" is keeping code you can't trust. Working code without real tests is technical debt.
"TDD is dogmatic, being pragmatic means adapting"
TDD IS pragmatic:
"Pragmatic" shortcuts = debugging in production = slower.
"Tests after achieve the same goals - it's spirit not ritual"
No. Tests-after answer "What does this do?" Tests-first answer "What should this do?"
Tests-after are biased by your implementation. You test what you built, not what's required. You verify remembered edge cases, not discovered ones.
Tests-first force edge case discovery before implementing. Tests-after verify you remembered everything (you didn't).
30 minutes of tests after does not equal TDD. You get coverage, lose proof tests work.
| Excuse | Reality |
|---|---|
| "Too simple to test" | Simple code breaks. Test takes 30 seconds. |
| "I'll test after" | Tests passing immediately prove nothing. |
| "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" |
| "Already manually tested" | Ad-hoc does not equal systematic. No record, can't re-run. |
| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. |
| "Keep as reference, write tests first" | You'll adapt it. That's testing after. Delete means delete. |
| "Need to explore first" | Fine. Throw away exploration, start with TDD. |
| "Test hard = design unclear" | Listen to test. Hard to test = hard to use. |
| "TDD will slow me down" | TDD faster than debugging. Pragmatic = test-first. |
| "Manual test faster" | Manual doesn't prove edge cases. You'll re-test every change. |
| "Existing code has no tests" | You're improving it. Add tests for existing code. |
| "React Native is hard to test" | React Native Testing Library makes it straightforward. No excuses. |
getByTestID before trying semantic queriesAll of these mean: Delete code. Start over with TDD.
Bug: Empty email accepted in login form
RED
test('shows error when email is empty and submit pressed', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.press(screen.getByRole('button', { name: 'Sign In' }));
expect(screen.getByText('Email required')).toBeOnTheScreen();
});
Verify RED
$ npx jest LoginForm.test.tsx
FAIL: Unable to find an element with text: Email required
GREEN
function LoginForm() {
const [error, setError] = useState('');
const handleSubmit = (email: string) => {
if (!email?.trim()) {
setError('Email required');
return;
}
// ...
};
return (
<View>
<TextInput placeholder="Email" onChangeText={setEmail} />
{error ? <Text>{error}</Text> : null}
<Pressable role="button" accessibilityLabel="Sign In" onPress={() => handleSubmit(email)}>
<Text>Sign In</Text>
</Pressable>
</View>
);
}
Verify GREEN
$ npx jest LoginForm.test.tsx
PASS
REFACTOR Extract validation logic, add accessibility labels if missing.
Before marking work complete:
waitFor or findBy* (no arbitrary delays)Can't check all boxes? You skipped TDD. Start over.
| Problem | Solution |
|---|---|
| Don't know how to test | Write wished-for API. Write assertion first. Ask your human partner. |
| Test too complicated | Design too complicated. Simplify interface. |
| Must mock everything | Code too coupled. Use dependency injection. |
| Test setup huge | Extract render helpers with providers. Still complex? Simplify design. |
act() warnings | Wrap state updates in act(), or use waitFor/findBy* queries. |
| Native module errors | Mock the native module in jest.setup.js or per-test. |
| Navigation hard to test | Mock expo-router hooks (useRouter, useLocalSearchParams). |
Bug found? Write failing test reproducing it. Follow TDD cycle. Test proves fix and prevents regression.
Never fix bugs without a test.
When adding mocks or test utilities, read @testing-anti-patterns.md to avoid common pitfalls:
waitFor for async state updatesAdditionally, the react-native-testing skill includes RNTL-specific anti-patterns (wrong query variants, waitFor misuse, unnecessary act(), legacy accessibility props, v14 async pitfalls) — invoke it when writing or reviewing RNTL tests.
Production code -> test exists and failed first
Otherwise -> not TDD
No exceptions without your human partner's permission.
npx claudepluginhub bidah/react-native-hifi --plugin react-native-hifiGuides test-driven development workflow for React Native using Jest, React Native Testing Library, and Detox in Red-Green-Refactor cycle. For new features, bug fixes, refactoring.
Provides Jest testing patterns including factory functions, mocking strategies, custom renders, and TDD workflow for React Native unit tests.
Enforces a strict test-driven development workflow: write failing test first, then minimal code to pass, then refactor. Use for features, bugfixes, and refactoring.