# Mobile apps

> Test an app on the iOS simulator: set it up, find elements, tap and swipe, and know what each test starts from.

Source: https://rehearsal.dev/retest/platforms/mobile

Retest drives apps on the iOS simulator through XCTest, with a pinned build of WebDriverAgent. Each test gets a fresh simulator.

## Requirements

- A Mac on Apple silicon, with Xcode 26.5 and the iOS 26.5 simulator runtime. That is the set Retest was tested with.
- A simulator build of your app: its `.app` bundle.
- The WebDriverAgent executor, built on your Mac with `npx retest install webdriveragent`.

`retest doctor` reads the executor from Retest's cache and checks that the app bundle is where the config says. It starts nothing.

## Configure the app

```ts filename="retest.config.ts"
const config = defineConfig({
  apps: {
    phone: {
      platform: 'ios-simulator',
      appPath: './TaskPhone.app',
      device: 'iPhone 17',
      runtime: '26.5',
      arguments: ['-reset', '-serviceURL', 'http://127.0.0.1:4310'],
    },
  },
})
```

- `appPath` is relative to the config. `device` and `runtime` name the simulator's device type and iOS version.
- `arguments` and `environment` reach the app at launch. Records keep only their count and a SHA-256 hash, never their text.
- A native app takes no `baseUrl`, viewport, emulation, proxy or saved browser state.

## Locators

- `getByTestId` finds an element by its accessibility identifier.
- `getByRole`, `getByLabel` and `getByText` find elements by role, label and text.
- Finders chain to look inside an element, and `first()`, `last()` and `nth()` pick one match.
- CSS and `getByPlaceholder` are refused, and every action needs exactly one match.

## Actions and gestures

```ts filename="tests/phone.retest.ts"
import { expect, secret, test } from '@rehearsal-labs/retest'

test('signs in on the phone', { apps: ['phone'] }, async ({ phone }) => {
  await phone.getByTestId('account-field').fill('ada')
  await phone.getByTestId('password-field').fill(secret('password'))
  await phone.getByTestId('sign-in-button').tap()
  await expect(phone.getByTestId('signed-in-account')).toHaveText('ada')
})
```

| Call | What it does |
| --- | --- |
| `tap()` | Taps an element. On iOS, elements take `tap()`, not `click()`. |
| `fill(value)`, `press(key)`, `scroll({ x, y })` | Type, press a key or scroll, as on the web |
| `swipe(direction)` | Swipes `up`, `down`, `left` or `right`, on the page or on an element |
| `keyboard.wait()`, `keyboard.dismiss()` | Waits for the software keyboard, or puts it away |
| `keyboard.dismissFirstRunCard()` | Presses Continue on the card about sliding to type, when the keyboard shows it |
| `alert.accept(button)`, `alert.dismiss(button)` | Presses the one button with that label in the alert in front of the app, such as `alert.accept('Allow')` |

An iOS app has no `goto`, `select`, `check` or `uncheck`, and calling one is a type error. The swipe, keyboard and alert calls have run against a stand-in for the simulator, not yet against a real one.

## Checks

Native elements take `toBeVisible`, `toBeHidden`, `toBeEnabled`, `toHaveText`, `toHaveValue`, `toBeSelected` and `toHaveCount`, each with `.not`.

- Retest's own process judges each check from its view of the app. A verdict the test sends cannot soften a failure.
- A text check on an editable field is refused. Use `toHaveValue` there.
- A state the platform does not expose is unsupported, such as selected on an element that has none.

## What each test starts from

- A test starts a fresh simulator once it holds everything it needs, and the simulator is deleted when the test ends.
- An argument such as `-reset` asks the app for its own reset, when it has one. Retest records it as the app's request.
- Data on your servers carries over between tests. A fresh simulator is not a fresh database.
- One test uses one app per simulator device type and runtime. A test that asks for two is refused before it starts.

> [!NOTE]
> Unknown outcomes work as on the web: Retest refuses a stale element, records input whose outcome is unknown, and never sends it again.

## Secrets in an app

An app has no web origin, so a secret is bound to its bundle identifier instead. A `fill` with `secret()` types into the app only once `secretOrigins` names the identifier Retest read from the installed app.

```ts filename="retest.config.ts"
secretOrigins: { password: ['dev.retest.fixtures.taskphone'] },
```

## Android

Android apps are not built yet, and neither are real phones. [Coming soon](https://rehearsal.dev/retest/coming-soon.md) lists Android among the planned releases.
