Testing
Fixtures
Fixtures give a test its device, its app and its screen, and options decide how they are set up. This page
covers the built-in fixtures and options, app isolation, launch arguments, permissions, test.use,
test.requires and fixtures of your own.
Built-in fixtures
| Fixture | Scope | What it is |
|---|---|---|
screen | test | The foreground app's screen: locators and screen-wide input. screen.system reaches system UI |
app | test | The app under test, isolated and launched before the test |
testInfo | test | The running test: title, retry, output directory, attach |
device | worker | The device session the worker holds |
runtime | worker | Typed calls to the runtime |
A test-scoped fixture is set up for each test. A worker-scoped one is set up once per worker and shared by the tests that worker runs: a worker keeps one device for all its tests.
Starting the device and installing the app are not charged to a test's timeout. The device fixture has its
own budget, the deviceTimeout option.
Options
Options are fixtures that hold a value. Set them in the config's use, in a project's use, or with
test.use.
These choose the device. They are worker-scoped:
| Option | Meaning | Default |
|---|---|---|
platform | ios, android or catalyst (or the full id) | none: required for a device |
deviceType | A device type by id or exact name, such as 'iPhone 18 Pro' or 'Pixel 8' | the platform's default |
os | The OS version: '27.0' on iOS, an API level such as '36' on Android | the newest installed one the device type runs |
arch | 'arm64' or 'x86_64' | the host's |
variant | The Android system image tag, such as 'google_apis' | 'google_apis' |
locale | A BCP 47 locale, applied at boot | 'en-US' |
timeZone | An IANA time zone | the host's |
orientation | The orientation the device starts in | 'portrait' |
appearance | 'light' or 'dark' | 'light' |
app | The build to install: a path relative to the config, { url, sha256 }, or xcodeBuild({ … }) | none |
deviceTimeout | The longest a worker waits for its device, in milliseconds | 360,000 on iOS, 300,000 on Android, 120,000 on Catalyst |
These shape each test. They are test-scoped:
| Option | Meaning | Default |
|---|---|---|
bundleId | The app to launch | the app that app installed |
appIsolation | How the app is reset before each test | 'relaunch' |
launchArgs | Arguments passed to the app at launch | none |
launchEnv | Environment variables passed to the app at launch | none |
launchTimeout | How long a launch may take, in milliseconds | 30,000 |
permissions | Permissions granted before launch | none |
actionTimeout | How long each action waits for its element, in milliseconds | what is left of the test's time |
trace | The trace mode | 'retain-on-failure' |
Changing options for some tests
test.use sets options for a file, or for one describe:
test.describe('launch arguments and environment', () => {
test.use({ launchArgs: ['--greeting', 'hello'], launchEnv: { STAGE: 'e2e' } })
test('reach the app', async ({ screen }) => {
await screen.getByTestId('nav-launch').tap()
await expect(screen.getByTestId('launch-arguments')).toContainText('--greeting hello')
await expect(screen.getByTestId('launch-environment')).toContainText('STAGE=e2e')
})
})A test-scoped option changes only how those tests start. A worker-scoped option such as locale or
deviceType asks for a different device, so those tests run in a worker of their own.
App isolation
appIsolation decides what the app fixture does to your app before each test. The launch follows.
| Value | Before each test | What survives from the test before |
|---|---|---|
'relaunch' (default) | Terminates the app and launches it | Data on disk: sign-ins, databases, preferences |
'clear' | Wipes the app's data, then launches | Nothing |
'reinstall' | Uninstalls the app and installs the build again, then launches | Nothing |
'none' | Brings the running app forward | Everything, the screen it was on included |
'clear' needs a platform that can clear app data, which Mac Catalyst cannot. 'reinstall' needs the app
option, since that is the build it installs again.
The app fixture
app is the launched app. Its methods act on that app by its bundle id.
| Method | What it does |
|---|---|
app.openUrl(url) | Opens a URL in the app: a deep link such as shop://orders/42 |
app.launch(options?) | Launches the app. Options: args, env, url, ifRunning ('relaunch' or 'activate'), timeout |
app.relaunch(options?) | Terminates and launches |
app.terminate() | Stops the app. Returns whether it was running |
app.clearData() | Stops the app and wipes its data |
app.reinstall() | Uninstalls it and installs the build again |
app.setPermissions({ grant?, revoke?, reset? }) | Changes permissions |
app.bundleId | The app's bundle id |
test('remembers the sign-in after a restart', async ({ app, screen }) => {
await screen.getByLabel('Email').fill('ada@ex.co')
await screen.getByRole('button', { name: 'Sign in' }).tap()
await app.relaunch()
await expect(screen.getByText('Signed in as ada@ex.co')).toBeVisible()
})On Mac Catalyst there are no launch arguments, no environment, no deep links and no data clearing. On Android the arguments and environment reach the app as intent extras; see Android.
Permissions
permissions grants permissions before the app launches, so no prompt appears:
test.use({ permissions: ['location', 'photos'] })| Platform | Permissions it can set |
|---|---|
| iOS Simulator | calendar, contacts, location, locationAlways, microphone, motion, photos, reminders |
| Android Emulator | calendar, camera, contacts, location, locationAlways, microphone, motion, notifications, photos |
| Mac Catalyst | none |
Inside a test, app.setPermissions({ revoke: ['location'] }) changes them. To test the prompt itself, leave
the permission out and answer the prompt through screen.system.
The device and runtime fixtures
device is the worker's session. It has the session's facts (sessionId, platform, resolved with the
device type and OS that were chosen, installed, screenSize) and these methods:
| Method | What it does |
|---|---|
device.install(source) | Installs another app: { path } with an absolute path, or { url, sha256 } |
device.uninstall(bundleId) | Removes an app |
device.apps() | Lists the installed apps |
device.openUrl(url, bundleId?) | Opens a URL, in one app or in whichever app handles it |
device.setOrientation(orientation), device.pressButton(button) | As on screen |
device.screenshot() | A picture of the screen, as bytes |
For anything else the runtime can do, call the procedure by its id. The runtime fixture has one method,
call(id, input), and device.runtime is the same thing bound to this device. Each call shows as a step in
the report and the trace:
test('reads the device log', async ({ device }, testInfo) => {
const { lines } = await device.runtime.call('logs.read', {
sessionId: device.sessionId,
limit: 100,
})
await testInfo.attach('device log', {
body: JSON.stringify(lines, null, 2),
contentType: 'application/json',
})
})The protocol reference lists every procedure with its input and output.
Requiring a capability
Platforms differ. test.requires skips a file's or a describe's tests on a platform that does not declare a
capability, and records the reason:
test.describe('gestures', () => {
test.requires('input.touch')
test('a long press is recognised', async ({ screen }) => {
await screen.getByTestId('nav-gestures').tap()
await screen.getByTestId('gesture-area').longPress()
await expect(screen.getByTestId('last-gesture')).toHaveText('Last gesture: long press')
})
})On Mac Catalyst that test is skipped with "requires input.touch, which catalyst does not declare". A second
argument asks for a value: test.requires('input.keyboard', 'keys') for key presses,
test.requires('input.buttons', 'back') for a button, test.requires('input.maxContacts', 2) for at least
two fingers. Call it at the top of a file or a describe, not inside a test. The
PlatformCapabilities type lists every capability.
A project whose own use needs something its platform lacks is refused before anything runs: launchArgs
or permissions on Mac Catalyst, for example, or an .apk as the app of an iOS project.
Your own fixtures
test.extend adds fixtures and options. A fixture function sets up, hands its value to the test with use,
and tears down after use returns.
// e2e/fixtures.ts
import { test as base, type Screen } from '@alivelabs/troupe'
interface TestUser {
email: string
password: string
}
export const test = base.extend<{ testUser: TestUser; signedIn: Screen }>({
// An option: a default that a project's `use` or `test.use` can replace.
testUser: [{ email: 'ada@ex.co', password: 'pw' }, { option: true }],
signedIn: async ({ screen, testUser }, use) => {
await screen.getByLabel('Email').fill(testUser.email)
await screen.getByLabel('Password').fill(testUser.password)
await screen.getByRole('button', { name: 'Sign in' }).tap()
await use(screen)
},
})// e2e/orders.e2e.ts
import { expect } from '@alivelabs/troupe'
import { test } from './fixtures.ts'
test('lists past orders', async ({ signedIn }) => {
await signedIn.getByRole('tab', { name: 'Orders' }).tap()
await expect(signedIn.getByRole('listitem')).toHaveCount(2)
})A fixture, like a test, must destructure its first parameter. Write a fixture as [value, options] to set:
| Option | Meaning |
|---|---|
scope: 'worker' | Set up once per worker instead of once per test |
option: true | A value the config's use and test.use can set. The config sets only fixtures marked this way, and the value must be JSON |
auto: true | Set up for every test, whether the test asks for it or not |
timeout | Its own time limit, outside the test's timeout |
title | The name its step has in the report |
box: true | Keep its steps out of the report |
mergeTests(testA, testB) combines the fixtures of several test objects. A fixture whose name starts with
_ is internal: the report and the trace viewer hide its steps unless you ask for them.