Skip to content

Test Runner

Tapsmith includes a built-in test runner with an API inspired by Jest and Playwright.

test(name: string, fn: (fixtures: TestFixtures) => Promise<void>): void

Section titled “test(name: string, fn: (fixtures: TestFixtures) => Promise<void>): void”

Register a test. The test function receives a fixtures object containing a device instance.

test("user can log in", async ({ device }) => {
await device.getByText("Sign In", { exact: true }).tap();
});

Run only this test (and other tests marked with .only). All other tests are skipped.

test.only("focused test", async ({ device }) => {
// Only this test will run
});

Declare a test that is reported as skipped and never runs.

test.skip("broken test", async ({ device }) => {
// This test will not run
});

test.skip() / test.skip(condition: boolean, description?: string)

Section titled “test.skip() / test.skip(condition: boolean, description?: string)”

Skip conditionally, as in Playwright. Where you call it decides what is skipped:

  • In a describe block, or at the top of a file: every test of that scope is skipped, nested describes included, and none of its hooks run. The call can come anywhere in the block.
  • Inside a test, a beforeEach/afterEach hook or a fixture: the running test stops at that line and is reported as skipped. afterEach hooks still run. Called in afterEach, it marks a test that passed as skipped (a failed test stays failed).
  • In a beforeAll hook: every test of that scope is skipped.

With no arguments it always skips. With a condition it skips only when the condition is truthy. The description says why. It is not shown in reports yet.

describe("payments", () => {
test.skip(!process.env.CI, "needs the CI payment sandbox");
test("checkout succeeds", async ({ device }) => { /* ... */ });
});
test("share sheet", async ({ device, platform }) => {
test.skip(platform === "android", "iOS share sheet only");
// ...
});

A skipped test is not retried, and it does not stop a describe.serial group. A test that fails and is then skipped on a retry is reported as failed. A file skipped at the top level sets up none of its worker-scoped fixtures. test.skip() inside a worker-scoped fixture isn’t supported yet: Tapsmith sets those up once for the whole file, so it throws an error saying so. Playwright’s callback form, test.skip(({ platform }) => …, description), isn’t supported yet. Call test.skip(condition) inside the test instead, where the fixtures are available.

Override configuration options for all tests in the current describe scope. Overrides cascade — inner describe blocks inherit and can further override outer ones.

describe("slow animations screen", () => {
test.use({ timeout: 60000 })
test("animation completes", async ({ device }) => {
// runs with 60s timeout instead of the default
})
})

Multiple calls in the same scope merge together:

describe("custom config", () => {
test.use({ timeout: 60000 })
test.use({ screenshot: "always" })
// equivalent to: test.use({ timeout: 60000, screenshot: "always" })
})

UseOptions

Option Type Description
timeout number Action/assertion timeout (ms)
screenshot 'always' | 'only-on-failure' | 'never' Screenshot capture mode
retries number Retry count for failed tests. A retry starts from a reset app (details)
trace TraceMode | Partial<TraceConfig> Trace recording configuration. See configuration.md for the full TraceConfig shape (includes network, networkHttpPorts, networkHosts, networkIgnoreHosts, networkPassthroughHosts, screenshots, etc.).
video VideoMode | Partial<VideoConfig> Video recording configuration. See the Video recording section below.
appState string Path to saved app state archive to restore; "" means clear
appReset 'auto' | 'clear' | 'restart' | 'warm' | 'none' How the app is reset before tests in this scope. See Test isolation.
appResetScope 'auto' | 'file' | 'test' Reset once per file or before every test (auto resolves to per file; opt into per-test with 'test')
appResetColdEvery number Cold-relaunch every N warm resets (default 10; 0 = off)

The following device-shaping fields may only be set on a project’s use block (not via test.use()), since the device is bound to the worker before any test runs:

Option Type Description
platform 'android' | 'ios' Target platform for this project
device string Explicit device serial / iOS UDID
devices number | { name: string; device?: string }[] Drive several devices from one test (devices: 2, or named members, optionally pinned to a serial; at most 10). Tests receive them as the devices fixture. test.use({ devices }) throws. See Multi-device tests.
avd string Android AVD name to launch
simulator string iOS simulator name or UDID
apk string Path to Android APK under test
app string Path to iOS .app bundle under test
package string Android package name / iOS bundle ID
activity string Optional Android launcher activity
agentApk string Override path to the Android agent APK
agentTestApk string Override path to the Android agent test APK
iosXctestrun string Override path to the iOS .xctestrun file
deviceStrategy 'prefer-connected' | 'avd-only' Device selection strategy (Android)
launchEmulators boolean Auto-launch emulators (Android)
resetAppDeepLink string Warm-reset deep link (makes appReset: 'auto' → warm)
resetAppWaitMs number Wait after the reset deep link

Reusable auth state — mirrors Playwright’s storageState:

// Setup: authenticate once and save state
test("authenticate", async ({ device }) => {
await device.launchApp("com.example.myapp");
// ... perform login flow ...
await device.saveAppState("com.example.myapp", "./auth-state.tar.gz");
});
// Tests: restore state instead of logging in
describe("authenticated tests", () => {
test.use({ appState: "./auth-state.tar.gz" });
test("shows profile", async ({ device }) => {
// Already logged in — no login flow needed
});
});

The fixtures object passed to every test function. Destructure the fields you need:

test("example", async ({ device, devices, request, projectName, platform }) => {
// device — the primary interface for interacting with the mobile device
// devices — every device of the test's group (devices[0] === device)
// request — HTTP client for API calls (seeding data, fetching tokens, etc.)
// projectName — current project name (when using multi-project config), or undefined
// platform — resolved platform: "android" or "ios"
});
Fixture Type Description
device Device Primary interface for interacting with the mobile device. An alias for devices[0].
devices Device[] Every device the test drives, in the order declared by the project’s use.devices — one entry unless the project declares a device group. Destructure by position ({ devices: [alice, bob] }) to drive two app sessions from one test. See Multi-device tests.
request APIRequestContext HTTP client for API calls. See API Request Fixture.
projectName string | undefined Name of the current project (from projects config). undefined when no projects are configured.
platform 'android' | 'ios' Resolved target platform for the current worker.

test.extend<T>(definitions): TestFn<Fixtures & T>

Section titled “test.extend<T>(definitions): TestFn<Fixtures & T>”

Create a new test function with additional custom fixtures. Returns a new TestFn with the extended fixture types. Follows the same pattern as Playwright’s test.extend().

Each fixture definition is a function that receives all other fixtures and a use callback. The fixture sets up its value, passes it to use(), and optionally cleans up after use() resolves. Fixture functions must destructure their first parameter (e.g., ({ request }, use)) so the framework can track dependencies for lazy resolution.

import { test as base, expect } from "tapsmith";
// Define a custom fixture that seeds a todo item via the API before each test
const test = base.extend<{ todoId: string }>({
todoId: async ({ request }, use) => {
// Setup: create a todo item via the API
const res = await request.post("https://api.example.com/todos", {
data: { title: "Buy groceries", completed: false },
});
const { id } = await res.json() as { id: string };
// Provide the fixture value to the test
await use(id);
// Teardown: clean up after the test (runs even if the test fails)
await request.delete(`https://api.example.com/todos/${id}`);
},
});
test("can mark todo as complete", async ({ device, todoId, request }) => {
await device.getByText("Refresh").tap();
await device.getByText("Buy groceries").tap();
await expect(device.getByRole("checkbox")).toBeChecked();
// Verify the change persisted via the API
const res = await request.get(`https://api.example.com/todos/${todoId}`);
const todo = await res.json() as { completed: boolean };
expect(todo.completed).toBe(true);
});

For authentication, prefer setup projects with device.saveAppState() and test.use({ appState }) instead of custom fixtures. This mirrors Playwright’s storageState pattern — authenticate once, save to a file, and restore across all tests. See the auth state example above and the Authentication patterns section in the Writing Tests guide.

Fixture scopes:

By default, fixtures have test scope (created and torn down for each test). Use a tuple to specify worker scope (created once per worker, shared across tests):

const test = base.extend<{ apiToken: string }>({
apiToken: [async ({ request }, use) => {
const res = await request.post("https://api.example.com/auth/service-token", {
data: { clientId: process.env.API_CLIENT_ID },
});
const { token } = await res.json() as { token: string };
await use(token);
}, { scope: "worker" }],
});
Scope Lifecycle Use for
test (default) Created before each test, torn down after Per-test data (seeded records, temp files)
worker Created once when the worker starts, torn down when it ends Expensive setup (API tokens, database connections)

Custom fixtures can depend on other custom fixtures — they’re resolved in dependency order automatically.

Lazy resolution: Only fixtures that are destructured by the test function and its hooks are resolved. If a test destructures { device, todoId }, only todoId (and its transitive dependencies) will be set up — other fixtures defined in test.extend() are skipped. This matches Playwright’s behavior.

describe(name: string, fn: () => void): void / test.describe(name, fn)

Section titled “describe(name: string, fn: () => void): void / test.describe(name, fn)”

Group tests into a suite. test.describe is the same function as the describe export (Playwright’s spelling), with every variant below; it is also available on a test.extend() result.

test.describe("Login flow", () => {
test("valid credentials", async ({ device }) => { /* ... */ });
test("invalid credentials", async ({ device }) => { /* ... */ });
});

The title is required, and the callback is the second argument: Playwright’s anonymous test.describe(callback) and test.describe(title, details, callback) forms throw an error saying they aren’t supported yet.

The callback must register its tests synchronously. An async callback that throws, or that is still running after it returns (it awaited something), fails its file to load, because tests registered after an await would be lost.

describe.only(name, fn) / describe.skip(name, fn)

Section titled “describe.only(name, fn) / describe.skip(name, fn)”

Focus or skip an entire suite. Also available as test.describe.only and test.describe.skip.

test.describe.serial(name, fn) / test.describe.serial.only(name, fn)

Section titled “test.describe.serial(name, fn) / test.describe.serial.only(name, fn)”

Declare a group of tests that depend on each other, as in Playwright: once a test in the group fails, every later test of the group is skipped (and reported as skipped), including the tests of nested describes. A nested describe that is skipped this way runs none of its hooks. The group’s own afterAll hooks still run.

test.describe.serial("checkout", () => {
test("adds an item to the cart", async ({ device }) => { /* ... */ });
test("pays", async ({ device }) => { /* skipped if the previous test failed */ });
});

A test that passes on a retry does not stop the group. Unlike Playwright, retries apply to each test where it failed rather than re-running the whole group from its first test, and a retry inside a serial group does not reset the app first, since that would wipe the state the group’s earlier tests built (outside a serial group, a retry starts from a reset app; see Retries start from a fresh app). Tests that sit directly in a describe run before its nested describes, so in a serial group a failure in a direct test also skips the nested describes declared above it.

test.describe.configure(options: DescribeConfigureOptions): void

Section titled “test.describe.configure(options: DescribeConfigureOptions): void”

Configure the enclosing describe, or the whole file when called at the top level of a test file.

test.describe.configure({ mode: "serial", retries: 1 });
Option Type Description
mode 'default' | 'serial' | 'parallel' 'serial' makes the scope a serial group (see test.describe.serial); 'default' turns that off for a describe declared with test.describe.serial. A serial group covers everything nested inside it, so a nested describe cannot leave its enclosing group with 'default' or 'parallel'. 'parallel' is accepted for Playwright compatibility and runs the tests in order: Tapsmith runs the tests of a file one after another on one device, and runs files in parallel with --workers.
retries number Retry count for failed tests in the scope. Same as test.use({ retries }).
timeout number Same as test.use({ timeout }): the action and assertion timeout for the scope (ms), which also raises each test’s safety timeout.

Any other option throws.

These Playwright names exist so that a test which uses one fails with a clear error rather than test.step is not a function. They are not part of the TypeScript types, so the type checker reports them as missing.

API Error suggests
test.step() device.tracing.group(name) / groupEnd() to group actions in the trace
test.fixme() test.skip(title, fn)
test.fail() —
test.slow(), test.setTimeout() test.use({ timeout }) in the describe
test.info() —
test.describe.fixme() test.describe.skip(title, fn)
test.describe.parallel() test.describe() (and --workers for parallel files)
expect.configure() { timeout } on the assertion, or timeout in the config / test.use()
expect.extend() —
expect(fn).toPass() expect.poll(fn)

Playwright locator methods that Tapsmith names differently throw an error naming the Tapsmith method: .click() → .tap(), .dblclick() → .doubleTap(), .fill() → .clearAndType(), .textContent() and .innerText() → .getText().

beforeAll(fn: (fixtures) => void | Promise<void>): void

Section titled “beforeAll(fn: (fixtures) => void | Promise<void>): void”

Run a function once before all tests in the current suite. Receives builtin fixtures (device, projectName, platform), worker-scoped custom fixtures, and any test-scoped custom fixtures it destructures (see the note below).

beforeAll(async ({ device }) => {
// One-time setup for the suite
await device.launchApp("com.example.myapp", { clearData: true });
});

afterAll(fn: (fixtures) => void | Promise<void>): void

Section titled “afterAll(fn: (fixtures) => void | Promise<void>): void”

Run a function once after all tests in the current suite. Receives the same fixtures as beforeAll.

beforeEach(fn: (fixtures) => void | Promise<void>): void

Section titled “beforeEach(fn: (fixtures) => void | Promise<void>): void”

Run a function before each test in the current suite. Hooks are inherited by nested suites. Receives builtin fixtures (device, request, projectName, platform).

beforeEach(async ({ device }) => {
await device.restartApp("com.example.myapp");
});

afterEach(fn: (fixtures) => void | Promise<void>): void

Section titled “afterEach(fn: (fixtures) => void | Promise<void>): void”

Run a function after each test in the current suite. Runs even if the test fails. Receives the same fixtures as beforeEach.

Each hook call gets its own time budget, equal to the test timeout: 3× the config timeout, or a larger test.use({ timeout }) of its scope. Time spent inside device actions that carry their own deadlines (app launches, deep links) does not count, as for a test body. A hook that is still running when its budget runs out fails with an error that names it, for example "beforeEach" hook at login.test.ts:12 timed out after 90000ms:

  • beforeAll: every test of its scope fails with that error, as when the hook throws.
  • beforeEach: the test fails. Its afterEach hooks still run.
  • afterEach: the test fails, even if its body passed. If the test had already failed, the timeout is added to its error. The remaining afterEach hooks still run.
  • afterAll: the error is printed, like an error thrown by an afterAll hook, and the run carries on.

Unlike Playwright, beforeEach/afterEach hooks do not share the test body’s budget: slow setup such as device.restartApp() on a loaded machine does not eat into the time the test itself has. A hook that timed out is fenced off like a timed-out test body: device calls it starts after the timeout are refused.

test.beforeAll(fn) / test.afterAll(fn) / test.beforeEach(fn) / test.afterEach(fn)

Section titled “test.beforeAll(fn) / test.afterAll(fn) / test.beforeEach(fn) / test.afterEach(fn)”

Hook methods on the extended test function. Use these instead of standalone hooks when you need custom fixtures in your hooks:

const test = base.extend<{ authScreen: AuthScreen }>({
authScreen: async ({ device }, use) => {
await use(new AuthScreen(device))
},
});
// Custom fixtures are available in test.beforeEach/afterEach
test.beforeEach(async ({ device, authScreen }) => {
await device.openDeepLink("myapp:///login");
await expect(authScreen.heading).toBeVisible();
});

Note: Test-scoped custom fixtures work in beforeAll/afterAll as well as beforeEach/afterEach. As in Playwright, each beforeAll/afterAll hook gets its own test-fixture scope: the fixtures it destructures are set up just before the hook runs and torn down immediately after (even if the hook throws). They are therefore not shared with the tests in the suite — for a single instance shared across the whole worker, declare the fixture with { scope: "worker" } instead.