Reference: SKILL.md · Navigation & Mocking · Reference
Before writing any test, read:
- The component file under test
- Any existing
*.view.test.tsxfor the same component - The relevant preset(s) in
tests/component-view/presets/ - The relevant renderer(s) in
tests/component-view/renderers/ - If the view calls an external HTTP API:
tests/component-view/api-mocking/and any existingapi-mocking/<feature>.tsfor that API (see navigation-mocking.md, External Service / API Mocking)
Do this before writing a single test line. Build a candidate list scoped and deduplicated against existing tests.
Ask: "What can a user do on this screen?" — type/paste input, press a button, select from a list, scroll/refresh, open/dismiss a modal, navigate to a sub-screen, wait for async data, long-press/swipe, toggle a setting.
| User action / system event | Valid pattern |
|---|---|
| Presses button → UI changes | fireEvent.press → waitFor |
| Types input → value appears | userEvent.type or fireEvent.changeText → findBy |
| Selects item → navigates | userEvent.press → route probe |
| Redux action dispatched → Engine called | store.dispatch + act → Engine spy |
| Async data arrives → list renders | findBy / waitFor |
| User triggers action → API called with correct params | interaction → spy assertion |
| Chained user journey → end state visible | Multiple fireEvent → final findBy |
Drop anything that only produces a render scenario: "The screen shows X when state is Y", "Button is disabled without input", "Token name appears in header".
Read ComponentName.view.test.tsx (if it exists) and remove any candidate already covered.
yarn test:view:coverage:folder app/components/UI/MyFeatureFocus on low branch coverage. Prioritize candidates that cover the most uncovered paths. Proceed directly to writing.
ComponentName.view.test.tsx ← always *.view.test.tsx
A good test is driven by user interaction or a meaningful business condition — not by what is statically visible after render. If your test has no fireEvent, no act, no waitFor, and no Engine spy, ask yourself: am I just checking the initial render? If yes, it's a render scenario and it's an antipattern.
Antipattern examples are in reference.md — What NOT to Do. Good tests are interaction-driven or verify a meaningful business rule:
// ✅ User types on keypad → fiat value reacts in real time
it('types 9.5 with keypad and displays $19,000.00 fiat value', async () => {
const { getByTestId, getByText, findByText, findByDisplayValue } =
defaultBridgeWithTokens({
bridge: {
sourceAmount: '0',
sourceToken: ETH_SOURCE,
destToken: undefined,
},
});
await waitFor(() =>
expect(
getByTestId(BuildQuoteSelectors.KEYPAD_DELETE_BUTTON),
).toBeOnTheScreen(),
);
fireEvent.press(getByText('9'));
fireEvent.press(getByText('.'));
fireEvent.press(getByText('5'));
expect(await findByDisplayValue('9.5')).toBeOnTheScreen();
expect(await findByText('$19,000.00')).toBeOnTheScreen();
});
// ✅ Redux dispatch → Engine called with correct params (proves the wiring, not just the UI)
it('calls quote API with custom slippage when user has set 5% and quote is requested', async () => {
const updateQuoteSpy = jest.spyOn(
Engine.context.BridgeController,
'updateBridgeQuoteRequestParams',
);
const { store } = defaultBridgeWithTokens({
bridge: { selectedDestChainId: '0x1' },
});
updateQuoteSpy.mockClear();
act(() => {
store.dispatch(setSlippage('5'));
});
await waitFor(
() => {
expect(updateQuoteSpy).toHaveBeenCalledWith(
expect.objectContaining({ slippage: 5 }),
expect.anything(),
);
},
{ timeout: 1000 },
);
updateQuoteSpy.mockRestore();
});
// ✅ Async data completeness — waits for API mock to resolve, then validates every
// field of every item. Valid because data arrival is async (findBy / waitFor).
// One of these per view — proves the full data pipeline end-to-end.
it('user sees all items with complete data after async load', async () => {
const { findByText, findByTestId } = renderMyFeatureWithRoutes();
// Wait for the first item to confirm data has loaded
await waitFor(async () => {
expect(await findByText('Token A')).toBeOnTheScreen();
});
// Validate all fields of each item in the base mock dataset
const tokenARow = await findByTestId('token-row-item-eip155:1/erc20:0xAAA');
const tokenAScope = within(tokenARow);
expect(tokenAScope.getByText('Token A')).toBeOnTheScreen();
expect(tokenAScope.getByText(/\+5\.2/)).toBeOnTheScreen(); // % change
expect(tokenAScope.getByText(/\$/)).toBeOnTheScreen(); // price
const tokenBRow = await findByTestId('token-row-item-eip155:1/erc20:0xBBB');
const tokenBScope = within(tokenBRow);
expect(tokenBScope.getByText('Token B')).toBeOnTheScreen();
expect(tokenBScope.getByText(/-1\.8/)).toBeOnTheScreen();
expect(tokenBScope.getByText(/\$/)).toBeOnTheScreen();
});
// ✅ User navigates to a new screen — proves the navigation wiring end-to-end.
// When you only need to confirm navigation occurred (not render the destination screen),
// omit the Component key. The framework renders a probe element with
// testID=`route-${routeName}` automatically when navigation arrives at that route.
it('navigates to dest token selector on press', async () => {
const state = initialStateBridge()
.withOverrides({ bridge: { sourceToken: ETH_SOURCE } })
.build();
const { findByTestId, findByText } = renderScreenWithRoutes(
BridgeView as unknown as React.ComponentType,
{ name: Routes.BRIDGE.ROOT },
[{ name: Routes.BRIDGE.TOKEN_SELECTOR }],
{ state },
);
fireEvent.press(await findByText('Swap to'));
await findByTestId(`route-${Routes.BRIDGE.TOKEN_SELECTOR}`);
});For test files where most tests share a common baseline, extract a local helper instead of repeating the same overrides:
// Define the baseline once — each test only overrides its delta from here
const DEFAULT_BRIDGE = {
sourceToken: ETH_SOURCE,
destToken: USDC_DEST,
sourceAmount: '1',
};
const defaultBridgeWithTokens = (overrides?: Record<string, unknown>) => {
const { bridge: bridgeOverrides, ...rest } = overrides ?? {};
return renderBridgeView({
deterministicFiat: true,
overrides: {
bridge: {
...DEFAULT_BRIDGE,
...(bridgeOverrides as Record<string, unknown>),
},
...rest,
} as unknown as DeepPartial<RootState>,
});
};Then each test only specifies its delta from this baseline.
Import from tests/component-view/platform. All helpers accept an optional filter (3rd arg): 'ios' | 'android' | ['ios','android'] | { only: 'ios' } | { skip: ['android'] }. Env: TEST_OS=ios or TEST_OS=android to run only one OS.
| Helper | Use |
|---|---|
describeForPlatforms(name, define, filter?) |
One describe per OS. Inside, define({ os }); use it() or itForPlatforms() — each runs once per that OS. |
itForPlatforms(name, (ctx) => {}, filter?) |
One it per OS. Callback receives { os }. |
itOnlyForPlatforms(name, fn, filter?) |
Same as itForPlatforms but registers it.only. |
itEach(table)(name, (row) => {}, filter?) |
One it per table row × per OS. Use $key in name to interpolate row fields. |
describeEach(table)(name, (row) => { it('...', () => {}); }, filter?) |
One describe per row × per OS. Use $key in name. |
getTargetPlatforms(filter?) |
Returns ['ios','android'] (or filtered list) for custom loops. |
Example — itEach (each case runs on iOS and Android):
import { itEach } from '../../../../../../tests/component-view/platform';
const cases = [
{ name: 'renders empty', amount: '0' },
{ name: 'displays fiat', amount: '1' },
];
itEach(cases)('$name', ({ amount }) => {
const { findByDisplayValue } = renderDefault({
bridge: { sourceAmount: amount },
});
expect(findByDisplayValue(amount)).toBeOnTheScreen();
});Jest modifiers (it.only, it.skip, describe.only, describe.skip) work as usual inside these blocks.
import '../../../../../../tests/component-view/mocks';
import { renderMyFeatureView } from '../../../../../../tests/component-view/renderers/myFeature';
import {
describeForPlatforms,
itForPlatforms,
} from '../../../util/test/platform';
import { act, fireEvent, waitFor, within } from '@testing-library/react-native';
import { MyViewSelectorsIDs } from './MyView.testIds'; // ← always import from the component's testIds file
import type { DeepPartial } from '../../../../../util/test/renderWithProvider';
import type { RootState } from '../../../../../reducers';
// Local helper — encapsulates the common baseline, each test only overrides its delta
// Define the baseline before the helper
const DEFAULT_MY_FEATURE = {
sourceToken: ETH_SOURCE,
destToken: USDC_DEST,
sourceAmount: '1',
};
const renderDefault = (overrides?: Record<string, unknown>) => {
const { myFeature: featureOverrides, ...rest } = overrides ?? {};
return renderMyFeatureView({
deterministicFiat: true,
overrides: {
myFeature: {
...DEFAULT_MY_FEATURE,
...(featureOverrides as Record<string, unknown>),
},
...rest,
} as unknown as DeepPartial<RootState>,
});
};
describeForPlatforms('MyView', () => {
// ✅ User interaction → UI reacts
it('types an amount with the keypad and updates the fiat display', async () => {
const { getByTestId, getByText, findByDisplayValue, findByText } =
renderDefault({
bridge: {
sourceAmount: '0',
sourceToken: ETH_SOURCE,
destToken: USDC_DEST,
},
});
await waitFor(() =>
expect(
getByTestId(MyViewSelectorsIDs.KEYPAD_DELETE_BUTTON),
).toBeOnTheScreen(),
);
fireEvent.press(getByText('1'));
fireEvent.press(getByText('0'));
expect(await findByDisplayValue('10')).toBeOnTheScreen();
expect(await findByText('$20,000.00')).toBeOnTheScreen();
});
// ✅ Redux dispatch → Engine method called with correct params
it('calls updateBridgeQuoteRequestParams with the selected dest chain when chain changes', async () => {
const updateQuoteSpy = jest.spyOn(
Engine.context.BridgeController,
'updateBridgeQuoteRequestParams',
);
const { store } = renderDefault({
bridge: { sourceToken: ETH_SOURCE, sourceAmount: '1' },
});
updateQuoteSpy.mockClear();
act(() => {
store.dispatch(setDestChain('0xa'));
});
await waitFor(() => {
expect(updateQuoteSpy).toHaveBeenCalledWith(
expect.objectContaining({ destChainId: '0xa' }),
expect.anything(),
);
});
updateQuoteSpy.mockRestore();
});
// ✅ User press → navigates to a new screen
it('opens the destination token selector when the dest token area is tapped', async () => {
const state = initialStateMyFeature()
.withOverrides({ bridge: { sourceToken: ETH_SOURCE } })
.build();
const { findByText, findByTestId } = renderScreenWithRoutes(
MyView as unknown as React.ComponentType,
{ name: Routes.MY_FEATURE },
[{ name: Routes.MY_FEATURE_TOKEN_SELECTOR }],
{ state },
);
fireEvent.press(await findByText('Swap to'));
await findByTestId(`route-${Routes.MY_FEATURE_TOKEN_SELECTOR}`);
});
});tests/component-view/mocks must be the very first import — it installs Engine and native mocks before anything else loads. For remaining imports follow project ESLint rules: renderer → platform helpers → testIds constants → @testing-library/react-native → other.
| View area | Renderer | Preset |
|---|---|---|
| Bridge | renderBridgeView |
initialStateBridge |
| Wallet | renderWalletView |
initialStateWallet |
| Trending | renderTrendingView |
initialStateTrending |
| Wallet Actions | renderWalletActionsView |
initialStateWalletActions |
| Perps | renderPerpsView |
initialStatePerps |
| Predict | renderPredictFeedView / renderPredictFeedViewWithRoutes |
initialStatePredict |
Always start from a preset, then narrow down with minimal overrides:
// Good — minimal delta from preset
renderBridgeView({
deterministicFiat: true,
overrides: {
bridge: { sourceAmount: '1' },
},
});
// Good — complex override via engine background state when needed
renderBridgeView({
overrides: {
engine: {
backgroundState: {
BridgeController: {
state: { quotesLastFetched: 0 },
},
},
},
},
});To enable the Bridge confirm CTA (requires a valid quote), use the withBridgeRecommendedQuoteEvmSimple helper on the state fixture — it's the easiest path:
const state = initialStateBridge()
.withBridgeRecommendedQuoteEvmSimple({ sourceAmount: '1' })
.build();Alternatively, set these fields manually in engine.backgroundState.BridgeController:
quotes: [recommendedQuote]recommendedQuote: recommendedQuotequotesLastFetched: Date.now()quotesLoadingStatus: 'SUCCEEDED'
Also ensure remote feature flags enable Bridge for the target chain(s) via RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.
Create one. Pattern (copy from tests/component-view/renderers/bridge.ts):
// tests/component-view/renderers/myFeature.ts
import '../mocks';
import React from 'react';
import type { DeepPartial } from '../../../app/util/test/renderWithProvider';
import type { RootState } from '../../../app/reducers';
import { renderComponentViewScreen } from '../render';
import Routes from '../../../app/constants/navigation/Routes';
import MyView from '../../../app/components/Views/MyFeature';
import { initialStateMyFeature } from '../presets/myFeature';
interface RenderMyFeatureOptions {
overrides?: DeepPartial<RootState>;
deterministicFiat?: boolean;
}
export function renderMyFeatureView(
options: RenderMyFeatureOptions = {},
): ReturnType<typeof renderComponentViewScreen> {
const { overrides, deterministicFiat } = options;
const builder = initialStateMyFeature({ deterministicFiat });
if (overrides) builder.withOverrides(overrides);
const state = builder.build();
return renderComponentViewScreen(
MyView as unknown as React.ComponentType,
{ name: Routes.MY_FEATURE },
{ state },
);
}If the view (or any child component) calls useQuery / useMutation, wrap the component in a QueryClientProvider inside the renderer — otherwise tests throw No QueryClient set:
// tests/component-view/renderers/myFeature.tsx
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderComponentViewScreen } from '../render';
import { initialStateMyFeature } from '../presets/myFeature';
import MyView from '../../../app/components/UI/MyFeature/MyView';
import Routes from '../../../app/constants/navigation/Routes';
export function renderMyFeatureView(options = {}) {
const state = initialStateMyFeature(options).build();
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return renderComponentViewScreen(
() => (
<QueryClientProvider client={queryClient}>
<MyView />
</QueryClientProvider>
),
{ name: Routes.MY_FEATURE },
{ state },
);
}Key points:
- Set
retry: falseso failed queries surface immediately in tests without retry delays - Create a new
QueryClientper call to avoid state leaking between tests
renderComponentViewScreen and renderScreenWithRoutes accept a 4th initialParams argument for views that read params from the route (e.g. via useRoute().params):
// For a view that expects { marketId: string } as route params
renderComponentViewScreen(
MyDetailView as unknown as React.ComponentType,
{ name: Routes.MY_FEATURE.DETAIL },
{ state },
{ marketId: 'market-abc-123' }, // ← initialParams as 4th argument
);In tests using renderScreenWithRoutes, pass initialParams in the route object:
renderScreenWithRoutes(
MyDetailView as unknown as React.ComponentType,
{ name: Routes.MY_FEATURE.DETAIL, params: { marketId: 'market-abc-123' } },
[],
{ state },
);If the component crashes with Cannot read properties of undefined (reading 'marketId'), the view is reading a required route param — pass it via initialParams or params.
And the matching preset (tests/component-view/presets/myFeature.ts):
import { createStateFixture } from '../stateFixture';
export const initialStateMyFeature = (options?: {
deterministicFiat?: boolean;
}) => {
const builder = createStateFixture()
.withMinimalAccounts()
.withMinimalMainnetNetwork()
.withMinimalKeyringController()
.withRemoteFeatureFlags({});
if (options?.deterministicFiat) {
builder.withOverrides({
/* currency rate overrides */
});
}
return builder;
};