Migrating from Detox
This guide is for teams with a Detox suite who want to move it to Tapsmith. Of the mobile testing tools, Detox is the closest to Tapsmith: both are driven from JavaScript or TypeScript, both use describe and expect, and both were built with React Native in mind. The guide maps Detox’s concepts onto Tapsmith’s, ports one test side by side, and lists what works differently, including what Tapsmith doesn’t do yet.
Written against Detox 20.51 and its documentation as of October 2026. Check Detox’s docs for the version you run.
The big differences
Section titled “The big differences”- No native code in your app. Detox works from inside your app: on Android through Gradle changes, a
DetoxTestinstrumentation class and a separate test APK, on iOS through a library it injects into the app process at launch. Tapsmith drives the app from outside through UIAutomator2 (Android) and XCUITest (iOS), the same way the platform’s own UI tests do. It installs and tests the build you already have, React Native or not. - No Jest. Tapsmith has its own runner, modelled on Playwright’s:
test(),describe(), hooks, fixtures, retries, projects, reporters, workers and sharding, configured intapsmith.config.ts. There is nojest.config.js,testRunnerblock ordetox buildstep. - Auto-waiting replaces synchronization. Detox waits for your app to go idle (no pending network requests, timers, animations or React Native bridge work) before each action. Tapsmith doesn’t watch the app’s internals: it waits for the specific element an action or assertion needs, and retries assertions until they pass. A screen that never goes idle (a looping animation, a polling request) doesn’t block Tapsmith.
- Locators, not matchers.
element(by.id("email-input"))becomesdevice.getByTestId("email-input"), and Tapsmith encourages user-facing locators:device.getByRole("textfield", { name: "Email" }).
Concept mapping
Section titled “Concept mapping”Test structure
Section titled “Test structure”| Detox (Jest) | Tapsmith |
|---|---|
describe / it |
describe / test, imported from tapsmith |
Globals device, element, by, expect, waitFor |
The device fixture passed to each test (async ({ device }) => …), and expect imported from tapsmith |
beforeAll / beforeEach / afterEach / afterAll |
The same names, imported from tapsmith (Hooks); they receive fixtures too |
it.only / it.skip |
test.only / test.skip |
.detoxrc.js (apps, devices, configurations) |
tapsmith.config.ts, with one project per app and device pair |
detox build |
Your own build command; Tapsmith installs the result (apk / app in the config) |
detox test -c ios.sim.release |
npx tapsmith test --project ios |
detox test --retries 2 |
retries: 2 in the config (retries the failed test, not the whole file) |
--take-screenshots failing |
screenshot: "only-on-failure" (the default) |
--record-videos failing |
video: "retain-on-failure" (Video recording) |
--record-logs failing |
trace: "retain-on-failure": a trace with a screenshot, the view hierarchy and the network traffic for every action |
Jest’s --maxWorkers |
workers / --workers N |
Matchers to locators
Section titled “Matchers to locators”| Detox | Tapsmith |
|---|---|
element(by.id("submit")) |
device.getByTestId("submit") |
element(by.text("Sign in")) |
device.getByText("Sign in", { exact: true }) (without exact, a substring match) |
element(by.label("Close")) |
device.getByDescription("Close"), or getByRole(role, { name: "Close" }) |
element(by.traits(["button"])) (iOS) |
device.getByRole("button"), on both platforms |
element(by.type("RCTTextInput")) |
device.locator({ className: … }) (native class names differ per platform) |
by.id("row").withDescendant(by.text("Premium")) |
device.getByTestId("row").filter({ has: device.getByText("Premium", { exact: true }) }) |
by.text("Delete").withAncestor(by.id("row-5")) |
device.getByTestId("row-5").getByText("Delete", { exact: true }) (scoping) |
by.id("a").and(by.text("b")) |
device.getByTestId("a").and(device.getByText("b")) |
.atIndex(2) |
.nth(2), .first(), .last() |
element(…).getAttributes() |
locator.find(), or getText(), isEnabled(), boundingBox(), … |
Actions
Section titled “Actions”| Detox | Tapsmith |
|---|---|
.tap() |
.tap() |
.multiTap(2) |
.doubleTap() |
.longPress(1500) |
.longPress(1500) |
.typeText("hi") |
.type("hi"), into an empty field (into a field that already has text, type() replaces it on Android and adds to it on iOS) |
.replaceText("hi") |
.clearAndType("hi") (types it, so your onChangeText handlers run) |
.clearText() |
.clear() |
.tapReturnKey() |
device.pressKey("ENTER") |
.scroll(200, "down") |
.scroll("down") scrolls the view by a step of its own size (no offset in points); to reach an element, prefer scrollIntoView() |
waitFor(el).toBeVisible().whileElement(by.id("list")).scroll(50, "down") |
el.scrollIntoView() |
.swipe("up") |
device.swipe("up") |
.pinch(0.5) (iOS) |
.pinchIn() / .pinchOut(), on both platforms |
Expectations to assertions
Section titled “Expectations to assertions”| Detox | Tapsmith |
|---|---|
await expect(el).toBeVisible() |
await expect(el).toBeVisible() (Detox’s default is 75% on screen; use toBeInViewport({ ratio: 0.75 }) for a share of the element) |
.not.toBeVisible() / .not.toExist() |
.not.toBeVisible() or .toBeHidden() / .not.toExist() |
.toExist() |
.toExist() |
.toBeFocused() |
.toBeFocused() |
.toHaveText("Hi") |
.toHaveText("Hi") |
.toHaveLabel("Close") |
.toHaveAccessibleName("Close") |
.toHaveToggleValue(true) |
.toBeChecked() |
.toHaveValue("…") (the accessibility value) |
.toHaveValue("…") checks a text field’s contents, not the accessibility value |
waitFor(el).toBeVisible().withTimeout(10000) |
await expect(el).toBeVisible({ timeout: 10_000 }), or el.waitFor({ timeout: 10_000 }) |
The device object
Section titled “The device object”| Detox | Tapsmith |
|---|---|
device.launchApp() |
Automatic before each test file; device.launchApp(pkg) mid-test |
device.launchApp({ newInstance: true }) |
device.restartApp(pkg), or appReset: "restart" |
device.launchApp({ delete: true }) |
The default appReset policy (clear data and relaunch; a warm, in-app reset instead when the app mounts @tapsmith/react-native), or device.resetApp({ mode: "clear" }) |
device.launchApp({ url }) / device.openURL({ url }) |
device.openDeepLink(url) |
device.launchApp({ permissions }) (iOS) |
On a simulator, device.grantPermission(bundleId, "photos") for each service simctl privacy supports (not notifications); some changes make iOS terminate the app, which Tapsmith does not relaunch for you |
device.reloadReactNative() |
A warm reset through @tapsmith/react-native, automatic between files once the app mounts it |
device.terminateApp() |
device.terminateApp() |
device.sendToHome() |
device.sendToBackground() |
device.pressBack() |
device.pressBack() |
device.setOrientation("landscape") |
device.setOrientation("landscape") |
device.takeScreenshot(name) |
device.takeScreenshot() |
device.getPlatform() |
device.platform, or the platform fixture |
device.disableSynchronization() |
Not needed: there is no app-wide synchronization to turn off |
Porting a test
Section titled “Porting a test”The sign-in screen of the React Native test app in the Tapsmith repository, tested with Detox:
describe("Login", () => { beforeEach(async () => { await device.launchApp({ newInstance: true, delete: true, url: "tapsmithtest:///login" }); });
it("signs in with valid credentials", async () => { await element(by.id("email-input")).typeText("test@example.com"); await element(by.id("password-input")).typeText("password123"); await element(by.id("password-input")).tapReturnKey(); await element(by.label("Sign in")).tap(); await expect(element(by.id("success-message"))).toBeVisible(); });
it("rejects a wrong password", async () => { await element(by.id("email-input")).typeText("test@example.com"); await element(by.id("password-input")).typeText("wrong"); await element(by.id("password-input")).tapReturnKey(); await element(by.label("Sign in")).tap(); await expect(element(by.text("Invalid credentials"))).toBeVisible(); });});The same tests in Tapsmith:
import { test, describe, expect } from "tapsmith";
describe("Login", () => { // Detox's `launchApp({ delete: true })` before every test: a fresh app before each test. test.use({ appResetScope: "test" });
test.beforeEach(async ({ device }) => { await device.openDeepLink("tapsmithtest:///login"); });
test("signs in with valid credentials", async ({ device }) => { await device.getByTestId("email-input").type("test@example.com"); await device.getByTestId("password-input").type("password123"); await device.hideKeyboard(); await device.getByRole("button", { name: "Sign in" }).tap(); await expect(device.getByTestId("success-message")).toBeVisible(); });
test("rejects a wrong password", async ({ device }) => { await device.getByTestId("email-input").type("test@example.com"); await device.getByTestId("password-input").type("wrong"); await device.hideKeyboard(); await device.getByRole("button", { name: "Sign in" }).tap(); await expect(device.getByText("Invalid credentials", { exact: true })).toBeVisible(); });});The .detoxrc.js that pointed Detox at the builds becomes a tapsmith.config.ts with a project per platform:
// .detoxrc.js (abridged)module.exports = { testRunner: { args: { $0: "jest", config: "e2e/jest.config.js" } }, apps: { "android.release": { type: "android.apk", binaryPath: "android/app/build/outputs/apk/release/app-release.apk", build: "cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release", }, "ios.release": { type: "ios.app", binaryPath: "ios/build/Build/Products/Release-iphonesimulator/TapsmithTestApp.app", build: "xcodebuild -workspace ios/TapsmithTestApp.xcworkspace -scheme TapsmithTestApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build", }, }, devices: { emulator: { type: "android.emulator", device: { avdName: "Pixel_7_API_35" } }, simulator: { type: "ios.simulator", device: { type: "iPhone 16" } }, }, configurations: { "android.emu.release": { device: "emulator", app: "android.release" }, "ios.sim.release": { device: "simulator", app: "ios.release" }, },};import { defineConfig } from "tapsmith";
export default defineConfig({ testMatch: ["**/*.tapsmith.ts"], package: "dev.tapsmith.testapp", projects: [ { name: "android", use: { platform: "android", apk: "./android/app/build/outputs/apk/release/app-release.apk", avd: "Pixel_7_API_35", launchEmulators: true, }, }, { name: "ios", use: { platform: "ios", app: "./ios/build/Build/Products/Release-iphonesimulator/TapsmithTestApp.app", simulator: "iPhone 16", }, }, ],});What changed:
- Globals became fixtures and imports.
devicearrives as an argument to each test and hook, andtest,describeandexpectare imported fromtapsmith. Everything is typed. - The reset moved into configuration. Instead of a
launchAppinbeforeEach,test.use({ appResetScope: "test" })asks for a fresh app before every test. Without it, Tapsmith resets once per test file. The deep link that opens the screen stays inbeforeEach. - Matchers became locators.
by.idmaps straight ontogetByTestId, which matches a React NativetestIDon both platforms. The button is found by role and accessible name. Where a locator matches several elements, Tapsmith’s strict mode throws and lists the matches instead of picking one, so use.nth()or a narrower locator where Detox needed.atIndex(). tapReturnKey()becamedevice.hideKeyboard(), which dismisses the keyboard without submitting the field where it can (on iOS its last resort, when nothing else puts the keyboard away, is the return key of a single-line field, which does submit it).device.pressKey("ENTER")is still there if you want to submit.- The build is yours.
detox buildran thebuildcommand from.detoxrc.js; with Tapsmith you run that command yourself (in CI, as a step beforenpx tapsmith test), and you build your normal app. TheassembleAndroidTestAPK and the Detox Gradle andDetoxTestsetup are no longer needed.
Run one platform with npx tapsmith test --project android, or both with npx tapsmith test.
Setup and configuration
Section titled “Setup and configuration”| Detox | Tapsmith |
|---|---|
npm install -D detox jest, detox init, native Android setup |
npm install -D tapsmith, then npx tapsmith init |
applesimutils (iOS) |
Nothing extra: Xcode’s simctl is enough (it grants fewer permissions than applesimutils; see below). npx tapsmith doctor checks the setup |
devices.*.device.avdName |
avd (Tapsmith launches it when it isn’t running) |
devices.*.device.type (simulator) |
simulator |
apps.*.binaryPath |
apk (Android) or app with platform: "ios" |
configurations.* |
projects, selected with --project |
detox test --headless |
Emulators Tapsmith launches run headless in CI automatically, and locally with emulatorLaunchOptions: { headless: true } (How Tapsmith launches emulators) |
--reuse |
The default: Tapsmith installs the app when the build changed |
detox test e2e/login.test.js |
npx tapsmith test tests/login.tapsmith.ts |
--retries |
retries in the config, test.use() or test.describe.configure() |
Jest’s --maxWorkers 3 |
workers: 3 or --workers 3, one device per worker (Parallel execution) |
Jest’s --shard |
npx tapsmith test --shard=1/4 and npx tapsmith merge-reports (CI sharding) |
Jest reporters (jest-junit, …) |
Built-in junit, html, json, github and blob reporters |
For CI, the CI setup guide has complete GitHub Actions workflows for Android and iOS.
Name Tapsmith test files *.tapsmith.ts (what tapsmith init sets up), so that your Jest unit tests never pick them up (why).
What works differently
Section titled “What works differently”Waiting. Detox’s synchronization means most Detox tests have no explicit waits, and you only reach for waitFor() when synchronization can’t see the work (or has been turned off). Tapsmith gets the same effect differently: each action waits for its element to appear, and a tap also waits for it to be enabled and not covered by something else (the keyboard, a sheet, another view); each expect() on an element keeps checking until it passes. Both wait up to the timeout in your config (30 s by default). Some consequences:
- You can delete
device.disableSynchronization(),setURLBlacklist()and the waits around screens with endless animations or long-polling: Tapsmith doesn’t wait for the app to go idle, so those screens are not a problem. waitFor(...).withTimeout(ms)becomes the assertion with{ timeout: ms }.- An assertion that something is absent (
not.toBeVisible(),toBeHidden()) waits for it to go away, rather than checking once.
Resetting the app. Detox suites reset with device.launchApp({ delete: true }) (reinstall), newInstance: true (relaunch), or device.reloadReactNative() (reload the JavaScript, keeping the process and its storage). In Tapsmith the reset is a declared policy, appReset, rather than a call in a hook:
- By default the app’s data is cleared and the app relaunched once per test file. The tests in a file share the app, so add
test.use({ appResetScope: "test" })where each test needs a fresh one. - React Native apps that mount
@tapsmith/react-nativeget a warm reset: the hooks clear the stores you list (AsyncStorage, for example), navigate to a route and confirm the reset finished, in-process, in about a second. UnlikereloadReactNative(), it clears persisted state. A release build needs the hooks switched on at build time (Release builds for e2e); without that, every reset is a clear and relaunch. - To start tests already signed in, sign in once in a setup project and restore the saved app state (Authentication patterns).
Mocking. Detox suites often mock modules at build time (an .e2e.js file extension picked up by Metro) or block URLs. Tapsmith doesn’t change your bundle. To mock or inspect HTTP traffic, use device.route(), Playwright’s network interception for the device (it needs tracing with network capture turned on). Build-time module mocks still work if you keep building with them; Tapsmith tests whatever build you give it.
Locators. Detox’s by.id and by.text keep working as getByTestId and getByText, so a first port can be mechanical. Over time, prefer getByRole with a name: it checks what the element is as well as its label, and pushes the app towards being accessible (Locators). Two details to watch for: getByText("Sign in") without { exact: true } matches any text containing “Sign in”, and an ambiguous locator throws rather than acting on the first match.
Debugging. Detox’s artifacts (screenshots, videos, logs) map to Tapsmith’s screenshot, video and trace options. A trace records every action with a screenshot, the view hierarchy and the network traffic, and opens with npx tapsmith show-trace. For writing tests, UI mode (npx tapsmith test --ui) shows a live, clickable device mirror with a locator picker, and watch mode re-runs a file on save on a device session that stays up: only the app reset runs again, not the daemon, agent and install.
Not supported yet
Section titled “Not supported yet”Some Detox device APIs have no Tapsmith equivalent at the moment:
- Device simulation:
setLocation,setBiometricEnrollment/matchFace/matchFinger,shake,setStatusBar, andsendUserNotification. - Launch configuration:
launchArgsandlanguageAndLocaleonlaunchApp. Tapsmith launches the app without extra arguments. - Some iOS permissions: on an iOS simulator,
grantPermission()andrevokePermission()cover the servicesxcrun simctl privacysupports (photos, location, contacts, calendar, microphone, camera, …), which leaves out notifications, among others. On physical iOS devices they are not supported at all. - Jest features:
jest.fn()mocks, snapshot matchers and Jest’s custom matchers aren’t part of Tapsmith’sexpect, and Jest’s watch mode is replaced by Tapsmith’s. - Some actions:
scroll()by an offset in points,scrollTo("bottom")(scroll to an edge; usescrollIntoView()on the element you want, orscroll()repeatedly) and setting picker wheel columns (setColumnToValue).
Migrating incrementally
Section titled “Migrating incrementally”Detox and Tapsmith can test the same app from the same repository while you move over:
- Keep the two suites apart. Leave Detox in
e2e/with its Jest config, and put Tapsmith tests in their own folder (tests/) with names ending in.tapsmith.ts, so neither runner picks up the other’s files. Run them as separate CI jobs, and never against the same device at once. - Build once, test twice. A release build of your app works for both. Tapsmith installs the same app build Detox tests and ignores Detox’s Android test APK, so one build can serve both suites until Detox is gone.
- Port the flakiest tests first, especially the ones that fight synchronization (
disableSynchronization(), longwaitFortimeouts). They tend to get simpler. - Port helpers before tests. A Detox helper module (
loginAs(user)) becomes a screen object or a custom fixture that every ported test reuses. - Retire Detox when the last test is ported. Remove the Detox Gradle setup, the
DetoxTestclass,.detoxrc.jsand thejestconfig for e2e, and stop building the Android test APK.
See also: Getting started, Writing tests, Warm app reset, API reference.

