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();});test.only(name, fn)
Section titled “test.only(name, fn)”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});test.skip(name, fn)
Section titled “test.skip(name, fn)”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/afterEachhook or a fixture: the running test stops at that line and is reported as skipped.afterEachhooks still run. Called inafterEach, it marks a test that passed as skipped (a failed test stays failed). - In a
beforeAllhook: 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.
test.use(options: UseOptions): void
Section titled “test.use(options: UseOptions): void”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 statetest("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 indescribe("authenticated tests", () => { test.use({ appState: "./auth-state.tar.gz" });
test("shows profile", async ({ device }) => { // Already logged in — no login flow needed });});TestFixtures
Section titled “TestFixtures”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 testconst 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()andtest.use({ appState })instead of custom fixtures. This mirrors Playwright’sstorageStatepattern — 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.
Playwright APIs not supported yet
Section titled “Playwright APIs not supported yet”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.
Hook timeouts
Section titled “Hook timeouts”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. ItsafterEachhooks 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 remainingafterEachhooks still run.afterAll: the error is printed, like an error thrown by anafterAllhook, 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/afterEachtest.beforeEach(async ({ device, authScreen }) => { await device.openDeepLink("myapp:///login"); await expect(authScreen.heading).toBeVisible();});Note: Test-scoped custom fixtures work in
beforeAll/afterAllas well asbeforeEach/afterEach. As in Playwright, eachbeforeAll/afterAllhook 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.

