Migrating from Maestro
This guide is for teams with a Maestro suite who want to move it to Tapsmith. It maps Maestro’s concepts onto Tapsmith’s, ports one flow side by side, and lists what works differently, including what Tapsmith doesn’t do yet.
Written against Maestro CLI 2.11.0 and its documentation as of October 2026. Maestro changes quickly; check its docs for the version you run.
The big differences
Section titled “The big differences”- YAML flows become TypeScript tests. A Maestro flow is a list of commands in YAML, with JavaScript for the parts YAML can’t express. A Tapsmith test is a TypeScript function, so loops, conditions, helpers, shared screen objects and type checking come from the language rather than from extra commands.
- The test runner is built in. Tapsmith has Playwright’s runner shape:
test(),describe(), hooks, fixtures, retries, projects, reporters, workers and sharding, configured in onetapsmith.config.ts. - Waiting is per action and per assertion. Maestro retries element lookups for a short window and waits for the screen to settle. In Tapsmith every action waits for its element to appear (a tap also waits for it to be enabled and not covered), and every
expect()on an element retries until it passes, both up to thetimeoutin your config (30 s by default). - The app is reset for you. Maestro flows usually start with
launchApp: { clearState: true }. Tapsmith resets the app before each test file by default, and can reset in-process, in about a second, for React Native apps.
Concept mapping
Section titled “Concept mapping”| Maestro | Tapsmith |
|---|---|
Flow file (login.yaml) |
Test file (login.tapsmith.ts) containing one or more test() calls |
appId: in the flow header |
package in tapsmith.config.ts (one config for the whole suite) |
tapOn: "Sign in" |
device.getByText("Sign in").tap(), or better device.getByRole("button", { name: "Sign in" }).tap() |
tapOn: { id: "email-input" } |
device.getByTestId("email-input").tap() |
tapOn: { point: "50%,50%" } |
device.tapXY(x, y) (in screen coordinates, not percentages: pixels on Android, points on iOS) |
doubleTapOn / longPressOn |
.doubleTap() / .longPress() |
inputText (into the focused field) |
locator.type(text) on the field itself (for an empty field; into a field that already has text, type() replaces it on Android and adds to it on iOS), or device.inputText(text) for the focused field |
eraseText |
.clear() or .clearAndType(text) |
assertVisible / assertNotVisible |
await expect(locator).toBeVisible() / .toBeHidden() |
extendedWaitUntil |
expect(locator).toBeVisible({ timeout: 60_000 }), or locator.waitFor() |
assertTrue |
expect(value) with any generic matcher, or expect.poll() for a value that changes |
scroll / swipe |
device.swipe("up"), or locator.scroll() on a scroll view |
scrollUntilVisible |
locator.scrollIntoView() |
hideKeyboard |
device.hideKeyboard() |
back / pressKey |
device.pressBack() (Android) / device.pressKey(key) |
openLink |
device.openDeepLink(url) |
launchApp |
Automatic before each test file; device.launchApp(pkg) when you need it mid-test |
launchApp: { clearState: true } / clearState |
The default appReset policy; device.resetApp({ mode: "clear" }) mid-test |
stopApp / killApp |
device.terminateApp(); add device.clearAppData(pkg) to clear its data too |
runFlow: login.yaml |
A function or a screen object you import and call; a custom fixture for setup with teardown |
runFlow with when: |
An if statement, with locator.isVisible() or locator.exists() as the condition |
repeat / retry |
A for loop / the retries option for whole tests |
env: and ${VAR} |
process.env.VAR in tests and config |
onFlowStart / onFlowComplete |
beforeEach / afterEach (or beforeAll / afterAll) |
tags: with --include-tags |
A tag in the test title ("checkout @smoke") with --grep @smoke |
takeScreenshot |
device.takeScreenshot(); failures are captured automatically (screenshot: "only-on-failure") |
startRecording / stopRecording |
The video option, or a trace, which records every action with screenshots, the view hierarchy and network traffic |
setOrientation / setClipboard |
device.setOrientation() / device.setClipboard() |
setPermissions |
device.grantPermission() / revokePermission() (Android, and iOS simulators) |
| Maestro Studio | UI mode (npx tapsmith test --ui): a live device mirror, a locator picker, and your tests in one window |
maestro hierarchy |
The Hierarchy tab of the trace viewer or UI mode, or the tapsmith_snapshot MCP tool |
maestro mcp |
npx tapsmith mcp-server |
Porting a flow
Section titled “Porting a flow”Here is a sign-in flow for the React Native test app in the Tapsmith repository, first in Maestro:
appId: dev.tapsmith.testapp---- launchApp: clearState: true- openLink: tapsmithtest:///login- tapOn: id: "email-input"- inputText: "test@example.com"- tapOn: id: "password-input"- inputText: "password123"- hideKeyboard- tapOn: "Sign in"- assertVisible: "Login successful!"appId: dev.tapsmith.testapp---- launchApp: clearState: true- openLink: tapsmithtest:///login- tapOn: id: "email-input"- inputText: "test@example.com"- tapOn: id: "password-input"- inputText: "wrong"- hideKeyboard- tapOn: "Sign in"- assertVisible: "Invalid credentials"And the same two flows as one Tapsmith test file:
import { test, describe, expect } from "tapsmith";
describe("Login", () => { // Maestro's `clearState: true` before every flow: 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.getByRole("textfield", { name: "Email" }).type("test@example.com"); await device.getByRole("textfield", { name: "Password" }).type("password123"); await device.hideKeyboard(); await device.getByRole("button", { name: "Sign in" }).tap(); await expect(device.getByText("Login successful!", { exact: true })).toBeVisible(); });
test("rejects a wrong password", async ({ device }) => { await device.getByRole("textfield", { name: "Email" }).type("test@example.com"); await device.getByRole("textfield", { name: "Password" }).type("wrong"); await device.hideKeyboard(); await device.getByRole("button", { name: "Sign in" }).tap(); await expect(device.getByText("Invalid credentials", { exact: true })).toBeVisible(); });});import { defineConfig } from "tapsmith";
export default defineConfig({ testMatch: ["**/*.tapsmith.ts"], apk: "./android/app/build/outputs/apk/release/app-release.apk", package: "dev.tapsmith.testapp",});What changed, line by line:
appIdmoved into the config aspackage, along with the build to install (apk, or for iOSplatform: "ios",appand thesimulatorto boot). Tapsmith installs the build and launches the app before the first test of each file; there is nolaunchAppstep.launchApp: { clearState: true }becametest.use({ appResetScope: "test" }). Without that line, Tapsmith resets the app once per test file rather than before every test (a clear, or a warm reset when the app mounts@tapsmith/react-native), which is faster and is often what a suite of separate flows really needs. See Test isolation.tapOnfollowed byinputTextbecame one call:type()taps the field and types into it. The fields are found by role and accessible name (accessibilityLabelin React Native) rather than bytestID.getByTestId("email-input")also works; Tapsmith recommends user-visible locators first, as Playwright does (Locators).tapOn: "Sign in"becamegetByRole("button", { name: "Sign in" }). Maestro treatstextas a regular expression;getByTextmatches a substring unless you pass{ exact: true }, andgetByRole’snameis a case-insensitive substring. If a locator matches more than one element, Tapsmith throws a strict mode error listing them instead of tapping the first.assertVisiblebecameawait expect(...).toBeVisible(), which retries until the text appears or the timeout passes.
Run it with:
npx tapsmith testSetup and configuration
Section titled “Setup and configuration”| Maestro | Tapsmith |
|---|---|
| Install the Maestro CLI | npm install -D tapsmith in your project, then npx tapsmith init |
appId in every flow |
package once in tapsmith.config.ts; per platform in projects |
You install the app (or maestro cloud --app-file) |
apk / app in the config: Tapsmith installs it |
maestro test flows/ |
npx tapsmith test (files from testMatch), or npx tapsmith test tests/login.tapsmith.ts |
Workspace config.yaml (flow order, tags) |
tapsmith.config.ts: testMatch, projects, retries, timeout, reporter |
-e KEY=VALUE, MAESTRO_* shell variables |
Ordinary environment variables, read with process.env (load a .env file with dotenv in your config if you like) |
--device <id> |
npx tapsmith test --device emulator-5554, or device / simulator in the config |
--shard-split N (one machine, N devices) |
workers: N or --workers N, with launchEmulators: true and an avd to start emulators for you (Parallel execution) |
| Splitting across CI machines | npx tapsmith test --shard=1/4 and npx tapsmith merge-reports (CI sharding) |
--format junit |
reporter: "junit", with html, json, github and blob also built in (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 Jest or Vitest in the same project never picks them up as unit tests (why).
What works differently
Section titled “What works differently”Waiting. Tapsmith has no waitForAnimationToEnd or fixed waits to port. An 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) before tapping. An assertion keeps re-checking until it passes. Both give up at the timeout from your config (30 s by default), or the { timeout } you pass. If you have extendedWaitUntil with a long timeout, pass the same timeout to the assertion.
Resetting the app. The appReset policy replaces clearState and clearKeychain (on an iOS simulator, a clear also wipes the simulator’s keychain). By default the app is cleared once per test file, so the tests in one file share the app: the second test starts where the first left off. Add test.use({ appResetScope: "test" }) to a file or describe to reset before every test, as the example does. React Native and Expo apps that mount @tapsmith/react-native reset in-process in about a second instead of being cleared and relaunched (a release build needs the hooks switched on at build time, as Warm app reset explains). To start tests already signed in, sign in once in a setup project and restore the saved state (Authentication patterns), rather than running a login subflow at the top of every test.
Locators. Maestro treats text and id as regular expressions, and relational selectors (below, childOf, …) narrow them. In Tapsmith:
getByText("Sign in")is a substring match;{ exact: true }matches the whole text; aRegExp(getByText(/^Sign in$/i)) works as in JavaScript.getByRole(role, { name })checks what the element is as well as its label, which also makes the app more accessible (Making React Native apps testable).- Instead of relational selectors, scope a locator inside another (
device.getByTestId("row-5").getByRole("button", { name: "Delete" })), or narrow it withfilter(),first(),nth(),and()andor()(ElementHandle). Position on screen (below,leftOf) has no locator equivalent. - Ambiguity is an error. When a locator matches several elements, Tapsmith’s strict mode refuses to guess: it reports every match and suggests a unique locator for each.
Conditional steps. runFlow with when: visible: becomes an if. Use isVisible() or exists(), which answer without waiting out the timeout (an absent element costs a second read once the screen settles, usually a second or two):
const notNow = device.getByRole("button", { name: "Not now" });if (await notNow.isVisible()) { await notNow.tap();}Debugging. A failed test leaves a screenshot by default. Turn on traces (trace: "retain-on-failure" in the config) to get a step-by-step record of each failed test with a screenshot, the view hierarchy and the network traffic for every action, opened with npx tapsmith show-trace. For writing tests interactively, UI mode and watch mode keep the device session (daemon, agent and installed app) up between runs, so a re-run only resets the app.
Not supported yet
Section titled “Not supported yet”Some Maestro commands have no Tapsmith equivalent at the moment. Plan to keep these flows in Maestro, or find another way to cover them:
- Device state:
setLocation,travel(moving the device’s location along a route),setAirplaneMode/toggleAirplaneModeandaddMedia. There are no Tapsmith methods for these. - 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. - Launch arguments:
launchApp’sargumentshave no counterpart;device.launchApp()takes an Androidactivity, not arguments. - AI and visual commands:
assertWithAI,assertNoDefectsWithAI,extractTextWithAIandassertScreenshot(screenshot comparison). - Web testing: Maestro can drive a desktop browser. Tapsmith tests native mobile apps; content inside your app’s WebViews is covered by WebView testing.
- A hosted device cloud: Tapsmith runs on emulators, simulators and devices you provide, locally or in your own CI.
Migrating incrementally
Section titled “Migrating incrementally”You don’t have to port everything at once. Both tools drive the same builds, so they can live in one repository:
- Start with the flows that hurt most: the flakiest, the slowest, or the ones that most need logic YAML can’t express.
- Keep the two suites apart. Put Tapsmith tests in their own folder (
tests/), next to your Maestroflows/(or.maestro/), and give Tapsmith its own CI job. Don’t run both against the same device at the same time: two tools driving one device will interfere with each other. - Port shared subflows first. A
runFlow: login.yamlthat many flows call becomes one screen object or fixture, which every ported test then reuses. - Retire each flow once its Tapsmith test has been green in CI for a while, and delete the Maestro job when the last one goes.
- Revisit isolation once the suite is ported. Ported flows often reset before every test; per-file resets, or the warm reset hooks in a React Native app, usually make the suite much faster.
See also: Getting started, Writing tests, Locators, API reference.

