Component testing
Argus runs real React inside the Hermes VM. Not a DOM emulation, not a mocked renderer — the actual React 19 reconciler, driven by a test renderer, on the engine your app ships.
The surface is intentionally small. Rendering and events stay synchronous; bounded async queries cover state that settles through promises or timers.
It needs React and a renderer. Both are optional peer dependencies of Argus, so they are not installed for you:
pnpm add -D react test-rendererimport { useState } from 'react';import { Pressable, Text, View } from 'react-native';
export function Counter() { const [count, setCount] = useState(0); return ( <View> <Text testID="value">{String(count)}</Text> <Pressable onPress={() => setCount(count + 1)}> <Text>+</Text> </Pressable> </View> );}import { fireEvent, render, screen } from 'argus';import { Counter } from './Counter';
describe('Counter', () => { test('increments on press', () => { render(<Counter />);
fireEvent.press(screen.getByText('+'));
expect(screen.getByTestId('value').props.children).toBe('1'); });});react-native itself is not needed to run this. The bundler aliases it to a small internal
shim, so the component under test imports View and Text exactly as it does in your app
while the suite runs with only react and test-renderer installed.
Test files import from 'argus' — a virtual specifier the bundler maps onto the
component-testing layer that ships inside the Argus package. Plain TypeScript tests never
import it, so React is never pulled into their bundle and they run with nothing extra
installed.
Import 'argus' without React and the run stops at the bundle step rather than resolving
something unexpected:
✘ [ERROR] Could not resolve "react" node_modules/@arguslab/argus/runtime/rntl/src/index.ts:1:18: 1 │ import React from 'react';
✗ INFRASTRUCTURE FAILURE [bundle] Build failed with 5 errorsExit code 2 — an infrastructure failure, reported separately from test failures.
React is resolved from your project, not from Argus’s own tree, so the renderer shares
the exact module instance your components were built against. Two copies of React in one
bundle means two copies of the internals act and the reconciler coordinate through, and
component tests break in ways that look like Argus bugs.
render
Section titled “render”const result = render(<Profile user={user} />);
result.root; // the rendered host treeresult.getByText('Profile'); // queries bound to this renderresult.rerender(<Profile user={other} />);result.unmount();Rendering is wrapped in act, so state set during mount is already flushed by the time
render returns.
Roots are unmounted automatically after each test. You do not need afterEach(cleanup) —
the facade registers an internal lifecycle hook that runs even when the test failed.
screen and queries
Section titled “screen and queries”screen queries the most recent render.
screen.getByText('Submit');screen.getByTestId('email-field');screen.getByRole('header');screen.getByPlaceholderText('Email');screen.getByDisplayValue('user@example.com');Each of the five predicates comes in six forms:
| Form | No match | Multiple matches |
|---|---|---|
getBy* |
throws | throws |
getAllBy* |
throws | returns all |
queryBy* |
returns null |
throws |
queryAllBy* |
returns [] |
returns all |
findBy* |
waits, then rejects | waits for exactly one |
findAllBy* |
waits, then rejects | waits for one or more |
Use getBy* when the element must exist — the throw is a better failure message than a
null dereference three lines later. Use queryBy* to assert absence:
expect(screen.queryByText('Error')).toBeNull();Matchers accept a string (exact match) or a RegExp:
screen.getByText('Total: 12.00');screen.getByText(/^Total:/);What each predicate looks at:
| Query | Matches against |
|---|---|
*ByText |
The concatenated text content of a Text node |
*ByTestId |
props.testID |
*ByRole |
props.accessibilityRole, falling back to props.role |
*ByPlaceholderText |
props.placeholder |
*ByDisplayValue |
props.value, falling back to props.defaultValue |
The same queries are bound to the object returned by render, screen, and
within(node). Async queries are waitFor plus the corresponding getBy* query, so a
timed-out findByText keeps the same No elements found for text or
Multiple elements found for text diagnostic.
Async queries and waits
Section titled “Async queries and waits”const result = render(<ProfileLoader />);
const profile = await result.findByText('Ada');await waitFor(() => expect(profile.props.accessibilityRole).toBe('header'));await waitForElementToBeRemoved(() => screen.getByText('Loading'));waitFor(callback, { timeout, interval }), every findBy* / findAllBy* query, and
waitForElementToBeRemoved use these defaults:
| Option | Default | Meaning |
|---|---|---|
timeout |
1000 ms |
Maximum real wall-clock time |
interval |
50 ms |
Requested delay between retries and the basis for the poll budget |
Like React Native Testing Library, waitFor succeeds as soon as the callback stops
throwing. Any returned value — including false, 0, or undefined — is a successful
result. waitForElementToBeRemoved accepts either a callback or a held live node; the
element must exist before the wait begins.
Each scheduler turn runs through React’s async act, so promise- and timer-backed state
updates are committed before the next query reads the live tree.
With argus.useFakeTimers() active, waits and findBy* still use their captured real
scheduler and real wall-clock safety budget.
Direct userEvent methods use a captured real-scheduler fallback, so enabling fake timers
cannot strand an interaction. To make interaction delays advance the fake clock instead,
wire the existing RNTL-compatible hook directly:
argus.useFakeTimers();const user = userEvent.setup({ advanceTimers: argus.advanceTimersByTime });await user.press(screen.getByText('Submit'));within
Section titled “within”Scopes queries to a subtree — the fix for “two elements have the same label”.
render( <View> <View testID="left"><Text>same</Text></View> <View testID="right"><Text>same</Text></View> </View>,);
expect(screen.getAllByText('same')).toHaveLength(2);expect(within(screen.getByTestId('left')).getAllByText('same')).toHaveLength(1);fireEvent
Section titled “fireEvent”fireEvent.press(node);fireEvent.changeText(node, 'Ada');fireEvent(node, 'focus'); // any handler: 'focus' → onFocusfireEvent(node, 'scroll', payload); // with a payloadBehaviour worth knowing:
- The handler is looked up on the node, then up its ancestors. Pressing the
Textinside aPressablefires thePressable’sonPress, exactly as a real press would. - A node with
disabled,accessibilityState.disabled, or (forchangeText)editable={false}swallows the event silently, matching production. - No handler anywhere up the tree throws
No handler found for onPress— a typo in a prop name fails loudly instead of passing quietly. - Dispatch is wrapped in
act, so resulting state updates are flushed before the call returns.
For state changes you trigger yourself, outside an event.
act(() => { store.setUser(nextUser);});
expect(screen.getByText(nextUser.name)).toBeDefined();The exported helper remains synchronous. Async query utilities manage their own awaited
act turns; callers do not wrap findBy* or waitFor themselves.
Held nodes stay live
Section titled “Held nodes stay live”A query result is a live view of the element, not a snapshot of the tree at the moment you queried it. Hold a node across an update and it reports the current props and fires the current handler, so you can press the same button twice without re-querying:
const button = screen.getByText('+');fireEvent.press(button); // 0 -> 1fireEvent.press(button); // 1 -> 2, the handler from the second renderThe same holds for result.root and for a scope captured from within — the scope keeps
resolving against the current tree after it re-renders.
These became live views after v0.2.0. On v0.2.0 — the current release on npm — they are still snapshots, and a node held across an update silently dispatches into the previous render’s closure: the counter above goes 0 → 1 → 1. Live views land in the next patch release. If you wrote re-queries to work around that, they stay correct either way; they are just no longer required once you upgrade.
What is not supported
Section titled “What is not supported”Deliberately out of scope:
- Suspense guarantees.
- Layout, measurement, and anything positional.
- Native platform fidelity beyond the four shim components.
Layout, native behaviour, and Suspense integration still belong in an on-device test.
Where this layer lives
Section titled “Where this layer lives”The facade is a separate workspace package in the repository (packages/rntl) and is
never published to npm. It ships inside @arguslab/argus as TypeScript source under
runtime/rntl/src, and esbuild compiles it on your machine on every run — the same way the
test framework itself ships. Keeping it a distinct package is an internal boundary that
stops React from leaking into the framework core, not a distribution decision.
It is treated as a stopgap. When upstream React Native Testing Library v14 on
test-renderer becomes bundleable within the supported Hermes envelope, this layer can be
retired without growing the framework core.
Your test code is insulated from either outcome: it imports from 'argus', and the
specifier is what gets remapped.