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
});

Skip this test.

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

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

OptionTypeDescription
timeoutnumberAction/assertion timeout (ms)
screenshot'always' | 'only-on-failure' | 'never'Screenshot capture mode
retriesnumberRetry count for failed tests
traceTraceMode | Partial<TraceConfig>Trace recording configuration. See configuration.md for the full TraceConfig shape (includes network, networkHosts, networkIgnoreHosts, networkPassthroughHosts, screenshots, etc.).
videoVideoMode | Partial<VideoConfig>Video recording configuration. See the Video recording section below.
appStatestringPath to saved app state archive to restore

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:

OptionTypeDescription
platform'android' | 'ios'Target platform for this project
devicestringExplicit device serial / iOS UDID
avdstringAndroid AVD name to launch
simulatorstringiOS simulator name or UDID
apkstringPath to Android APK under test
appstringPath to iOS .app bundle under test
packagestringAndroid package name / iOS bundle ID
activitystringOptional Android launcher activity
agentApkstringOverride path to the Android agent APK
agentTestApkstringOverride path to the Android agent test APK
iosXctestrunstringOverride path to the iOS .xctestrun file
deviceStrategy'prefer-connected' | 'avd-only'Device selection strategy (Android)
launchEmulatorsbooleanAuto-launch emulators (Android)
resetAppDeepLinkstringSoft-reset deep link between files
resetAppWaitMsnumberWait 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, request, projectName, platform }) => {
// device — the primary interface for interacting with the mobile 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"
});
FixtureTypeDescription
deviceDevicePrimary interface for interacting with the mobile device
requestAPIRequestContextHTTP client for API calls. See API Request Fixture.
projectNamestring | undefinedName 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" }],
});
ScopeLifecycleUse for
test (default)Created before each test, torn down afterPer-test data (seeded records, temp files)
workerCreated once when the worker starts, torn down when it endsExpensive 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

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

Group tests into a suite.

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

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

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

Focus or skip an entire suite.

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.

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.