Skip to content

Writing Tests

This guide covers the patterns and practices for writing reliable, maintainable Tapsmith tests. If you are new to Tapsmith, start with Getting Started first, then return here.

A Tapsmith test file imports from tapsmith and uses the test() function to define tests. Each test receives fixtures – most commonly device, which is your handle to the mobile device under test.

import { test, expect } from "tapsmith"
test("home screen shows welcome message", async ({ device }) => {
await expect(device.getByText("Welcome")).toBeVisible()
})

Use describe() (or Playwright’s spelling, test.describe()) to group related tests. Groups share hooks and make test output easier to scan:

import { test, describe, expect, beforeAll } from "tapsmith"
describe("Login screen", () => {
beforeAll(async ({ device }) => {
await device.getByDescription("Login Form").tap()
})
test("shows email and password fields", async ({ device }) => {
await expect(device.getByRole("textfield", { name: "Email" })).toBeVisible()
await expect(device.getByRole("textfield", { name: "Password" })).toBeVisible()
})
test("sign in button starts disabled", async ({ device }) => {
await expect(device.getByRole("button", { name: "Sign in" })).toBeDisabled()
})
})

Every import you need comes from the tapsmith package: test, describe, expect, beforeAll, afterAll, beforeEach, afterEach, flushSoftErrors, Device, and the config utilities.

A file that fails to load — an import of a module that doesn’t exist, or an error thrown at the top level or inside a describe() callback — runs none of its tests. It is reported as one failed result, <file name> — failed to load, with the error and a code frame pointing at the line. tapsmith test loads every file before it touches a device, so it reports such a file at once and runs nothing until it is fixed; UI mode and watch mode run the rest and pick the file up again once you fix it.


The screen object pattern is the recommended way to abstract your locators. It is the mobile equivalent of Playwright’s page object model. A screen class wraps the locators for a single screen of your app, keeping locators out of test logic and making changes to your UI a one-line fix.

Create a file alongside your tests (or in a screens/ directory):

screens/login.screen.ts
import { Device } from "tapsmith"
export class LoginScreen {
constructor(private device: Device) {}
get heading() { return this.device.getByText("Sign In", { exact: true }) }
get emailField() { return this.device.getByRole("textfield", { name: "Email" }) }
get passwordField() { return this.device.getByRole("textfield", { name: "Password" }) }
get signInButton() { return this.device.getByRole("button", { name: "Sign in" }) }
get forgotPasswordLink() { return this.device.getByDescription("Forgot password") }
async login(email: string, password: string) {
await this.emailField.clearAndType(email)
await this.passwordField.clearAndType(password)
// The keyboard is still up: tap() puts it away first on a React Native
// ScrollView. Without one, hideKeyboard() first if it covers the button.
await this.signInButton.tap()
}
}
import { test, describe, expect, beforeAll } from "tapsmith"
import { LoginScreen } from "../screens/login.screen.js"
describe("Login screen", () => {
beforeAll(async ({ device }) => {
await device.getByDescription("Login Form").tap()
})
test("shows the sign in heading", async ({ device }) => {
const login = new LoginScreen(device)
await expect(login.heading).toBeVisible()
})
test("can type into email field", async ({ device }) => {
const login = new LoginScreen(device)
await login.emailField.type("test@example.com")
await expect(login.emailField).toHaveValue("test@example.com")
})
test("successful login flow", async ({ device }) => {
const login = new LoginScreen(device)
await login.login("test@example.com", "password123")
await expect(device.getByText("Login successful!")).toBeVisible()
})
})
  • One class per screen. If a screen has clearly distinct sections (e.g. a header and a scrollable list), expose them as getter groups within the same class.
  • Use getters, not constructor assignments. Getters like get emailField() create a fresh locator on every access, which is the correct behavior – locators are lazy references that resolve at action time.
  • Encapsulate multi-step flows. Wrap common sequences (login, add to cart, navigate to settings) as methods on the screen class. Tests read better when they describe intent (login.login(email, password)) rather than mechanism.
  • Keep assertions in tests, not screen objects. Screen objects provide locators and actions. Tests decide what to assert.

Tapsmith resets the app for you, but by default only once per test file, not before every test. The tests in a file share the app: the second test starts on whatever screen the first one left, with any state it created, such as a signed-in user. The exception is a failure: the test after a failed one starts from a fresh app (A failure starts the rest of the file fresh). This differs from Playwright, where every test gets a fresh browser context. A file whose tests each need a fresh app opts in with test.use({ appResetScope: "test" }) (Declaring the reset policy below).

So with the default, each test should bring the app to the state it needs itself (a deep link, say), rather than assume a fresh app or rely on where the previous test left off. A test that assumes a fresh app passes on its own and fails after another test that passed. A test that relies on the previous one fails whenever it runs without it: a --grep or a single test run from UI mode starts it from a reset app. With the default policy, each file does start from a reset app, as long as the config sets package (as tapsmith init does).

The reset is declared, not written by hand: it runs as fixture setup, appears in the trace in its own APP RESET section just before BEFORE ALL (per-file) or BEFORE EACH (per-test) — so the hook sections contain only your code — and its time counts toward the test’s duration so slow isolation is visible rather than hidden.

These options control it, at any level — config, projects[].use, or test.use():

Option Values Default
appReset 'auto' | 'clear' | 'restart' | 'warm' | 'none' 'auto'
appResetScope 'auto' | 'file' | 'test' 'auto'
appResetRoute a path ('/settings') or a full deep link ('myapp:///settings') the app’s launch route
Mode What happens Typical cost
clear Wipe app data and cold-launch — fully hermetic 7–13 s on iOS simulators, 1–3 s on Android emulators
restart Terminate and relaunch, keeping persisted data (AsyncStorage, databases) 1–3 s
warm In-app reset through detected @tapsmith/react-native hooks (or the legacy resetAppDeepLink); no process restart < 1 s
none No reset — only verify the session is healthy ~0

'auto' picks the fastest hermetic option the app supports: warm when in-app hooks are detected (or resetAppDeepLink is set), otherwise clear. A declared warm policy that cannot run warm (no hook, or the delivery fails) falls back to a clear — the policy’s promise is state-clearing, and a restart would keep persisted data. Only the explicit device.resetApp() API keeps the gentler warm → restart → clear ladder. The scope is per-file — one reset at file entry (and at a nested describe that declares a different policy). Even a warm reset costs ~1-2 s, so resetting before every test roughly doubles a suite that navigates per test anyway; scopes that genuinely need it opt in with appResetScope: "test" (each of those resets is warm when the app mounts the hooks). With no configuration at all you get a clean app once per file, and nothing to write.

// tapsmith.config.ts — restart between files instead of wiping data
export default defineConfig({
apk: "./app-debug.apk",
package: "com.example.myapp",
appReset: "restart",
})
// a spec that needs a truly fresh app before every test
test.use({ appReset: "clear", appResetScope: "test" })
test("first launch shows onboarding", async ({ device }) => { /* … */ })
test("skipping onboarding lands on home", async ({ device }) => { /* … */ })

When every test in a scope starts on the same screen, declare it with appResetRoute instead of opening it from a beforeEach. With @tapsmith/react-native, the warm reset’s own deep link carries the route, so the app is reset and on the screen in one delivery rather than two:

describe("settings", () => {
test.use({ appResetScope: "test", appResetRoute: "/settings" })
test("dark mode starts off", async ({ device }) => { /* already on /settings */ })
})

A path is resolved against the URL prefix the hooks publish (the urlPrefix/scheme you gave <TapsmithTestHooks />). An app without the hooks gives the full deep link instead (appResetRoute: "myapp:///settings"); a path there fails the reset with a message saying so. Whenever the reset itself cannot land on the route — a clear, restart or restored appState, a warm reset that fell back, or a device that was already prepared — Tapsmith opens the route as a deep link right after it, so the test starts on the screen either way. A warm reset that lands on the screen the previous test left open keeps that screen mounted, so the screen’s own React state (counters, form fields, scroll offset) is only fresh if the screen resets it when the reset epoch changes; see What a warm reset does not clear. In the trace’s APP RESET section, a warm reset that landed on the route says so (warm reset via @tapsmith/react-native (epoch 4→5) → /settings); any other reset is followed by an openDeepLink row for the route. The route is opened where the reset runs: with the default file scope that is once per file, so pair it with appResetScope: "test" when every test must start on the screen. appResetRoute has no effect with appReset: "none", since no reset runs to land anywhere.

// tests that deliberately share state across the file
describe("multi-step wizard", () => {
test.use({ appReset: "none" })
// …
})

A beforeEach that only calls device.restartApp() or device.clearAppData() + device.launchApp() is the same thing written by hand — the tapsmith/prefer-app-reset-option lint rule points you at the option instead. The manual methods remain available for resets in the middle of a test.

For tests that need a specific starting state (e.g. logged-in), restore a previously saved snapshot. A restore replaces the reset for that scope — Tapsmith restores the archive and relaunches, and never wipes app data first (on Android that would destroy the keystore keys the saved credentials depend on):

describe("authenticated tests", () => {
test.use({ appState: "./tapsmith-results/auth-state.tar.gz" })
test("profile screen is accessible", async ({ device }) => {
await device.openDeepLink("myapp:///profile")
await expect(device.getByText("Profile")).toBeVisible()
})
})

To opt out of restored state in a nested scope, pass an empty string — it means “clear”:

describe("without auth", () => {
test.use({ appState: "" })
test("profile redirects to login", async ({ device }) => {
await device.openDeepLink("myapp:///profile")
await expect(device.getByText("Sign In")).toBeVisible()
})
})

With retries set, a failed test is retried from the state its first attempt started from, not from the screen the failure left behind. Playwright gets this by retrying in a fresh worker; Tapsmith does the same with the app:

  • Per-file scope (the default). Before the retry, Tapsmith runs the afterAll hooks of every describe around the test (innermost first), then each scope’s reset again (a real reset, never one satisfied by the startup launch, and cold-launched on iOS simulators) and its beforeAll hooks (outermost first). A beforeAll that opens a screen with device.openDeepLink() therefore runs again before the retry. If the reset or a beforeAll hook fails, that attempt fails with its error, and the next test runs the reset and beforeAll hooks again before it starts. An afterAll failure is logged, as it is at the end of a scope.
  • Per-test scope. The test’s own reset runs before every attempt (cold-launched on a retry on iOS simulators).
  • Exceptions. A test whose own scope has appReset: "none", and a test in a serial group, retry in place, without a reset or a replay of the setup in their trace. A serial group’s later tests depend on the state its earlier tests built, which a reset would wipe.

A retry’s trace shows only what ran before that attempt: under per-file scope, the APP RESET and BEFORE ALL sections it just ran; under per-test scope, its own reset. A retry in place shows neither, since nothing ran. Worker-scoped fixtures are set up once per file and are not set up again for a retry.

A failure starts the rest of the file fresh

Section titled “A failure starts the rest of the file fresh”

When a test fails for the last time (after any retries), the test after it does not start on the screen the failure left. Playwright discards its worker after a failure and runs the rest of the file in a new one; Tapsmith does the same with the app, so one failure does not turn into a run of unrelated ones:

  • The next test in the same describe starts the way a retry does (above): the afterAll hooks of every enclosing describe run (innermost first), then each scope’s reset (cold-launched on iOS simulators) and its beforeAll hooks (outermost first). Its trace shows those afterAll hooks in an AFTER ALL section, then that reset and those beforeAll hooks in its APP RESET and BEFORE ALL sections. The tests after it replay that setup (without the teardown) rather than the one the file started with.
  • A describe that starts after the failure gets the same fresh start from the scopes around it before its own reset and beforeAll hooks run, so they never run on the failed test’s screen.
  • A failing beforeAll fails its scope’s tests as before, and what runs after that scope starts fresh too.

A test that passes, or passes on a retry, leaves nothing to redo, and a failure in a file’s last test costs nothing extra (the next file starts from its own reset). The same exceptions as for retries apply: a scope with appReset: "none" runs on as it is, a per-test scope already resets before each test, and inside a serial group the tests after a failure are skipped. After one of those, the fresh start happens before the next test or describe that is not an exception. Worker-scoped fixtures are not set up again.

  • device.resetApp() — the same ladder the runner uses (warm hook → restart → clear); returns what actually ran. See API reference.
  • device.restartApp() — restart, keep persisted data.
  • device.clearAppData(pkg) then device.launchApp(pkg), or device.launchApp(pkg, { clearData: true }) — full wipe.
  • device.openDeepLink(resetLink) — trigger your app’s own reset hook.
Scenario Method
Tests are independent, app has little persistent state appReset: "restart"
Tests modify persistent storage default (clear), or appResetScope: "test" for per-test hermeticity
App exposes a reset hook resetAppDeepLink (→ warm) — see Configuration
Many tests need the same complex starting state Setup project with saveAppState() + test.use({ appState })
One specific scope needs different state test.use({ appState: "" }) or test.use({ appState: "other.tar.gz" })
Tests intentionally build on each other test.use({ appReset: "none" }) in that scope

Most real apps require authentication. Re-running a login flow before every test is slow and fragile. Instead, use a setup project that authenticates once and saves the app state, then dependent projects that restore it.

tests/auth.setup.ts
import path from "node:path"
import { test, expect } from "tapsmith"
import { LoginScreen } from "../screens/login.screen.js"
test("authenticate and save app state", async ({ device, projectName }) => {
const suffix = projectName ? `-${projectName.replace(/[^a-zA-Z0-9]/g, "-")}` : ""
const statePath = path.join(process.cwd(), "tapsmith-results", `auth-state${suffix}.tar.gz`)
await device.getByDescription("Login Form").tap()
const login = new LoginScreen(device)
await login.login("test@example.com", "password123")
await expect(device.getByText("Login successful!")).toBeVisible()
await device.saveAppState("com.example.myapp", statePath)
})

Step 2: Configure projects with dependencies

Section titled “Step 2: Configure projects with dependencies”
tapsmith.config.ts
import { defineConfig } from "tapsmith"
export default defineConfig({
testMatch: ["**/*.tapsmith.ts"],
package: "com.example.myapp",
timeout: 10_000,
projects: [
{
name: "setup",
testMatch: ["**/auth.setup.ts"],
use: { timeout: 30_000 },
},
{
name: "default",
testMatch: ["**/*.tapsmith.ts"],
testIgnore: ["**/auth-gate.tapsmith.ts"],
},
{
name: "authenticated",
dependencies: ["setup"],
use: { appState: "./tapsmith-results/auth-state-setup.tar.gz" },
testMatch: ["**/auth-gate.tapsmith.ts"],
},
],
})

The dependencies array ensures the setup project runs to completion before any authenticated tests begin. The appState path in use tells the runner to restore that state archive before each test file in the project.

When running on both Android and iOS, each platform needs its own auth state file. Use projectName to generate unique paths:

tapsmith.config.ts
export default defineConfig({
testMatch: ["**/*.tapsmith.ts"],
projects: [
{ name: "android:auth-setup", testMatch: ["**/auth.setup.ts"], use: { platform: "android", /* ... */ } },
{ name: "ios:auth-setup", testMatch: ["**/auth.setup.ts"], use: { platform: "ios", /* ... */ } },
{
name: "android:authenticated",
dependencies: ["android:auth-setup"],
use: { platform: "android", appState: "./tapsmith-results/auth-state-android-auth-setup.tar.gz" },
testMatch: ["**/auth-gate.tapsmith.ts"],
},
{
name: "ios:authenticated",
dependencies: ["ios:auth-setup"],
use: { platform: "ios", appState: "./tapsmith-results/auth-state-ios-auth-setup.tar.gz" },
testMatch: ["**/auth-gate.tapsmith.ts"],
},
],
})

Android + Keystore-backed auth (e.g. Firebase): restoring saved app state works on the same device that saved it, but an archive is not portable across devices. Many auth SDKs (including the Firebase Android SDK behind @react-native-firebase/auth) encrypt their session token with an Android Keystore key that stays on the saving device and isn’t captured in the archive — so restoring it on a different device or CI runner results in an unauthenticated state. The setup-project pattern above already handles this: dependencies re-runs auth.setup on each device, minting fresh state where the tests run. Don’t commit an Android auth archive and restore it elsewhere; let the setup project regenerate it (or use API-driven auth such as custom-token sign-in). See saveAppState for details.


Locators determine how your tests find UI elements. The right locator makes tests resilient to refactors. The wrong one makes them break whenever a developer moves a button.

To get a locator that matches, pick the element in UI mode or ask your coding agent over MCP instead of guessing: see Finding locators.

Use the most accessible locator that uniquely identifies the element:

Priority Method When to use
1 (preferred) getByRole() Buttons, text fields, checkboxes, switches, headings
2 (preferred) getByText() Static text, labels, headings without a role
3 (preferred) getByDescription() Elements with explicit accessibility descriptions
4 (acceptable) getByPlaceholder() Text inputs without a visible label
5 (escape hatch) getByTestId() When no user-visible attribute works
6 (discouraged) locator({ id }), locator({ className }) Platform-specific locators
7 (last resort) locator({ xpath }) Android-only, extremely fragile
// Good -- uses accessible role + name
device.getByRole("button", { name: "Submit" })
// Good -- matches visible text
device.getByText("Welcome back")
// Acceptable -- no better alternative for this input
device.getByPlaceholder("Search products")
// Escape hatch -- element has no accessible attributes
device.getByTestId("animated-loader")
// Avoid -- breaks if layout changes
device.locator({ xpath: "//android.widget.Button[2]" })

getByRole() supports state filters that make locators both precise and readable:

device.getByRole("switch", { name: "Dark Mode", checked: true })
device.getByRole("button", { name: "Submit", disabled: true })
device.getByRole("tab", { name: "Settings", selected: true })
device.getByRole("button", { name: "Details", expanded: true })

getByRole("tab") matches the tab items, not the tab bar that holds them (that is getByRole("tablist")). A tab bar whose items publish no accessibility role needs getByText() or getByDescription() instead — the locators guide says what each platform exposes.

On iOS, when a component has an explicit accessibilityLabel, child text elements are hidden from the accessibility tree. The parent becomes a single accessible element. On Android, child text remains individually queryable.

// React Native component
<Pressable accessibilityLabel="Login Form" accessibilityRole="button">
<Text>Login Form</Text>
<Text>Enter your credentials</Text> {/* hidden on iOS */}
</Pressable>

Write cross-platform tests by targeting the parent:

// Works on both platforms
await device.getByRole("button", { name: "Login Form" }).tap()
await device.getByDescription("Login Form").tap()
// Android-only -- child text is hidden on iOS
await device.getByText("Enter your credentials").tap()

For elements that must be individually addressable on both platforms, use getByTestId().

Tapsmith ships an ESLint plugin that warns when tests use low-priority locators. Add it to your ESLint config to enforce accessible locator usage across your team. The Locators Guide has the setup, which also reports a missing await.

Narrow a search to elements inside a specific element using chained getBy* calls (an element is inside when its centre lies within the parent’s bounds; see Chaining and Scoping):

const form = device.getByTestId("login-form") // a plain View with testID="login-form"
const emailField = form.getByRole("textfield", { name: "Email" })
await emailField.type("user@example.com")

Tapsmith auto-waits in most situations. Understanding when it waits and when it does not helps you write tests that neither flake nor waste time.

Assertion methods like toBeVisible(), toHaveText(), and toBeEnabled() automatically poll until the condition is met or the timeout expires. You do not need to add explicit waits before assertions:

// Correct -- toBeVisible() polls automatically (default 5s assertion timeout)
await expect(device.getByText("Welcome")).toBeVisible()
// Unnecessary -- do not add manual waits before auto-waiting assertions
await device.waitForIdle() // remove this
await expect(device.getByText("Welcome")).toBeVisible()

Override the assertion timeout when an element takes longer than usual to appear:

await expect(device.getByText("Data loaded")).toBeVisible({ timeout: 15_000 })

Actions like tap(), type(), and longPress() wait for the element to exist before executing. If the element is not found within the action timeout (default 30s, configurable), the action throws.

When you need to wait for a specific state before proceeding (not just asserting), use waitFor():

// Wait for a loading indicator to disappear before continuing
await device.getByText("Loading...").waitFor({ state: "hidden", timeout: 10_000 })
// Wait for an element to appear
await device.getByText("Ready").waitFor({ state: "visible" })
// Wait for an element to be added to the hierarchy (may not be visible)
await device.getByTestId("offscreen-data").waitFor({ state: "attached" })
// Wait for an element to be removed from the hierarchy entirely
await device.getByTestId("splash-screen").waitFor({ state: "detached" })

The four states:

State Meaning
visible Element exists and is visible on screen (default)
hidden Element either does not exist or exists but is not visible
attached Element exists in the hierarchy, regardless of visibility
detached Element does not exist in the hierarchy at all

Waits for the device UI to reach a stable state (no animations, no pending layout). Useful after complex transitions:

await device.swipe("up")
await device.waitForIdle()

For flows that depend on network responses, use waitForRequest() or waitForResponse() instead of arbitrary timeouts. Start waiting before the action that sends the request (see Network prerequisites for when the network proxy starts):

// Wait for a specific API call to complete
const responsePromise = device.waitForResponse("**/api/users")
await device.getByRole("button", { name: "Load Users" }).tap()
const response = await responsePromise

If the tap fails, the test ends before it awaits responsePromise, and the waiter ends with the test. More generally, a promise a test never awaits that rejects while the test runs (an unhandled rejection) fails that test, and the rest of the run carries on.


Tapsmith finds test files with testMatch. A config from tapsmith init sets it to **/*.tapsmith.ts, a suffix Jest and Vitest don’t run, so the app’s unit-test suite and Tapsmith don’t pick up each other’s files, as long as Tapsmith tests stay out of __tests__ folders, where Jest runs every file (more). Without testMatch, Tapsmith runs **/*.test.ts, **/*.spec.ts and **/*.tapsmith.ts files. Use one convention consistently:

tests/
login.tapsmith.ts
checkout.tapsmith.ts
settings.tapsmith.ts
device-management.android.tapsmith.ts # platform-specific tests

For large projects, organize by feature or screen:

e2e/
screens/
login.screen.ts
checkout.screen.ts
settings.screen.ts
tests/
login.tapsmith.ts
checkout.tapsmith.ts
settings.tapsmith.ts
utils/
test-data.ts
tapsmith.config.ts

Control which tests run in each project:

export default defineConfig({
testMatch: ["**/*.tapsmith.ts"],
projects: [
{
name: "smoke",
testMatch: ["**/smoke-*.tapsmith.ts"],
},
{
name: "full",
testMatch: ["**/*.tapsmith.ts"],
testIgnore: ["**/smoke-*.tapsmith.ts"],
},
],
})

Tag your test names with markers and filter on the command line:

test("@smoke login flow works", async ({ device }) => { /* ... */ })
test("@regression edge case in checkout", async ({ device }) => { /* ... */ })
# Run only smoke tests
npx tapsmith test --grep "@smoke"
# Run everything except regression tests
npx tapsmith test --grep-invert "@regression"

You can also configure grep and grepInvert in the config file or per-project:

export default defineConfig({
projects: [
{
name: "smoke",
grep: /@smoke/,
},
{
name: "full",
grepInvert: /@smoke/,
},
],
})

Use file naming conventions combined with testIgnore to exclude platform-specific tests:

export default defineConfig({
testMatch: ["**/*.tapsmith.ts"],
projects: [
{
name: "ios",
use: { platform: "ios" },
testIgnore: ["**/*.android.tapsmith.ts"],
},
{
name: "android",
use: { platform: "android" },
},
],
})

Or use the platform fixture inside tests:

test("android notification shade", async ({ device, platform }) => {
if (platform !== "android") return
await device.openNotifications()
// ...
})

Hooks run setup and teardown code around your tests. They are registered inside describe() blocks and apply to all tests within that block.

Each hook gets a time budget equal to the test timeout. A hook that never finishes (for example, one waiting on a promise that never settles) fails with an error that names it instead of hanging the run. See hook timeouts.

Run before and after every test in the enclosing describe. Use these to bring each test to its starting screen, or to undo what a test changed outside the app. Resetting the app itself is the appResetScope option’s job (Test isolation), not a hook’s:

describe("Checkout", () => {
test.use({ appResetScope: "test" }) // a fresh app before every test
beforeEach(async ({ device }) => {
await device.getByDescription("Cart").tap()
})
afterEach(async ({ device }) => {
// Device settings outlive an app reset: put back what a test changed
await device.setOrientation("portrait")
})
test("shows empty cart message", async ({ device }) => {
await expect(device.getByText("Your cart is empty")).toBeVisible()
})
})

Run once before all tests in the enclosing describe and once after all tests complete. Use these for expensive one-time setup:

describe("Settings screen", () => {
beforeAll(async ({ device }) => {
// Navigate to settings once -- all tests in this block start here
await device.getByDescription("Settings").tap()
})
test("shows account section", async ({ device }) => {
await expect(device.getByText("Account")).toBeVisible()
})
test("shows notification preferences", async ({ device }) => {
await expect(device.getByText("Notifications")).toBeVisible()
})
})

All hooks receive the same { device } fixture that tests do. beforeAll and afterAll also receive device, which is the same device instance used for the worker’s lifetime:

beforeAll(async ({ device }) => {
await device.setOrientation("portrait") // device-level, so it lasts
})
beforeEach(async ({ device }) => {
await device.openDeepLink("myapp://login") // each test's starting screen
})

Hooks can also be defined without fixtures for setup that does not involve the device:

beforeAll(() => {
console.log("Starting test suite")
})

Hooks cascade through nested describe blocks. Inner hooks run after outer hooks:

describe("App", () => {
beforeEach(async ({ device }) => {
await device.getByDescription("Home").tap() // runs first
})
describe("Login", () => {
beforeEach(async ({ device }) => {
await device.getByDescription("Login Form").tap() // runs second
})
test("shows sign in form", async ({ device }) => {
// Home was tapped first, then Login Form
await expect(device.getByText("Sign In")).toBeVisible()
})
})
})
Hook Scope Use for
beforeAll Once per describe Navigating to a screen, one-time setup
afterAll Once per describe Cleaning up shared resources
beforeEach Before every test Bringing each test to its starting point (reset the app with appResetScope: "test", and land on a screen with appResetRoute, instead)
afterEach After every test Cleaning up test-specific side effects

Caution: beforeAll runs once, so all tests in that block share whatever state it creates. If one test modifies the screen, subsequent tests see that modification. When in doubt, use beforeEach.


Custom fixtures let you define reusable setup/teardown logic that tests can request by name. They follow the same use() callback pattern as Playwright.

Fixtures are ideal for reusable test data setup/teardown, navigation helpers, or any per-test context that isn’t authentication (for auth, use the app state pattern instead).

fixtures.ts
import { test as base } from "tapsmith"
// A fixture that seeds a product via the API and cleans it up afterward
const test = base.extend<{ productId: string }>({
productId: async ({ request }, use) => {
// Setup: create test data via API
const res = await request.post("https://api.example.com/products", {
data: { name: "Test Widget", price: 9.99 },
})
const { id } = await res.json() as { id: string }
// Hand the value to the test
await use(id)
// Teardown: clean up after the test (runs even on failure)
await request.delete(`https://api.example.com/products/${id}`)
},
})
export { test }
import { test } from "./fixtures.js"
import { expect } from "tapsmith"
test("product appears in catalog", async ({ device, productId }) => {
await device.getByText("Refresh").tap()
await expect(device.getByText("Test Widget")).toBeVisible()
})

By default, fixtures are test-scoped – they set up and tear down for each test. For expensive setup that should persist across all tests in a worker, use worker scope:

const test = base.extend<{ dbConnection: DatabaseClient }>({
dbConnection: [async ({}, use) => {
const db = await connectToTestDatabase()
await use(db)
await db.close()
}, { scope: "worker" }],
})

Worker-scoped fixtures are created once per worker and shared across all tests that worker runs. They are torn down when the worker exits.

The use() function is the boundary between setup and teardown:

myFixture: async ({ device }, use) => {
// Everything before use() is SETUP
const data = await seedTestData()
await use(data) // <-- test runs here
// Everything after use() is TEARDOWN (always runs, even on failure)
await cleanupTestData(data)
},

Regular assertions stop the test immediately on failure. Soft assertions record the failure but let the test continue, so you can check multiple things and see all failures at once.

import { test, expect, flushSoftErrors } from "tapsmith"
test("dashboard shows all sections", async ({ device }) => {
await expect.soft(device.getByText("Revenue")).toBeVisible()
await expect.soft(device.getByText("Users")).toBeVisible()
await expect.soft(device.getByText("Orders")).toBeVisible()
await expect.soft(device.getByText("Inventory")).toBeVisible()
// Flush at the end to fail the test if any soft assertions failed
const errors = flushSoftErrors()
expect(errors).toHaveLength(0)
})
  • Layout verification tests where you want to check the visibility of many elements at once and see which ones are missing.
  • Data validation where multiple fields should have specific values.
  • Smoke tests where you want a single test to check many screens and report all failures.

Do not use soft assertions when later steps depend on earlier ones. If tapping a button is a prerequisite for checking the next screen, a regular assertion is correct – there is no point continuing if the tap target does not exist.


The request fixture provides an HTTP client for making API calls during tests. Use it to seed test data, verify backend state, or fetch authentication tokens.

import { test, expect } from "tapsmith"
test("GET request returns data", async ({ request }) => {
const response = await request.get("https://api.example.com/users/1")
expect(response.ok).toBe(true)
expect(response.status).toBe(200)
const body = (await response.json()) as { id: number; name: string }
expect(body.id).toBe(1)
})

The request fixture supports all standard HTTP methods:

await request.get(url, options?)
await request.post(url, options?)
await request.put(url, options?)
await request.patch(url, options?)
await request.delete(url, options?)
await request.head(url, options?)
// JSON body (most common)
await request.post("https://api.example.com/posts", {
data: { title: "Test Post", body: "Created by Tapsmith" },
})
// Form-encoded body
await request.post("https://api.example.com/login", {
form: { username: "admin", password: "secret" },
})
// Custom headers
await request.get("https://api.example.com/protected", {
headers: { Authorization: "Bearer token123" },
})
// Query parameters
await request.get("https://api.example.com/search", {
params: { q: "tapsmith", limit: "10" },
})

The most powerful pattern is using request alongside device to seed backend state and then verify it in the UI:

test("user created via API appears in app", async ({ device, request }) => {
// Seed data via API
const res = await request.post("https://api.example.com/users", {
data: { name: "Jane Doe", email: "jane@example.com" },
})
expect(res.status).toBe(201)
// Refresh the app and verify the user appears
await device.getByRole("button", { name: "Refresh" }).tap()
await expect(device.getByText("Jane Doe")).toBeVisible()
})

Set baseURL in your config to avoid repeating the full URL in every call:

tapsmith.config.ts
export default defineConfig({
baseURL: "https://api.example.com",
})
// In tests, use relative URLs
const res = await request.get("/users/1")

You can also set extraHTTPHeaders in config for headers that apply to all requests (e.g. API keys).


Tapsmith supports parallel execution across multiple workers, where each worker runs on its own device or emulator. This speeds up large test suites dramatically, but requires tests to be written with isolation in mind.

Each worker runs a subset of test files. Tests must not depend on:

  • Shared mutable backend state (e.g. a single test user that two workers modify concurrently)
  • Execution order (files are distributed to workers in no guaranteed order)
  • Device state left by a previous test file

When parallel workers hit the same backend, use unique identifiers to avoid collisions:

test("create and verify user", async ({ device, request }) => {
const uniqueEmail = `test-${Date.now()}@example.com`
await request.post("/api/users", {
data: { email: uniqueEmail, name: "Test User" },
})
// ...
})

Each worker gets its own device, so device-level state (app data, clipboard, orientation) is naturally isolated. No extra work is needed for on-device isolation.

Backend isolation is your responsibility. If your test creates a resource via API, clean it up in teardown or use unique identifiers so parallel runs do not interfere.

export default defineConfig({
workers: 4, // 4 parallel workers
avd: "Pixel_6", // each worker gets its own emulator instance
launchEmulators: true, // auto-launch emulators to fill worker count
})

Per-project worker counts are also supported:

export default defineConfig({
projects: [
{ name: "android", workers: 2, use: { platform: "android", avd: "Pixel_6" } },
{ name: "ios", workers: 1, use: { platform: "ios", simulator: "iPhone 16" } },
],
})

  • Locators Guide – deep dive into locator types and cross-platform behavior
  • API Reference – complete reference for all public APIs
  • Configuration – all config options, reporters, trace modes, and video recording