API reference
Everything below is exported from @ng-native/testing, except the runner integrations, which have
entry points of their own: @ng-native/testing/vitest and @ng-native/testing/register. The
signatures are the declarations in the source, shortened only where a type is spelled out
elsewhere on this page.
render()
function render<T>(
component: Type<T> | string,
options?: RenderOptions<T>,
): Promise<RenderResult<T>>;Mounts a component class, or a template string, onto a fresh fake Fabric with mount() from
@ng-native/platform, and resolves once the first frame has been committed and one more task has
passed. Each call gets its own fake, and becomes what screen queries.
A string is compiled just in time as the template of a host component, so the template form needs
@angular/compiler installed; a class is used as compiled. Anything the component throws while
it is created or first checked, such as NG0950 for a required input nobody set, rejects the
promise.
The fake stands for a development build that has every native view. While something is rendered
and no expo global is set, render() sets one whose getViewConfig answers for every view, so a
component that asks the build for a view, as ui-bottom-sheet does for the bar its
ui-toolbar-items go in on iOS, draws what a development build draws. It is removed once nothing
is rendered, and an expo global a test sets is left as it is.
RenderOptions<T>
| Option | Type | Default | What it does |
|---|---|---|---|
inputs |
Record<string, unknown> |
none | Inputs, set with setInput before the first change detection pass. The only way to give an input.required() a value. A name that is no input of the component rejects, here and in rerender(). Class form only. |
on |
Record<string, (value) => void> |
none | Output handlers, by the name a template binds: an output's alias, and one a host directive forwards. A name that is no output rejects. Class form only. |
providers |
(Provider | EnvironmentProviders)[] |
none | Providers for the app the component is mounted in: provideNativeRouter, provideNativeHttpClient, a stand-in for a service. |
imports |
unknown[] |
none | What the template may use. Template form only. |
componentProperties |
Partial<T> & Record<string, unknown> |
none | Fields assigned to the instance. On the template form's host, before its first pass; on a class, after it, followed by another pass. |
globalStyles |
StyleSheet | null |
none | A compiled global stylesheet, as compileCss() returns it. What Tailwind classes resolve against. |
conditions |
Conditions |
none | What @media queries resolve against: width, height, color scheme. With none, every media query is false. Screen from @ng-native/device reports the same size. |
tokens |
Record<string, TokenValue> |
none | Values only a device knows, such as the hairline width. |
processColor |
(value: string | number) => unknown |
none | React Native's processColor. With none, colors reach the props as the strings the stylesheet wrote, which is what a test usually wants to assert. |
resolveAssetSource |
(value: unknown) => unknown |
none | React Native's Image.resolveAssetSource. |
dev, now and onDirty are the engine's own options too, and pass through unchanged.
RenderResult<T>
Every query in the matrix, bound to this render, plus:
| Member | Type | What it does |
|---|---|---|
fabric |
FakeFabric |
The fake this render committed to. See createFakeFabric(). |
componentRef |
ComponentRef<T> |
The rendered component. componentRef.injector reaches anything the app provides. |
instance |
T |
The component instance, or the template form's host. |
fixture |
{ componentRef, componentInstance, detectChanges() } |
The part of ComponentFixture that has a meaning here. |
rerender(changes) |
(changes?: { inputs?, componentProperties? }) => Promise<void> |
Sets each input given with setInput, assigns each property, and runs change detection. Inputs not named keep their values. |
detectChanges() |
() => Promise<void> |
Marks the component for check, runs change detection, and waits a task for the commit. For a plain field changed from the test. |
debug(node?) |
(node?: FakeFabricNode) => void |
Prints the committed tree, or one node's subtree, as screen.debug does. |
unmount() |
() => void |
Destroys the component and the app it was mounted in. screen falls back to the render before it. Calling it twice does nothing. |
A render's own queries keep querying that render after a later one; screen moves to the latest.
injectService()
function injectService<T>(token: ProviderToken<T>, options?: InjectServiceOptions): T;Mounts an empty component onto a fresh fake Fabric with mount(), as render() does, and returns
token from that app's root injector. A service with no component gets the same root an app gives
it, so a @Service() and the root services it injects resolve. The calls in one test share the app,
so each returns the instance the others see. InjectServiceOptions has one option, providers,
which works as it does for render(); a call that passes it starts a new app, which later calls
use. The next test starts another app. Every call makes its own outside a test (a beforeAll),
while tests run at once (it.concurrent), and where the runner's setup did not run (a file run with
neither ngNative() nor node --import @ng-native/testing/register --test). The app is not what
screen queries, and cleanup() destroys it. See
A service on its own.
screen
const screen: BoundQueries & { debug(node?: FakeFabricNode): void };Every query, over whatever was rendered most recently and has not been unmounted. A query on
screen with nothing rendered throws Nothing is rendered: call render() first.
screen.debug(node?) prints the tree with console.log: one line per node, its Fabric view name,
the text of a RawText, and whichever of testID, nativeID, accessibilityRole,
accessibilityLabel, placeholder and a text field's text (printed as value) it has. That
is the same tree a failing getBy* query puts in its error.
within()
function within(node: FakeFabricNode): BoundQueries;Every query, scoped to node and everything under it, in whichever commit is current. The node
itself is included, so within(screen.getByRole('alert')).getByText('Email is required') finds the
alert's own text. The scope follows the node by its tag, so it stays valid across commits, and
matches nothing once the node has gone.
Queries
Each of the six queries comes in six variants. ...args is the query's own arguments, below.
| Variant | Returns | No match | One match | More than one |
|---|---|---|---|---|
getBy*(...args) |
FakeFabricNode |
throws | the node | throws |
getAllBy*(...args) |
FakeFabricNode[] |
throws | [node] |
every node |
queryBy*(...args) |
FakeFabricNode | null |
null |
the node | throws |
queryAllBy*(...args) |
FakeFabricNode[] |
[] |
[node] |
every node |
findBy*(...args, waitForOptions?) |
Promise<FakeFabricNode> |
retries, then rejects | the node | retries, then rejects |
findAllBy*(...args, waitForOptions?) |
Promise<FakeFabricNode[]> |
retries, then rejects | [node] |
every node |
findBy* is getBy* inside waitFor: it retries until the query would succeed and
rejects with the query's own error once the timeout passes. Its waitForOptions is the third
argument, after the query's options, so a query that takes none passes undefined:
screen.findByText('Going soon', undefined, { timeout: 60, interval: 10 }).
Every error says what was looked for and prints the tree. With none: Unable to find a node with text "Bananas".. With more than one: Found 2 nodes with role "listitem", and expected one. Use getAllByRole if more than one is expected.
| Query | Arguments | Matches a node when |
|---|---|---|
ByRole |
(role, { name?, exact?, disabled?, selected?, checked?, busy?, expanded? }) |
accessibilityRole or role is role; given name, the node's accessible name matches it: its accessibilityLabel, or its text content when it has none, as a screen reader reads it; and given a state, the node is in it. |
ByText |
(text, { exact? }) |
It is a Paragraph and its text content matches. |
ByTestId |
(testId, { exact? }) |
testID or nativeID matches. |
ByLabelText |
(label, { exact? }) |
accessibilityLabel matches. |
ByPlaceholderText |
(placeholder, { exact? }) |
A text field's or the navigation bar's search field's placeholder matches. |
ByDisplayValue |
(value, { exact? }) |
It is a text field and its text, the value <text-input> binds its model to, matches. |
Every query reads the props on the committed nodes, which are the props native was sent: what a
component writes for testID, id (as nativeID), accessibilityRole or role (as
accessibilityRole), accessibilityLabel and placeholder. A control that sets its own role
by default commits one without you writing it, the way <switch> commits accessibilityRole: 'switch'; anything else takes [accessibilityRole] directly.
An @expo/ui <ui-button> commits no accessibility props, and is still a button, as SwiftUI's
and Compose's buttons are to a screen reader: its name is its label, or the text inside it. Its
role, such as destructive, is SwiftUI's and not one a query finds it by. On iOS, SwiftUI's
disabled modifier makes it disabled.
Text content is every RawText under the node, in order, joined with nothing between them.
<text>and <text>plums</text></text> commits as one Paragraph holding a RawText and a nested
VirtualText, and its text content is and plums. Only the Paragraph is a ByText match; the
VirtualText is part of its text, not a match of its own.
Matching follows Testing Library's rules:
- Both sides are normalized first: whitespace runs collapse to one space, and the ends are trimmed.
getByText(' Pears ')finds a paragraph holdingPears. - A string matches the whole value, case-sensitively, by default.
{ exact: false }matches a case-insensitive substring instead:getByText('apple', { exact: false })findsApples.- A
RegExpis tested against the normalized value as given, so/pears/iis case-insensitive and/^and plums$/is anchored. ItslastIndexis set back to 0 before each test, so one with thegoryflag finds every match, on every call. - In
ByRole, the role itself is always an exact match, andexactapplies toname.
States in ByRole are read from accessibilityState, which the components and the aria-*
attributes write, as React Native Testing Library reads them. A state nothing set is false for
disabled, selected and busy, and unset for checked and expanded, so { checked: false }
finds only a node that says it is unchecked. checked counts only on a checkbox, radio or
switch; a node with any other role has no checked state. A <text-input> with editable false
is disabled. checked can also be 'mixed'. A findByRole with a state waits for the node to
reach it, which is how a test waits for a button to be enabled again before pressing it:
await userEvent.press(await screen.findByRole('button', { name: 'Retry', disabled: false }));Hidden elements are left out, as React Native Testing Library leaves them out by default: a view
with accessibilityElementsHidden, aria-hidden or importantForAccessibility: 'no-hide-descendants' hides itself and everything under it, and so does one with opacity: 0 that
takes no touch (pointerEvents: 'none'). { includeHiddenElements: true } finds them as well. An
element that is display: none has no view, so no query finds it, with the option or without. A
node scrolled off screen is not hidden; the fake has no layout to say it is. A <virtual-list>
keeps a few recycled rows as views with no opacity that take no touch, still showing their last
item, so a query does not find those either.
A node a query returns has parent: the node it is under as of that query, and null at the top.
Query again after a change rather than keep a node and read its parent later.
So a test goes up from what it found, screen.getByText('Hello').parent, to the row around it.
Interactions
Every interaction delivers its events through the handler the renderer registered with Fabric, the
one the C++ side calls on a device, so they take the same route a real event does: a touch goes
through the engine's responder negotiation and every other event bubbles from the target up. Every
event payload carries target, the node's React tag. Each interaction resolves after the tree has
had a task to commit.
An interaction on a node found by a query goes to the render that node came from. A node held from
an earlier commit still works: events find their target by its tag, which does not change. Its
props and children are that earlier commit's, though: query again to read what changed.
fireEvent
function fireEvent(node: FakeFabricNode, name: string, payload?: unknown): Promise<void>;
fireEvent.press(node: FakeFabricNode): Promise<void>;
fireEvent.changeText(node: FakeFabricNode, text: string): Promise<void>;
fireEvent.scroll(node: FakeFabricNode, payload?: EventPayload): Promise<void>;
fireEvent.focus(node: FakeFabricNode): Promise<void>;
fireEvent.layout(node: FakeFabricNode, frame?: Partial<LayoutFrame>): Promise<void>;
fireEvent.blur(node: FakeFabricNode): Promise<void>;One event, or the one pair a press is, then a task for the commit. fireEvent(node, name) with
press, changeText, scroll, focus or blur is the matching method; any other name is sent as
a top-level event, with top and a capital letter put in front unless the name has them:
fireEvent(field, 'submitEditing', { text: 'x' }) sends topSubmitEditing. A payload is the native
event itself, or an event object with it under nativeEvent, as React Native Testing Library passes
one: fireEvent.scroll(node, { nativeEvent: { contentOffset: { x: 0, y: 120 } } }). layout sends
a (layout) event with the frame given, { x, y, width, height }, each 0 where left out.
fireEvent.changeText goes to the first TextInput at or under node, so a query for the view
wrapping a field reaches the field. The navigation bar's search field (RNSSearchBar, from a
<native-header-item type="searchBar">) counts as one too, for userEvent.type as well. With
neither there it rejects with No TextInput or search bar at or under this <view name>.
A press, by fireEvent.press or fireEvent(node, 'press'), does nothing at all on a node a finger
could not reach, as React Native Testing Library decides it from pointerEvents on the node as last
committed: one that is none or box-none itself, or one under a view that is none or
box-only. pointer-events: none in CSS commits as the same prop. Views above a
ui-bottom-sheet's content and the items in its bar are not counted, since the sheet takes its
own touches on a device. Nothing is sent and nothing is thrown, so a test asserts on what did not
happen: the handler never ran. Any other event by name is sent as before.
A press on a <ui-button> ends with the event its native button sends, topButtonPress on iOS
and topButtonPressed on Android, so its (buttonPress) runs. On iOS one that SwiftUI's
disabled modifier turns off sends none.
userEvent
const userEvent: UserEvent & { setup(): UserEvent };
interface UserEvent {
press(node: FakeFabricNode): Promise<void>;
longPress(node: FakeFabricNode, options?: { duration?: number }): Promise<void>;
type(node: FakeFabricNode, text: string, options?: TypeOptions): Promise<void>;
clear(node: FakeFabricNode): Promise<void>;
}
interface TypeOptions {
skipBlur?: boolean;
submitEditing?: boolean;
}What a person does, as the several events the platform sends, a task apart. userEvent.setup()
returns the same four methods, and they can be called on userEvent directly too. longPress
holds for duration milliseconds, 500 by default, which is React Native's long-press delay.
type and clear go to the text field at or under node, and do nothing at all if it has
editable set to false: a disabled or read-only field takes no input. press, longPress,
type and clear also do nothing on a node pointerEvents keeps a finger from, as
fireEvent.press does.
Event sequences
touch below is { identifier: 0, target, pageX: 0, pageY: 0, locationX: 0, locationY: 0, timestamp }. A "task" is one setTimeout(0), which is when change detection runs and commits.
| Call | Events, in order |
|---|---|
fireEvent.press(node) |
topTouchStart { ...touch, touches: [touch], changedTouches: [touch] }, topTouchEnd { ...touch, touches: [], changedTouches: [touch] }, both in the same task; a task. |
fireEvent.changeText(node, text) |
topChange { text, eventCount } to the field; a task. eventCount is one more than the last one sent to the field, or than its committed mostRecentEventCount where that is higher: what native would send next. An iOS field commits 0, but a multiline one in the commit that restyles its text. |
fireEvent.scroll(node, payload) |
topScroll with the payload; a task. |
fireEvent.focus(node) |
topFocus; a task. |
fireEvent.blur(node) |
topBlur; a task. |
fireEvent(node, name, payload) |
top + Name with the payload; a task. |
userEvent.press(node) |
topTouchStart; a task; topTouchEnd; a task. |
userEvent.longPress(node, { duration }) |
topTouchStart; a task; duration ms; topTouchEnd; a task. |
userEvent.type(node, text, options) |
topFocus; a task; then for each character, topKeyPress { key } and topChange { text, eventCount } with the text so far, and a task; then topSubmitEditing { text } if submitEditing; then, unless skipBlur, topEndEditing { text } and topBlur; a task. |
userEvent.clear(node) |
topFocus, topChange { text: '', eventCount }; a task; topEndEditing { text: '' }, topBlur; a task. |
Typing starts from the field's committed text with the caret after it, so it appends to what is
already there. A key goes in at the caret and replaces a selection. When the app moves the caret or
the selection with setSelection() (a setTextAndSelection command), the next character goes
there. When the app puts different text back without a selection, the caret keeps its distance
from the end of the old text, as an iOS field does. A field with maxLength stops emitting past it
(a key that does not fit in what is left is dropped whole), the way native truncates before
onChangeText ever fires rather than delivering the extra characters for a handler to reject. Each
character is typed into the text the field shows: when the app refuses or rewrites a keystroke and
puts its own text back, the next character is added to that text, as it is on a device. The
topBlur at the end of type is what marks a Signal Forms field touched.
gestureOf
gestureOf(node: FakeFabricNode, kind?: string): TestGestureThe gesture a view was given with [gesture], or, with kind ('Pan', 'Tap', 'LongPress'
and the other builder names), the gesture of that kind inside it, however deeply it is composed
with Gesture.Race, Gesture.Exclusive or Gesture.Simultaneous. Its callbacks are the ones
the component gave it, by name, to call in place of a finger:
gestureOf(row, 'Pan').callbacks['onEnd']!({ translationX: -200 }). Throws when the view has no
gesture, or none of that kind.
Waiting
waitFor
function waitFor<T>(callback: () => T | Promise<T>, options?: WaitForOptions): Promise<T>;
interface WaitForOptions {
timeout?: number; // 1000, or what configure() set
interval?: number; // 50
}Calls callback until it returns without throwing, and resolves to what it returned. Between
attempts it waits interval milliseconds on a real timer, which is also what lets change detection
commit. Once timeout has passed, it rejects with the callback's last error, so a failed waitFor
reads like the assertion inside it. A fake clock stops it: advance the clock yourself instead.
waitForElementToBeRemoved
function waitForElementToBeRemoved(
target:
| FakeFabricNode
| readonly FakeFabricNode[]
| (() => FakeFabricNode | readonly FakeFabricNode[] | null | undefined),
options?: WaitForOptions,
): Promise<void>;Resolves once the node, the nodes, or whatever the callback returns have left the committed tree.
A callback that throws or returns null counts as removed. It rejects at once if the target is
already gone when it starts, as Testing Library's does, because a wait for something that is
already absent passes for the wrong reason.
configure
function configure(options: { asyncUtilTimeout?: number }): void;
function resetToDefaults(): void;asyncUtilTimeout is the timeout that waitFor, every findBy* query and
waitForElementToBeRemoved use when a call does not pass one, 1000 by default, as in React Native
Testing Library. A suite that runs on a slow machine sets it once, in a setup file, rather than on
every call:
import { configure } from '@ng-native/testing';
configure({ asyncUtilTimeout: 3000 });It holds for every test after it in the same process, until resetToDefaults() puts the default
back. A test that changes it for itself puts it back in finally.
settle
function settle(): Promise<void>;One task: long enough for zoneless change detection to run and commit whatever is pending. A change
the engine sees outside change detection, such as a modal's dismissal with no (dismiss) listener,
commits on the next frame instead, and settle waits for that frame too. Every interaction above
ends with it. It is exported for a test that changes state some other way, a
signal set on instance for example, and then wants to read the tree.
cleanup
function cleanup(): void;Unmounts everything rendered so far, and destroys every app injectService() created. It runs after
every test by itself when afterEach is a global, which it is in Vitest with globals: true; with
node:test or Vitest's default, call it from an afterEach of your own if a test should not leave
its render mounted. Every render has its own fake, so a test that skips cleanup still cannot see
another test's tree.
Asserting how much re-rendered
The engine counts what it commits in engine.stats, and a test reaches the engine through the
render's injector. createdNodes and clonedNodes are the two a test of re-rendering reads: a
node created is a view native builds, and a node cloned is one whose props or children changed, or
that holds one that did.
import { Engine } from '@ng-native/fabric';
const { componentRef } = await render(Orders);
const { stats } = componentRef.injector.get(Engine);
const before = { ...stats };
await userEvent.press(screen.getByText('Tapped 0'));
expect(stats.createdNodes - before.createdNodes).toBe(0);
expect(stats.clonedNodes - before.clonedNodes).toBeLessThan(10);The counters run from the mount, so a test compares against a copy taken before the interaction.
commits counts commits, and slowCommits those after the mount that took longer than 8ms, a
frame at 120Hz; the timings beside them (firstCommitMs, worstCommitMs, worstRenderMs) are
real time, and belong in a benchmark rather than an assertion. fabric.calls counts the same
operations as native receives them, from the last fabric.reset().
compileCss()
function compileCss(css: string, name?: string, options?: CompileCssOptions): StyleSheet;Compiles a stylesheet as the build compiles an app's, for render()'s globalStyles. name is
what a message about the sheet calls it. A declaration or rule native cannot express throws, so a
test never passes on a sheet that lost a rule; pass onUnsupported to be told of each instead, and
the rest of the sheet is kept. See Testing styling.
memoryDatabase()
function memoryDatabase(): MemoryDatabase;An in-memory SQLite database with the part of expo-sqlite's interface a service uses, on Node's
node:sqlite. Give it to openDatabasesWith from @ng-native/expo/database to test a service
built on database(): see Database.
createFakeFabric()
function createFakeFabric(): FakeFabric;The fake nativeFabricUIManager itself, for a test that mounts with mount() directly rather than
through render(). It records every call instead of drawing, and adds what a test needs:
| Member | What it is |
|---|---|
committed |
The root nodes of the most recent completeRoot. Each FakeFabricNode has viewName, props, children, reactTag, handle and instanceHandle. |
render(options?) |
The committed tree as an indented string of view names and text. { props: true } adds every node's props, after its text where it has text. |
find(viewName) |
The first committed node with that view name. |
emit(node, type, nativeEvent?) |
Calls the registered event handler, as the C++ side does. Everything in Interactions is built on it. |
frames |
measureInWindow's answers, by nativeID and then by view name. A node with no entry is not measured, as on a platform that has not laid it out. |
responderCalls |
Every setIsJSResponder call, in order. |
commands |
Every dispatchCommand, in order: focus, blur, setTextAndSelection and the rest. |
calls |
How many of each node operation the renderer made. fabric.reset() zeroes them. |
It also does what native does that a test would otherwise have to stand in for. A ModalHostView
committed with visible: false reports topDismiss as soon as that commit is done, as iOS does
once the dismissal has animated, so a modal a test closes leaves the tree and (dismiss) fires. One
dismissal gets through per close, so a test that also sends fireEvent(host, 'dismiss') does not
see (dismiss) twice.
@ng-native/testing/vitest
function ngNative(options?: {
inline?: (string | RegExp)[];
libraryStyles?: string[] | false;
}): Plugin;The Vite plugin for Vitest. It compiles every decorated .ts file with @ng-native/metro's AOT
transform and links every partial-compiled package; tells Vitest to process @angular/*,
@ng-native/* and @ng-icons/* itself rather than hand them to Node, plus anything in inline;
adds a setup file that installs the animation globals; and turns off Vitest's injectCjsGlobals.
Every library's own component stylesheets are compiled, so a test draws a library's components with
their styles. libraryStyles narrows that as the Metro preset's option of the same name does: a
list of the only npm packages, or false for none. The @ng-native/* packages' are compiled
whatever it says.
Setup says why each is needed.
@ng-native/testing/register
node --import @ng-native/testing/register --testInstalls the animation globals and registers a Node module hook that does what the Vitest plugin
does, for node:test or any other script run under Node. It also strips types from every .ts
file under node_modules, which Node refuses to. @ng-native/testing/loader is the hook on its
own, for a suite that registers it with module.register() and passes
{ data: { skip: string[] } }: URL fragments whose TypeScript is left to Node untouched.