Skip to content

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:

Terminal window
pnpm add -D react test-renderer
src/Counter.tsx
import { 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>
);
}
src/Counter.test.tsx
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 errors

Exit 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.

const result = render(<Profile user={user} />);
result.root; // the rendered host tree
result.getByText('Profile'); // queries bound to this render
result.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 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.

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'));

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.press(node);
fireEvent.changeText(node, 'Ada');
fireEvent(node, 'focus'); // any handler: 'focus' → onFocus
fireEvent(node, 'scroll', payload); // with a payload

Behaviour worth knowing:

  • The handler is looked up on the node, then up its ancestors. Pressing the Text inside a Pressable fires the Pressable’s onPress, exactly as a real press would.
  • A node with disabled, accessibilityState.disabled, or (for changeText) 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.

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 -> 1
fireEvent.press(button); // 1 -> 2, the handler from the second render

The 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.

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.

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.