Troupeby AliveDocsSign in

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

FixtureScopeWhat it is
screentestThe foreground app's screen: locators and screen-wide input. screen.system reaches system UI
apptestThe app under test, isolated and launched before the test
testInfotestThe running test: title, retry, output directory, attach
deviceworkerThe device session the worker holds
runtimeworkerTyped 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:

OptionMeaningDefault
platformios, android or catalyst (or the full id)none: required for a device
deviceTypeA device type by id or exact name, such as 'iPhone 18 Pro' or 'Pixel 8'the platform's default
osThe OS version: '27.0' on iOS, an API level such as '36' on Androidthe newest installed one the device type runs
arch'arm64' or 'x86_64'the host's
variantThe Android system image tag, such as 'google_apis''google_apis'
localeA BCP 47 locale, applied at boot'en-US'
timeZoneAn IANA time zonethe host's
orientationThe orientation the device starts in'portrait'
appearance'light' or 'dark''light'
appThe build to install: a path relative to the config, { url, sha256 }, or xcodeBuild({ … })none
deviceTimeoutThe longest a worker waits for its device, in milliseconds360,000 on iOS, 300,000 on Android, 120,000 on Catalyst

These shape each test. They are test-scoped:

OptionMeaningDefault
bundleIdThe app to launchthe app that app installed
appIsolationHow the app is reset before each test'relaunch'
launchArgsArguments passed to the app at launchnone
launchEnvEnvironment variables passed to the app at launchnone
launchTimeoutHow long a launch may take, in milliseconds30,000
permissionsPermissions granted before launchnone
actionTimeoutHow long each action waits for its element, in millisecondswhat is left of the test's time
traceThe trace mode'retain-on-failure'

Changing options for some tests

test.use sets options for a file, or for one describe:

TypeScript
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.

ValueBefore each testWhat survives from the test before
'relaunch' (default)Terminates the app and launches itData on disk: sign-ins, databases, preferences
'clear'Wipes the app's data, then launchesNothing
'reinstall'Uninstalls the app and installs the build again, then launchesNothing
'none'Brings the running app forwardEverything, 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.

MethodWhat 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.bundleIdThe app's bundle id
TypeScript
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:

TypeScript
test.use({ permissions: ['location', 'photos'] })
PlatformPermissions it can set
iOS Simulatorcalendar, contacts, location, locationAlways, microphone, motion, photos, reminders
Android Emulatorcalendar, camera, contacts, location, locationAlways, microphone, motion, notifications, photos
Mac Catalystnone

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:

MethodWhat 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:

TypeScript
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:

TypeScript
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.

TypeScript
// 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)
  },
})
TypeScript
// 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:

OptionMeaning
scope: 'worker'Set up once per worker instead of once per test
option: trueA 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: trueSet up for every test, whether the test asks for it or not
timeoutIts own time limit, outside the test's timeout
titleThe name its step has in the report
box: trueKeep 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.

Next

esc

    Type to search every page, the protocol reference and the cloud API.