Skip to main content

Zero-dependency E2E testing
for React Native

Connects to your app via the Hermes CDP bridge. Walks the React fiber tree. Triggers interactions directly - no Appium, no coordinate math, no YAML.

npm install --save-dev stowaway
Get Started →

How it works

1

Connect

Attaches to your running app via the Hermes CDP bridge that Metro already exposes. No simulator plugins, no separate proxy, no Appium server to spin up.

2

Find

Traverses the live React fiber tree to locate elements by testID, component name, text content, or accessibility attributes - the same tree React DevTools reads.

3

Interact

Calls React prop handlers directly - onPress, onChangeText, onValueChange. No native event dispatch, no coordinate math, no gesture simulation.

Features

Zero dependencies

No Appium, no WebDriver, no native test servers. The only runtime requirement is Node and a Hermes-powered Metro bundle.

iOS + Android

Works with iOS Simulators and Android emulators or physical devices. One test file, two platforms - switch with PLATFORM=android.

Full TypeScript

All APIs are typed end-to-end. Query results, element methods, config, and assertions - no any, no casting gymnastics.

Works with any Hermes app

Hermes has been the default React Native engine since RN 0.70. If your app runs on Hermes, Stowaway works out of the box.

One command to onboard

npx stowaway init scaffolds the entry point, a smoke test, and npm scripts - from zero to first run in under a minute.

JUnit XML + JSON output

Results land in test-results/ as JUnit XML and JSON. Plug straight into GitHub Actions, Bitrise, or Jenkins with no extra steps.

What a test looks like

Clean TypeScript. No config files. No driver boilerplate.

import { describe, it, expect } from 'stowaway';
import type { AppSession } from 'stowaway';

describe('Login', () => {
it('signs in and reaches the home screen', async (app: AppSession) => {
await (await app.find({ testID: 'input-email' })).typeText('user@example.com');
await (await app.find({ testID: 'input-password' })).typeText('secret');
await (await app.find({ testID: 'btn-sign-in' })).tap();

const welcome = await app.waitForElement('home-title');
expect(await welcome.text()).toBe('Welcome back');
});

it('shows an error on bad credentials', async (app: AppSession) => {
await app.mockNetwork(
{ method: 'POST', url: /\/api\/login/ },
{ status: 401, body: { error: 'Invalid credentials' } },
);

await (await app.find({ testID: 'btn-sign-in' })).tap();
const banner = await app.waitForElement('error-banner');
expect(await banner.text()).toContain('Invalid credentials');
});
});