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() 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.


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 selectors 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.getByText("Forgot password?", { exact: true }) }
async login(email: string, password: string) {
await this.emailField.clearAndType(email)
await this.passwordField.clearAndType(password)
await this.device.hideKeyboard()
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("[email protected]")
await expect(login.emailField).toHaveValue("[email protected]")
})
test("successful login flow", async ({ device }) => {
const login = new LoginScreen(device)
await login.login("[email protected]", "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.

Tests must not depend on each other. Each test should start from a known state and leave the device in a state that does not affect subsequent tests. Tapsmith provides several mechanisms for resetting state.

The most common pattern. Restarting the app clears in-memory state (React state, navigation stack) but preserves persisted data (AsyncStorage, databases):

beforeEach(async ({ device }) => {
await device.restartApp()
})

When tests write persistent data that affects other tests, clear everything:

beforeEach(async ({ device }) => {
await device.clearAppData("com.example.myapp")
await device.launchApp("com.example.myapp")
})

Combines clearing and launching in a single call:

beforeEach(async ({ device }) => {
await device.launchApp("com.example.myapp", { clearData: true })
})

For tests that need a specific starting state (e.g. logged-in), restore a previously saved snapshot:

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:

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()
})
})
ScenarioMethod
Tests are independent, app has little persistent staterestartApp() in beforeEach
Tests modify persistent storageclearAppData() or launchApp({ clearData: true })
Many tests need the same complex starting stateSetup project with saveAppState() + test.use({ appState })
One specific scope needs different statetest.use({ appState: "" }) or test.use({ appState: "other.tar.gz" })

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("[email protected]", "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({
package: "com.example.myapp",
timeout: 10_000,
projects: [
{
name: "setup",
testMatch: ["**/auth.setup.ts"],
use: { timeout: 30_000 },
},
{
name: "default",
testMatch: ["**/*.test.ts"],
testIgnore: ["**/auth-gate.test.ts"],
},
{
name: "authenticated",
dependencies: ["setup"],
use: { appState: "./tapsmith-results/auth-state-setup.tar.gz" },
testMatch: ["**/auth-gate.test.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({
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.test.ts"],
},
{
name: "ios:authenticated",
dependencies: ["ios:auth-setup"],
use: { platform: "ios", appState: "./tapsmith-results/auth-state-ios-auth-setup.tar.gz" },
testMatch: ["**/auth-gate.test.ts"],
},
],
})

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

Use the most accessible selector that uniquely identifies the element:

PriorityMethodWhen 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 selectors
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 selectors 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 })

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 selectors. Add it to your ESLint config to enforce accessible selector usage across your team. See Selectors Guide for full details.

Narrow a search to descendants of a specific element using chained getBy* calls:

const form = device.getByRole("button", { name: "Login Form" })
const emailField = form.getByRole("textfield", { name: "Email" })
await emailField.type("[email protected]")

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:

StateMeaning
visibleElement exists and is visible on screen (default)
hiddenElement either does not exist or exists but is not visible
attachedElement exists in the hierarchy, regardless of visibility
detachedElement 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. These require network tracing to be enabled:

// 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

Tapsmith discovers test files matching **/*.test.ts and **/*.spec.ts by default. Use either convention consistently:

tests/
login.test.ts
checkout.test.ts
settings.test.ts
device-management.android.test.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.test.ts
checkout.test.ts
settings.test.ts
utils/
test-data.ts
tapsmith.config.ts

Control which tests run in each project:

export default defineConfig({
projects: [
{
name: "smoke",
testMatch: ["**/smoke-*.test.ts"],
},
{
name: "full",
testMatch: ["**/*.test.ts"],
testIgnore: ["**/smoke-*.test.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 }) => { /* ... */ })
Terminal window
# 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({
projects: [
{
name: "ios",
use: { platform: "ios" },
testIgnore: ["**/*.android.test.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.

Run before and after every test in the enclosing describe. Use these for per-test setup like resetting app state:

describe("Checkout", () => {
beforeEach(async ({ device }) => {
await device.restartApp()
await device.getByDescription("Cart").tap()
})
afterEach(async ({ device }) => {
// Clean up test data if needed
await device.clearAppData("com.example.myapp")
})
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.getByDescription("Login Form").tap()
})
beforeEach(async ({ device }) => {
await device.restartApp()
})

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.restartApp() // runs first
})
describe("Login", () => {
beforeEach(async ({ device }) => {
await device.getByDescription("Login Form").tap() // runs second
})
test("shows sign in form", async ({ device }) => {
// restartApp() has run, then navigated to login
await expect(device.getByText("Sign In")).toBeVisible()
})
})
})
HookScopeUse for
beforeAllOnce per describeNavigating to a screen, one-time setup
afterAllOnce per describeCleaning up shared resources
beforeEachBefore every testResetting app state, ensuring a clean starting point
afterEachAfter every testCleaning 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: "[email protected]" },
})
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" } },
],
})

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