# e2e and Expo: iOS and Android tests on every pull request, with no Mac in CI

> Run e2e's iOS and Android tests on Expo Simulators from a Linux runner. One agent test covers SwiftUI, Jetpack Compose and the web, replays without model calls, and runs on every pull request.

By Kacper Kapuściak. Published 2026-11-09. Canonical URL: https://e2e.com/blog/e2e-and-expo

If you've ever tried to run iOS tests in CI, you know where it starts to hurt. The simulator needs Xcode, Xcode needs a Mac, and the Mac runner is the most expensive line on your CI bill. Android looks easier until the emulator needs hardware virtualization that your runner doesn't have. So mobile end-to-end tests often end up running on one developer's laptop, the night before a release.

Expo just took the device off your machine. [Expo Simulators](https://expo.dev/services/simulators) gives you iOS simulators and Android emulators in the cloud, on demand, as many at once as you need. Expo calls them "simulators built for agents", and that's exactly how [e2e](https://e2e.com/) uses them.

[Video: Introduction to Verification Engineering](https://www.youtube.com/watch?v=2OFB4B7Vy10)

## One test for SwiftUI, Jetpack Compose and the web

Expo's [`with-e2e` example](https://e2e.com/examples/expo-simulators) looks simple: a name field, a Greet button and a line that says hello. The interesting part is underneath. The form is built with Expo UI, so the same screen renders as real SwiftUI on iOS, real Jetpack Compose on Android and a regular HTML element on the web. e2e runs one test file against all three, and the agent step never names the field or the button.

Here's the test that runs it:

```ts
import { expect, test } from "e2e";

test("the agent gets a greeting", async ({ app, agent, screen }) => {
  await app.open();

  await agent.act("get the app to greet {name}", { params: { name: "Grace" } });

  await expect(screen.getByTestId("greeting")).toHaveText("Hello, Grace!");
  await agent.assert("the app greets Grace by name");
});
```

`app.open()` launches the app. No model is involved yet.

Then `agent.act` gets a goal: get the app to greet Grace. The test doesn't say which field to type into or which button to press. The agent reads the screen, finds the name field, types "Grace" and taps Greet. It doesn't care that the field is a SwiftUI text field on one platform and a Compose one on the other. To e2e it's just an implementation detail.

The next line is a plain locator assertion. It finds the element with the `greeting` test ID and checks that it reads exactly "Hello, Grace!", with no model call. Then `agent.assert` asks a separate judge model to look at the screen and confirm that the app greets Grace by name.

The same repo has the deterministic version of this flow:

```ts
test("greets the name you type", async ({ app, screen }) => {
  await app.open();

  await screen.getByTestId("name").fill("Ada");
  await screen.getByTestId("greet").tap();

  await expect(screen.getByTestId("greeting")).toHaveText("Hello, Ada!");
});
```

Both files run in the same suite, so you decide per step whether to [describe a goal using agentic APIs or script it deterministically](https://e2e.com/docs/core-concepts).

## You pay for the agent once

On the first run, every action of the agent needs a model call. When the step passes and the check after it confirms the result, e2e saves the actions to `.e2e/cache`. The next run [replays them without asking the model](https://e2e.com/docs/cache).

In the e2e repo's copy of this Expo example, the first run took 31.6 seconds and made 11 model calls. The next one took 9.7 seconds and made 2.

When someone redesigns the screen and the recorded button is gone, the replay stops there. The agent takes over from wherever the app is, finds the new path, and records it for the next run. You never need to touch the test.

## Run it on Expo Simulators

On your laptop, the example drives your local simulator. To run it on Expo Simulators, it only needs a build ID:

```ts
{
  name: 'ios',
  engine: mobile({
    platform: 'ios',
    device: iosBuildId
      ? expoSimulators({ buildId: iosBuildId, device: 'iPhone 17' })
      : process.env.E2E_DEVICE,
  }),
  app,
}
```

Make a simulator build and pass its ID to the suite:

```bash
npx eas-cli@latest build --platform ios --profile e2e
E2E_IOS_BUILD_ID=<build id> npm run test:e2e:ios
```

The output looks like this, here with two workers:

```txt
ℹ leasing 2 ios device(s) from eas-simulators
ℹ eas-simulators (1 of 2): simulator session 01a0…, https://expo.dev/accounts/acme/projects/shop/simulator-sessions/01a0…
ℹ eas-simulators: leased 01a0…
```

[`@e2e-dev/eas`](https://e2e.com/docs/integrations/eas) asks Expo Simulators for one simulator per worker. Expo Simulators boots it, installs your build, and starts an agent-device daemon next to it, and e2e sends every tap and every screen read to that daemon. The link in the log opens the session on [expo.dev](https://expo.dev), so you can watch the test tap through your app while it runs.

A session takes about three minutes to start, because it queues, boots and installs your build. All sessions start in parallel before the first test, and Expo Simulators stops any session that sits idle for ten minutes, so a crashed run doesn't leave a simulator running.

## Every pull request, from a Linux runner

The example ships an Expo workflow, `.eas/workflows/e2e.yml`, that builds the app for iOS and Android and then runs the whole suite on Expo Simulators. It runs on every pull request and every push to `main`, and your iOS tests run from a cost-effective Linux machine.

Setting it up takes three steps:

1. Connect the GitHub repository to your Expo project.
2. Add `EXPO_TOKEN`, a robot token with access to the project, as a secret in the `preview` environment.
3. Add an AI provider. Here we use Vercel AI Gateway, so add `AI_GATEWAY_API_KEY` if you want the agent tests to run. Without it, the agent tests are skipped and the deterministic tests still run.

You can also start the workflow by hand:

```bash
npx eas-cli@latest workflow:run .eas/workflows/e2e.yml
```

## Agents on both ends

Expo's simulators let coding agents install a build, tap through your app, and check their work before opening a pull request. With e2e, that check becomes a test you can keep in your codebase and rerun.

`npx e2e init` adds [the e2e skill that tells coding agents how to write and run tests](https://e2e.com/docs/coding-agents), and the MCP server config for Claude Code and Cursor. The coding agent that builds a feature can write its e2e test in the same run. From then on, Expo Workflows runs that test on every pull request, and Expo Simulators gives it a fresh device each time.

> "Each e2e worker gets its own Expo Simulators session, so tests never fight over the same machine.
>   When the run ends, you have a recording of every session, so you can see exactly what the test
>   did."
>
> Szymon Dziedzic, engineer at Expo

## Try it

Create your own copy of the example:

```bash
npx create-expo --example with-e2e
```

Expo Simulators is in preview, so check your account first with `npx eas-cli@latest simulator:availability`. Expo's docs have a guide for setting up e2e in your project, and our docs cover Expo Simulators and Expo Workflows step by step.

- Expo's e2e example: [github.com/expo/examples/tree/master/with-e2e](https://github.com/expo/examples/tree/master/with-e2e)
- Expo Simulators: [expo.dev/services/simulators](https://expo.dev/services/simulators)
- e2e on Expo Simulators: [e2e.com/docs/integrations/eas](https://e2e.com/docs/integrations/eas), [docs.expo.dev/guides/using-e2e](https://docs.expo.dev/guides/using-e2e/)
- e2e on Expo Workflows: [e2e.com/docs/ci/eas-workflows](https://e2e.com/docs/ci/eas-workflows)

If this saves you a Mac mini, a [star on GitHub](https://github.com/tester-army/e2e) helps more people find e2e.
