Skip to main content

New Recipe

Universal E2E Testing with Detox and Playwright

Published on February 12th, 2025 by Joshua Yoes

View recipe

Latest Ignite Release

View on Github

Proven Recipes for your React Native apps

Starting from scratch doesn’t always make sense. That’s why we made the Ignite Cookbook for React Native – an easy way for developers to browse and share code snippets (or “recipes”) that actually work.

Spin Up Your App In Record Time

Stop reinventing the wheel on every project. Use the Ignite CLI to get your app started. Then, hop over to the Ignite Cookbook for React Native to browse for things like libraries in “cookie cutter” templates that work for almost any project. It’s the fastest way to get a React Native app off the ground.

Animation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation Image

Find Quality Code When You Need It

The popular forum sites are great for finding code until you realize it’s based on an old version of React Native. Ignite Cookbook is a place for recipes that work as of the time they’re published – meaning, it worked when it was posted. And if it ever goes out of date, we’ll make sure the community knows on what version it was last working.

npx ignite-cli@latest new PizzaApp
cd PizzaApp

 export default defineConfig({
-  testDir: "./tests/playwright",
+  testDir: "./e2e/tests",
   /* Run tests in files in parallel */
   fullyParallel: true,

e2e
├─ detox
│  ├─ screens
│  │  ├─ LoginScreen.ts
│  │  └─ WelcomeScreen.ts
│  ├─ entry.ts
│  └─ setup.ts
├─ playwright
│  ├─ screens
│  │  ├─ LoginScreen.ts
│  │  └─ WelcomeScreen.ts
│  ├─ entry.ts
│  └─ setup.ts
├─ tests
│  └─ Login.test.ts
├─ screens.ts
├─ entry.ts
└─ jest.config.js

import { test } from "../entry";

test("Open up our app and use the default credentials to login and navigate to the demo screen", async ({
  loadApp,
  loginScreen,
  welcomeScreen,
}) => {
  await loadApp();
  await loginScreen.login();
  await welcomeScreen.launch();
});

import type { Fixtures } from "./screens";

/** Check for runtime globals that we are in a detox environment  */
export const isDetoxTestEnv = () =>
  // @ts-ignore
  typeof device !== "undefined";

/** Check for runtime globals that we are in a playwright environment */
export const isPlaywrightTestEnv = () =>
  // @ts-ignore
  globalThis._playwrightInstance !== undefined;

/** Our library-agnostic test function */
export type Test = (
  name: string,
  fn: (fixtures: Fixtures) => Promise<void>
) => void;

/**
 * This test function is a little funky, but it ensures that we don't accidentally
 * import playwright code into a detox environment, or vice versa.
 */
export const test: Test = (() => {
  const testEnvsLoaded = [isDetoxTestEnv(), isPlaywrightTestEnv()].filter(
    Boolean
  ).length;

  if (testEnvsLoaded !== 1) {
    throw new Error(
      `${testEnvsLoaded} test environments loaded. Only one is allowed. Check the isTestEnv functions to make sure they check for globals that are specific only to their test environment`
    );
  }

  if (isDetoxTestEnv()) {
    return require("./detox/entry").test;
  }
  if (isPlaywrightTestEnv()) {
    return require("./playwright/entry").test;
  }

  throw new Error(
    "Unknown test environment. Check the isTestEnv functions to make sure they check for globals that are specific only to their test environment"
  );
})();

export interface ILoginScreen {
  login(): Promise<void>;
}

export interface IWelcomeScreen {
  launch(): Promise<void>;
}

/** A fixture of all the page object models we can use in our tests */
export type Fixtures = {
  loadApp: () => Promise<void>;
  loginScreen: ILoginScreen;
  welcomeScreen: IWelcomeScreen;
  // ... you can add more as needed
};

import { expect, element, by } from "detox";
import type { ILoginScreen } from "../../screens";

export class DetoxLoginScreen implements ILoginScreen {
  async login() {
    await expect(element(by.text("Log In"))).toBeVisible();
    await element(by.text("Tap to log in!")).tap();
  }
}

import { expect, element, by } from "detox";
import type { IWelcomeScreen } from "../../screens";

export class DetoxWelcomeScreen implements IWelcomeScreen {
  async launch() {
    await expect(
      element(by.text("Your app, almost ready for launch!"))
    ).toBeVisible();
    await element(by.text("Let's go!")).tap();
    await expect(
      element(by.text("Components to jump start your project!"))
    ).toBeVisible();
  }
}

import { device } from "detox";
import { resolveConfig } from "detox/internals";
import type { AppJSONConfig } from "@expo/config";
const appConfig: AppJSONConfig = require("../../app.json");

type Platform = ReturnType<typeof device.getPlatform>;

export async function detoxLoadApp() {
  const config = await resolveConfig();
  const platform = device.getPlatform();
  const isDebugConfig = config.configurationName.split(".").at(-1) === "debug";
  if (isDebugConfig) {
    return await openAppForDebugBuild(platform);
  } else {
    return await device.launchApp({
      newInstance: true,
    });
  }
}

async function openAppForDebugBuild(platform: Platform) {
  const deepLinkUrl = process.env.EXPO_USE_UPDATES
    ? // Testing latest published EAS update for the test_debug channel
      getDeepLinkUrl(getLatestUpdateUrl())
    : // Local testing with packager
      getDeepLinkUrl(getDevLauncherPackagerUrl(platform));

  if (platform === "ios") {
    await device.launchApp({
      newInstance: true,
    });
    await sleep(1000);
    await device.openURL({
      url: deepLinkUrl,
    });
  } else {
    await device.launchApp({
      newInstance: true,
      url: deepLinkUrl,
    });
  }

  await sleep(1000);
}

const getAppId = () => appConfig?.expo?.extra?.eas?.projectId ?? "";

const getAppSchema = () => appConfig?.expo?.scheme ?? "";

const getDeepLinkUrl = (url: string) =>
  `exp+${getAppSchema()}://expo-development-client/?url=${encodeURIComponent(
    url
  )}`;

const getDevLauncherPackagerUrl = (platform: Platform) =>
  `http://localhost:8081/index.bundle?platform=${platform}&dev=true&minify=false&disableOnboarding=1`;

const getLatestUpdateUrl = () =>
  `https://u.expo.dev/${getAppId()}?channel-name=test_debug&disableOnboarding=1`;

const sleep = (t: number) => new Promise((res) => setTimeout(res, t));

import { DetoxWelcomeScreen } from "./screens/WelcomeScreen";
import { DetoxLoginScreen } from "./screens/LoginScreen";
import type { Fixtures } from "../screens";
import type { Test } from "../entry";
import { detoxLoadApp } from "./setup";

const fixtures: Fixtures = {
  loadApp: detoxLoadApp,
  loginScreen: new DetoxLoginScreen(),
  welcomeScreen: new DetoxWelcomeScreen(),
};

export const test: Test = (name, fn) =>
  globalThis.test(name, (done) => {
    fn(fixtures)
      .then(() => done())
      .catch(done.fail);
  });

import { expect, Page } from "@playwright/test";
import type { ILoginScreen } from "../../screens";

export class PlaywrightLoginScreen implements ILoginScreen {
  constructor(private page: Page) {}

  async login() {
    await expect(this.page.locator("[data-testid='login-heading']")).toHaveText(
      "Log In"
    );
    await this.page.locator("[data-testid='login-button']").click();
  }
}

import { expect, Page } from "@playwright/test";
import type { IWelcomeScreen } from "../../screens";

export class PlaywrightWelcomeScreen implements IWelcomeScreen {
  constructor(private page: Page) {}

  async launch() {
    await expect(
      this.page.getByText("Your app, almost ready for launch!")
    ).toBeVisible();
    await this.page.getByText("Let's go!").click();
    await expect(
      this.page.getByText("Components to jump start your project!")
    ).toBeVisible();
  }
}

import { test as base } from "@playwright/test";
import { playwrightLoadApp } from "./setup";
import { PlaywrightLoginScreen } from "./screens/LoginScreen";
import { PlaywrightWelcomeScreen } from "./screens/WelcomeScreen";
import type { Fixtures } from "../screens";

export const test = base.extend<Fixtures>({
  loadApp: async ({ page }, use) => {
    await use(() => playwrightLoadApp(page));
  },
  loginScreen: async ({ page }, use) => {
    await use(new PlaywrightLoginScreen(page));
  },
  welcomeScreen: async ({ page }, use) => {
    await use(new PlaywrightWelcomeScreen(page));
  },
});

import type { Page } from "@playwright/test";

export async function playwrightLoadApp(page: Page) {
  await page.goto("http://localhost:3000");
}

{
  "scripts": {
    "detox:build:ios:debug": "detox build --c ios.sim.debug",
    "detox:test:debug": "detox test --configuration ios.sim.debug"
  }
}

yarn detox:build:ios:debug
yarn start
yarn detox:test:debug

{
  "scripts": {
    "playwright:build": "yarn bundle:web && yarn serve:web",
    "playwright:test": "yarn playwright test"
  }
}

yarn playwright:build
yarn playwright:test

yarn playwright:test --ui

 module.exports = {
   preset: "jest-expo",
   setupFiles: ["<rootDir>/test/setup.ts"],
+  testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/e2e/"],
 }

export default {
-  API_URL: "CHANGEME",
+  API_URL: "https://api.rss2json.com/v1/",
}

updated 28 seconds ago
Animation Image

Backed By A Community of React Native Experts

The Ignite Cookbook isn’t just a random group of code snippets. It’s a curated collection of usable code samples that the Infinite Red team’s used in their own React Native projects. Having worked with some of the biggest clients in the tech industry, we know a thing or two about keeping our code to a high standard. You can code confidently!

Animation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation Image