Detox y Cucumber BDD para testing E2E en React Native

Detox + Cucumber BDD para testing E2E en React Native

Al terminar este post vas a tener Detox controlando un simulador de iOS y un emulador de Android, con una capa de feature files de Cucumber en lenguaje natural por encima. Cinco pasos: instalar Detox, conectar Cucumber, escribir la capa de soporte, escribir un feature, ejecutarlo.

Dónde encaja esta combinación

Detox + Cucumber no es el stack por defecto de E2E en React Native. La mayoría de los equipos se queda en estilo imperativo con Jest como runner, o tira por WebdriverIO o Maestro cuando quiere tests estilo flujo. Son elecciones razonables. Maestro, especialmente, es una maravilla si lo único que quieres es grabar un flujo.

¿Por qué entonces sumar una capa de BDD encima de Detox?

Porque una vez que existen los feature files, QA y los PMs los pueden leer. Pueden pedirte escenarios que a ti no se te ocurriría escribir. Detox imperativo mantiene el diseño de los tests dentro de ingeniería. Cucumber lo saca fuera.

El coste son dos dependencias más y una capa de soporte, y en mi experiencia se mantiene bajo una vez que las definiciones de pasos se estabilizan. Añadir escenarios nuevos pasa a ser un trabajo de cinco minutos.

Por qué BDD para tests E2E

La mayoría de los ejemplos de Detox muestran código de test imperativo:

await element(by.id('email-input')).typeText('user@example.com');
await element(by.id('password-input')).typeText('password123');
await element(by.id('login-button')).tap();
await expect(element(by.id('home-screen'))).toBeVisible();

Funciona. Se lee como código, no como una especificación de test. Cuando un PM pregunta «¿qué cubre realmente el test de login?», lo mandas a un archivo TypeScript.

Cucumber te deja escribir el mismo test en Gherkin:

Feature: User Authentication

  Scenario: Successful login
    Given the app is launched
    And I am on the "Login" screen
    When I type "user@example.com" into the input with testID "email-input"
    And I type "password123" into the input with testID "password-input"
    And I tap the "Login" button
    Then I should see the "Home" screen

Los mismos comandos de Detox por debajo. Ahora cualquiera del equipo puede leer el test, revisarlo y sugerir los escenarios que te faltaron. Cuando uno falla, la línea que se rompió está en lenguaje claro, no en TypeScript.

Supuestos

Esta guía está escrita para:

  • React Native 0.74+ (bare workflow, no Expo)
  • TypeScript con la configuración estándar de Babel de RN
  • Host macOS (simulador de iOS y emulador de Android)
  • Xcode 16+ con las Command Line Tools, más un simulador de iOS creado (por ejemplo iPhone 16)
  • Android Studio con al menos un AVD creado (por ejemplo Pixel 7 API 35)
  • Node 18 o posterior

Lo monté sobre Detox 20, @cucumber/cucumber 12, ts-node y un React Native reciente. Las piezas con más probabilidad de cambiar son la firma del init de Detox y las claves de configuración de Cucumber, así que dejé anotadas las dos inline.

Si estás en Expo, Detox necesita un dev client personalizado. La capa de Cucumber es la misma de todos modos.

Paso 1. Instalar Detox y Cucumber

Detox, Cucumber y el cargador de TypeScript como dependencias de desarrollo:

yarn add -D detox@20.51.3 @cucumber/cucumber@12.9.0 ts-node@10.9.2 tsconfig-paths@4.2.0
cd ios && pod install && cd ..

Estas son las versiones contra las que se validó este recorrido, en el tag blog-2026-08 del repo.

El pod install de iOS hace falta porque Detox trae código nativo que hay que enlazar en la build de test.

También necesitas dos herramientas a nivel de host que no son paquetes npm:

brew tap wix/brew
brew install applesimutils

applesimutils es lo que usa Detox para controlar el simulador de iOS. Para Android necesitas un emulador funcionando. La CLI de Detox se invoca vía npx detox, así que no hace falta instalarla en global.

Paso 2. Los tres archivos de configuración

Tres archivos conectan todo: .detoxrc.js (o detox.config.js; Detox acepta los dos), cucumber.js y un tsconfig.cucumber.json ligero. La configuración de Cucumber tiene que llamarse cucumber.js (o .cjs/.mjs/.json): ese es el nombre de archivo que cucumber-js carga por defecto, y nada en este setup pasa un --config explícito.

.detoxrc.js

La configuración de Detox define las builds de tu app y los dispositivos destino.

Una cosa que .detoxrc.js NO hace aquí: conectar Detox con Cucumber. Cuando Cucumber es el runner, invocas cucumber-js directamente y la configuración testRunner propia de Detox nunca se consulta; el archivo de soporte del paso 3 es el único puente. .detoxrc.js solo describe apps y dispositivos:

module.exports = {
  apps: {
    'ios.debug': {
      type: 'ios.app',
      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/YourApp.app',
      build: 'xcodebuild -workspace ios/YourApp.xcworkspace -scheme YourApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
    },
    'android.debug': {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
      build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
    },
  },
  devices: {
    simulator: {
      type: 'ios.simulator',
      device: { type: 'iPhone 16' },
    },
    emulator: {
      type: 'android.emulator',
      device: { avdName: 'Pixel_7_API_35' },
    },
  },
  configurations: {
    'ios.sim.debug': {
      device: 'simulator',
      app: 'ios.debug',
    },
    'android.emu.debug': {
      device: 'emulator',
      app: 'android.debug',
    },
  },
};

cucumber.js

La configuración de Cucumber indica dónde viven los feature files, dónde viven las definiciones de pasos y cómo formatear la salida:

// Cuántos workers recibe esta ejecución: DETOX_PARALLEL=false lo fija a uno,
// DETOX_WORKERS lo sobrescribe, y si no, dos simuladores iOS pero un solo
// emulador Android (.detoxrc.js solo define un AVD).
const byPlatform = (process.env.DETOX_CONFIGURATION ?? '').includes('android') ? 1 : 2;
const workers =
  process.env.DETOX_PARALLEL === 'false' ? 1 : Number(process.env.DETOX_WORKERS ?? byPlatform);

module.exports = {
  default: {
    // ts-node compila el código de soporte TS/TSX; tsconfig-paths resuelve
    // los alias @app/*. Tienen que ser entradas de requireModule, no llamadas
    // a register() al inicio de este archivo: los workers en paralelo vuelven
    // a requerir el código de soporte pero nunca cargan el archivo de config.
    requireModule: ['ts-node/register', 'tsconfig-paths/register'],
    paths: ['src/features/**/__tests__/*.feature'],
    require: [
      'src/test-utils/cucumber/support/**/*.ts',
      'src/test-utils/cucumber/step-definitions/**/*.{ts,tsx}',
      'src/**/__tests__/**/*.cucumber.{ts,tsx}',
    ],
    format: ['./src/test-utils/cucumber/formatters/CheckmarkFormatter.js'],
    strict: true,
    parallel: workers,
    retry: workers > 1 ? 1 : 0,
    failFast: workers === 1,
  },
};

Las entradas de requireModule importan más de lo que parece. Registrar ts-node con un require() al inicio de este archivo funciona hasta la primera ejecución en paralelo: cada worker vuelve a requerir el código de soporte pero nunca carga el archivo de config, así que el registro tiene que ir en requireModule, que Cucumber reproduce dentro de cada worker. Las opciones propias de ts-node (transpile-only, JSX) viven en el bloque ts-node del tsconfig.cucumber.json.

OpciónQué hace
requireModuleMódulos cargados antes del código de soporte, en cada worker: ts-node y tsconfig-paths
pathsDónde viven los feature files de Gherkin
requireDónde viven las definiciones de pasos y archivos de soporte
formatFormatter personalizado para salida legible
strictFalla en pasos indefinidos o pendientes
parallelNúmero de workers, del workers de arriba dirigido por el entorno
retryUn reintento para tests inestables, solo en ejecuciones paralelas
failFastParar en el primer fallo, solo en ejecuciones secuenciales

tsconfig.cucumber.json

Un tsconfig ligero para el runtime de Cucumber, separado del tsconfig principal de la app:

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "ES2020",
    "lib": ["ES2020"],
    "moduleResolution": "node",
    "jsx": "react",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "strict": false,
    "baseUrl": ".",
    "rootDir": ".",
    "ignoreDeprecations": "6.0",
    "paths": { "@app/*": ["src/*"] },
    "types": ["node", "detox"]
  },
  "ts-node": {
    "transpileOnly": true,
    "compilerOptions": { "module": "commonjs", "jsx": "react" }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx"]
}

Hay tres cosas que conviene señalar. "types": ["node", "detox"] es lo que le enseña a TypeScript que device, element, by y waitFor son globales. Sin eso, cada definición de paso se llena de rojo en device.launchApp. "strict": false es una elección pragmática para archivos de test, donde los optional chains en los resultados de los escenarios necesitarían guardas constantes. Y la clave ignoreDeprecations junto al rootDir explícito mantienen a TypeScript 6 parseando la config, porque TypeScript 6 deprecia baseUrl y la resolución estilo node; en TypeScript 5, quita la línea de ignoreDeprecations, porque 5.x solo acepta "5.0".

Apunta cucumber-js a esta config con TS_NODE_PROJECT=tsconfig.cucumber.json en el script de npm.

Paso 3. La capa de soporte

Tres archivos configuran el ciclo de vida de Detox dentro de Cucumber: detox-setup.ts, hooks.ts y world.ts.

detox-setup.ts

Detox 20 expone su ciclo de vida programático a través del entrypoint detox/internals. El import público 'detox' te da device, element, by y waitFor. Los hooks del ciclo de vida (init, cleanup, onTestStart, onTestDone) viven bajo 'detox/internals':

import detox from 'detox/internals';

export const setupDetox = async (workerId: string = '0') => {
  const config = process.env.DETOX_CONFIGURATION;
  if (!config) {
    throw new Error('DETOX_CONFIGURATION is not set (e.g. ios.sim.debug)');
  }
  await detox.init({ workerId: `cucumber-worker-${workerId}` });
};

export const cleanupDetox = async () => {
  await detox.cleanup();
};

export { detox };

hooks.ts

El nexo entre el ciclo de vida de Cucumber y la gestión de dispositivos de Detox:

import { After, AfterAll, Before, BeforeAll, Status } from '@cucumber/cucumber';
import { execSync } from 'child_process';
import { device } from 'detox';

import { cleanupDetox, detox, setupDetox } from './detox-setup';
import { DetoxWorld } from './world';

// Los simuladores de iOS 17+ muestran la hoja del sistema "¿Guardar contraseña?"
// tras enviar cualquier formulario con contraseña. Detox no llega a las hojas
// del sistema, así que cada espera posterior al login expira detrás de ella.
// Esto escribe la misma preferencia que escribe el ajuste de Ajustes
// (General > Autorrelleno y contraseñas) antes del primer escenario.
const disablePasswordAutofill = (): void => {
  if (device.getPlatform() !== 'ios') return;
  try {
    execSync(
      `xcrun simctl spawn ${device.id} defaults write com.apple.WebUI AutoFillPasswords -bool false`,
      { stdio: 'ignore' }
    );
  } catch {
    console.warn('Could not disable simulator password autofill');
  }
};

BeforeAll({ timeout: 180 * 1000 }, async function () {
  const workerId = process.env.CUCUMBER_WORKER_ID ?? '0';
  await setupDetox(workerId);
  disablePasswordAutofill();
  await device.launchApp({
    newInstance: true,
    launchArgs: { detoxEnableSynchronization: 0 },
  });
  await device.enableSynchronization();
});

Before({ timeout: 30000 }, async function (this: DetoxWorld, { pickle }) {
  await detox.onTestStart({
    title: pickle.name,
    fullName: pickle.name,
    status: 'running',
  });
  await device.disableSynchronization();
  await device.reloadReactNative();
  await new Promise(resolve => setTimeout(resolve, 500));
  await device.enableSynchronization();
});

After(async function (this: DetoxWorld, { pickle, result }) {
  const testStatus = result?.status === Status.PASSED ? 'passed' : 'failed';
  if (result?.status === Status.FAILED) {
    try {
      await device.takeScreenshot(pickle.name);
    } catch (error) {
      console.error('Failed to take screenshot:', error);
    }
  }
  await detox.onTestDone({
    title: pickle.name,
    fullName: pickle.name,
    status: testStatus,
  });
});

AfterAll(async function () {
  await cleanupDetox();
});
HookTimeoutQué hace
BeforeAll180sLevanta el simulador, desactiva el autorrelleno de contraseñas, lanza la app
Before30sRecarga React Native para un estado limpio por escenario
AfterdefaultToma un screenshot si falla, notifica a Detox
AfterAlldefaultCierra Detox

La secuencia de sincronización importa. Lanza la app con la sincronización deshabilitada (detoxEnableSynchronization: 0), y después reactívala una vez que la app está corriendo. Esto esquiva el timeout de Detox que aparece cuando la carga inicial del bundle es lenta. La recarga por escenario recibe el mismo trato: suspende la sincronización, recarga, dale un momento al bundle, reactívala. El seguimiento de inactividad de Detox puede engancharse a un timer creado durante la evaluación del bundle y esperarlo hasta que el paso agote su tiempo, mientras la app está ociosa en su primera pantalla.

El helper de autorrelleno se gana el puesto en cuanto un escenario envía una contraseña. Sin él, el simulador ofrece guardar la credencial en una hoja del sistema que se coloca encima de tu app, y la siguiente espera de visibilidad falla con un timeout que apunta a una pantalla perfectamente sana.

world.ts

Un Cucumber World personalizado que lleva el contexto de Detox entre pasos:

import { IWorldOptions, setDefaultTimeout, setWorldConstructor, World } from '@cucumber/cucumber';
import { device } from 'detox';

// Las step definitions esperan hasta 20s a través de Detox (waitFor().withTimeout),
// pero Cucumber mata cualquier paso que dure más de 5s por defecto. Sube el
// límite por encima de la espera más larga que quieras pedirle a Detox.
setDefaultTimeout(60 * 1000);

export class DetoxWorld extends World {
  device: typeof device;
  testID: string | null;

  constructor(options: IWorldOptions) {
    super(options);
    this.device = device;
    this.testID = null;
  }

  setTestID(id: string) { this.testID = id; }
  getTestID(): string {
    if (!this.testID) throw new Error('No testID set');
    return this.testID;
  }
}

setWorldConstructor(DetoxWorld);

Llamar a setWorldConstructor es lo que conecta esta clase a cada this dentro de un paso. Si te olvidas de esa llamada, this.device queda undefined. La llamada a setDefaultTimeout importa igual: el límite de 5 segundos por defecto de Cucumber queda por debajo de las esperas de 20 segundos que las step definitions le piden a Detox, así que sin ella tus esperas más largas mueren como timeouts de paso antes de que Detox haya terminado de comprobar.

Paso 4. Escribir tu primer feature file

Los feature files son texto plano con sintaxis Gherkin. Cada escenario describe un flujo de usuario. Deja un archivo en src/features/Auth/__tests__/Login.feature:

Feature: User Authentication

  Scenario: Successful login
    Given the app is launched
    And I am on the "Login" screen
    When I type "testuser@example.com" into the input with testID "email-input"
    And I type "SecurePass123" into the input with testID "password-input"
    And I tap the "Login" button
    Then I should see the "Home" screen

  Scenario: Login with invalid credentials
    Given the app is launched
    And I am on the "Login" screen
    When I type "testuser@example.com" into the input with testID "email-input"
    And I type "WrongPassword" into the input with testID "password-input"
    And I tap the "Login" button
    Then I should see text "Invalid email or password"

  Scenario: Deep link opens password reset
    Given the app is launched via password reset deep link
    Then I should see the "Reset Password" screen

Los tags te permiten filtrar qué escenarios ejecutar:

@accessibility @voiceover @ios
Feature: VoiceOver Gestures

  @eaa
  Scenario: Navigate login form with swipe gestures
    ...

Después en tu comando de test (los features de accesibilidad viven en e2e/, fuera de los paths por defecto de la config, así que nómbralos):

yarn detox:ios:test 'e2e/**/*.feature' --tags "@accessibility and @ios"

Paso 5. Escribir las definiciones de pasos

Cada paso de Gherkin se mapea a una función. Las definiciones de pasos son lo que reutilizas en cada feature file.

device, element, by y waitFor están disponibles aquí porque tsconfig.cucumber.json los declaró en el paso 2.

Pasos comunes

import { Given, When, Then } from '@cucumber/cucumber';

Given('the app is launched', async function () {
  await device.terminateApp();
  await device.clearKeychain(); // solo iOS: en Android es un no-op silencioso
  await device.launchApp({ newInstance: true });
  await new Promise(r => setTimeout(r, 500));
});

Given('I am on the {string} screen', async function (screen: string) {
  const testID = `${screen.toLowerCase().replace(/\s+/g, '-')}-screen`;
  await waitFor(element(by.id(testID)))
    .toBeVisible()
    .withTimeout(20000);
});

When('I tap the {string} button', async function (name: string) {
  const testID = `${name.toLowerCase().replace(/\s+/g, '-')}-button`;
  await element(by.id(testID)).tap();
});

When('I type {string} into the input with testID {string}',
  async function (text: string, testID: string) {
    await waitFor(element(by.id(testID)))
      .toBeVisible()
      .withTimeout(5000);
    await element(by.id(testID)).replaceText(text);
  }
);

Then('I should see the {string} screen', async function (screen: string) {
  const testID = `${screen.toLowerCase().replace(/\s+/g, '-')}-screen`;
  await new Promise(r => setTimeout(r, 500));
  await waitFor(element(by.id(testID)))
    .toBeVisible()
    .withTimeout(20000);
});

Then('I should see text {string}', async function (text: string) {
  await waitFor(element(by.text(text)))
    .toBeVisible()
    .withTimeout(5000);
});

Tres patrones que conviene fijar aquí. Primero, una convención consistente de testID: los nombres de pantalla pasan a kebab-case con un sufijo -screen o -button. “Login” mapea a login-screen, “Home” a home-screen, “Submit” a submit-button. Segundo, cada aserción pasa por waitFor con un timeout, no expect directo. Las animaciones y llamadas de red necesitan tiempo para asentarse y expect no se lo da. Tercero, replaceText en vez de typeText. typeText escribe a continuación del texto que ya hay. replaceText limpia primero. Para inputs de formulario quieres el segundo.

Guarda el archivo como src/test-utils/cucumber/step-definitions/common.cucumber.tsx y Cucumber lo va a recoger gracias al glob de tu cucumber.js.

Estrategias de búsqueda de elementos

A veces by.id() no basta. Una definición de paso que no se rompe entre iOS y Android prueba múltiples estrategias:

When('I tap the text {string}', async function (text: string) {
  try {
    await element(by.text(text)).tap();
  } catch {
    try {
      await element(by.label(text)).tap();
    } catch {
      const testID = text.toLowerCase().replace(/\s+/g, '-');
      await element(by.id(testID)).tap();
    }
  }
});

Prueba by.text() primero (texto visible), pasa a by.label() (accessibility label) si no aparece, y termina en by.id() (testID). Esto cubre los botones que renderizan texto de forma distinta entre plataformas.

Con qué habla la app durante estos tests

Los escenarios de login escriben credenciales y esperan una pantalla Home, lo que plantea la pregunta que todo setup de E2E tiene que responder: ¿contra qué backend se ejecuta la app? Ejecútala contra uno real y tu suite falla cada vez que staging estornuda. La respuesta determinista es mockear a nivel de bundle con un flag de build E2E, que es un post en sí mismo: Mocking en runtime de Metro para tests E2E deterministas en React Native. Hasta que tengas esa capa, apunta la build E2E a un entorno de test estable y trata los cortes de red como fallos de la suite, no de los tests.

Un formatter personalizado

La salida por defecto de Cucumber es ruidosa. Un formatter personalizado te da resultados limpios, más fáciles de leer en los logs de CI:

✓ Feature: User Authentication
  ✓ Scenario: Successful login (2340ms)
    ✓ Given the app is launched (890ms)
    ✓ And I am on the "Login" screen (450ms)
    ✓ When I type "testuser@example.com" into the input with testID "email-input" (120ms)
    ✓ And I type "SecurePass123" into the input with testID "password-input" (95ms)
    ✓ And I tap the "Login" button (85ms)
    ✓ Then I should see the "Home" screen (700ms)

  ✗ Scenario: Login with expired token (1890ms)
    ✓ Given the app is launched (850ms)
    ✓ And I am on the "Login" screen (420ms)
    ✗ Then I should see the "Session Expired" screen (620ms)
      Error: Element not found: session-expired-screen

2 scenarios (1 passed, 1 failed)
12 steps (11 passed, 1 failed)

El formatter es una clase que se suscribe a los eventos envelope de Cucumber (testStepFinished, testCaseFinished), mapea el estado de cada paso a un icono y un color, e imprime la línea. El mío además trackea pickles, mapea los pasos de test a su texto Gherkin, cronometra cada paso y muestra un resumen con recuento de pasados y fallidos, lo que lo deja en unas 250 líneas. La salida de ejemplo del principio de esta sección es lo que produce; el archivo es src/test-utils/cucumber/formatters/CheckmarkFormatter.js en el repo enlazado al final.

Ejecución en paralelo

Detox puede ejecutar escenarios en múltiples simuladores. El setting parallel de Cucumber dirige el número de workers, y cada worker recibe su propia instancia de Detox.

# Ejecutar con 3 simuladores en paralelo
DETOX_WORKERS=3 yarn detox:ios:test:parallel

El hook BeforeAll lee CUCUMBER_WORKER_ID y se lo pasa a setupDetox para que cada worker se inicialice contra su propio simulador. Cucumber distribuye los escenarios entre los workers por ti.

ConfiguraciónLocalCI
Workers iOS2-33
Workers Android1-22
Reintentos en fallo11
Fail fastNoNo

Un consejo sobre las ejecuciones en paralelo. Mantén el fail-fast desactivado cuando ejecutes en paralelo. Un escenario inestable no debería matar a los otros workers, y con reintentos habilitados ese inestable tiene una segunda oportunidad mientras el resto sigue. En una ejecución con un solo worker, fail-fast activo está bien.

Testing de accesibilidad con BDD

Detox no controla VoiceOver ni TalkBack directamente. El testing manual con screen reader sigue haciendo falta. Lo que Detox sí puede comprobar es si los labels, roles y traits de accesibilidad están bien puestos en cada elemento. Escritas como escenarios de Gherkin, esas comprobaciones detectan una clase de regresión que nadie va a notar manualmente hasta que un usuario con VoiceOver abra la app.

Mi repo tiene dos feature files para esto, uno para patrones de iOS y otro para Android.

@accessibility @voiceover @ios @eaa
Feature: VoiceOver Gestures

  Scenario: Navigate login form with swipe right
    Given the app is launched
    And I am on the "Login" screen
    And VoiceOver focus is on the "Email" element
    When I swipe right to move to the next element
    Then VoiceOver focus should move to the next element
    And I should hear the accessibility label for "Password"

  Scenario: Activate login button with double tap
    Given the app is launched
    And I am on the "Login" screen
    And I have entered valid credentials
    And VoiceOver focus is on the "Login" button
    When I double tap to activate
    Then I should see the "Home" screen

  Scenario: Error announced via live region
    Given the app is launched
    And I am on the "Login" screen
    And I have entered invalid credentials
    When I double tap to activate
    Then the error message should be announced via a live region

Las definiciones de pasos para testing de accesibilidad mantienen estado:

interface AccessibilityState {
  focusedElementIndex: number;
  visitedElements: string[];
  lastAnnouncement: string | null;
  granularity: 'characters' | 'words' | 'lines' | 'headings' | 'default';
}

Esto trackea el orden de foco esperado, el texto de los anuncios y la granularidad de lectura. Unos 50 escenarios entre los dos feature files cubren labels, comportamiento de foco, anuncios de live regions y acciones personalizadas. Evitan que las regresiones obvias lleguen a producción.

Cómo ejecutarlo

Los scripts que uso viven en package.json así:

{
  "scripts": {
    "detox:ios:build": "ENVFILE=.env.e2e node scripts/timed-run.js detox build -c ios.sim.debug",
    "detox:ios:mock-validation": "DETOX_PARALLEL=false DETOX_LOGLEVEL=error DETOX_CONFIGURATION=ios.sim.debug TS_NODE_PROJECT=tsconfig.cucumber.json cucumber-js --tags @mock-validation",
    "detox:ios:test": "DETOX_PARALLEL=false DETOX_LOGLEVEL=error DETOX_CONFIGURATION=ios.sim.debug TS_NODE_PROJECT=tsconfig.cucumber.json cucumber-js --tags 'not @mock-validation'",
    "detox:ios:test:parallel": "DETOX_LOGLEVEL=error DETOX_CONFIGURATION=ios.sim.debug TS_NODE_PROJECT=tsconfig.cucumber.json cucumber-js --tags 'not @mock-validation'",
    "e2e:ios": "yarn detox:ios:build && yarn detox:ios:mock-validation && yarn detox:ios:test"
  }
}

Fíjate en que los scripts de test ejecutan cucumber-js directamente en vez de detox test. Con Cucumber como runner, no pasas por el wrapper de Detox. Detox se inicializa desde tu archivo de soporte. Todo lo demás viene de cucumber.js: los scripts solo añaden el entorno y un filtro de tags. timed-run.js es un pequeño wrapper que imprime cuánto tardó la build, y DETOX_PARALLEL=false fija una ejecución a un worker a través de la expresión workers de la config (la variante paralela lo deja sin definir, así que decide DETOX_WORKERS; la versión del repo además arranca antes los simuladores). El prefijo ENVFILE=.env.e2e es clave: react-native-config incrusta los valores del entorno en el binario nativo en tiempo de build, y los flags de E2E (APIs mockeadas, pantallas solo de test) viven en un .env.e2e commiteado lleno de valores de relleno. Si compilas sin él, la app se construye contra tu entorno de desarrollo, las pantallas de test nunca existen en el binario, y la etapa de mock-validation falla antes de que arranque la suite.

La cadena e2e:ios tiene una etapa intermedia que vale la pena copiar. Un smoke test de dos escenarios con el tag @mock-validation demuestra que la app arrancó contra su backend mockeado antes de que la suite completa se pase la ejecución entera descubriéndolo a base de timeouts. La mía comprueba que la capa de mocking en runtime de Metro está interceptando de verdad las peticiones.

Primera ejecución:

yarn e2e:ios
⏱️  Starting: detox build -c ios.sim.debug

Building app for ios.sim.debug...
xcodebuild ... ** BUILD SUCCEEDED **

$ cucumber-js
✓ Feature: User Authentication
  ✓ Scenario: Successful login (2340ms)
  ✓ Scenario: Login with invalid credentials (1820ms)

2 scenarios (2 passed)
12 steps (12 passed)

Si xcodebuild falla en la primera ejecución, comprueba que el simulador que aparece en .detoxrc.js realmente existe (xcrun simctl list devices). El fallo más común en la primera ejecución es un iPhone 16 hardcodeado que nunca creaste en Xcode.

Errores comunes

La sincronización es la parte más difícil. Detox intenta esperar a que la app esté idle automáticamente, pero las animaciones, timers y llamadas de red lo pueden confundir. El patrón de lanzar con sincronización deshabilitada (detoxEnableSynchronization: 0 y después enableSynchronization()) esquiva el timeout más común.

typeText añade, replaceText reemplaza. Si un campo tiene texto placeholder o input previo, typeText escribe a continuación de lo que ya hay. Usa replaceText para inputs de formularios donde quieres un valor limpio.

Screenshots cuando falla. El hook After captura un screenshot cuando un escenario falla. Sin eso, depurar fallos en CI es leer logs y adivinar. Nombra el screenshot con el nombre del escenario para poder relacionar un fallo con su imagen.

Describe comportamiento, no implementación. Escribe “When I log in”, no “When I type into email-input and tap login-button”. Los detalles de implementación van en las definiciones de pasos, no en el Gherkin. Si alguien que no es ingeniero no puede leer el feature file en voz alta y entenderlo, has dejado que se filtren detalles de implementación a la capa equivocada.

La estructura de archivos completa

src/
  test-utils/
    cucumber/
      formatters/
        CheckmarkFormatter.js    # Formatter personalizado ✓/✗
      step-definitions/
        common.cucumber.tsx      # Pasos compartidos (tap, type, navigate)
        auth.cucumber.tsx         # Pasos de autenticación
        accessibility.cucumber.tsx # Pasos de VoiceOver + TalkBack
      support/
        detox-setup.ts           # Inicialización de Detox
        hooks.ts                  # BeforeAll/Before/After/AfterAll
        world.ts                  # Contexto del Cucumber World
e2e/
  accessibility/
    VoiceOverGestures.feature    # Tests de screen reader para iOS
    TalkBackGestures.feature     # Tests de screen reader para Android

Qué ganas

El setup es un trabajo de una mañana. El primer feature file es una tarde. Después de eso, añadir escenarios es rápido porque las definiciones de pasos se reutilizan entre features.

Lo que has construido al final:

  1. Tests que cualquiera del equipo puede leer. Producto, QA, diseñadores. El archivo Gherkin es la especificación y el test en el mismo lugar, y cuando un escenario falla, el informe nombra el paso que se rompió en vez de una línea de código del test.
  2. Ejecución en paralelo que funciona con Detox. Tres simuladores, tres workers y una ejecución de CI mucho más corta.
  3. Cobertura de regresión de accesibilidad para labels, roles y traits, junto a la pasada manual con screen reader, no en su lugar.

Este post cubre testing E2E. Para tests unitarios y de integración uso MSW v2 para mockear la capa de red en vez de jest.fn(). Los dos combinan bien: MSW para tests rápidos y enfocados contra llamadas HTTP reales; Detox + Cucumber para flujos completos de usuario en un dispositivo real.

El código de este post viene de rn-warrendeleon, mi proyecto personal de React Native; el tag blog-2026-08 marca el estado exacto al que corresponde. El setup completo de Detox + Cucumber, las definiciones de pasos, el formatter personalizado y los feature files de accesibilidad viven todos ahí.

Warren de Leon
Warren de Leon

Software Engineering Manager. Recientemente lideré el equipo de Mobile Platform en Hargreaves Lansdown. Escribo sobre liderazgo técnico, React Native y cómo construir buenos equipos.

Ver perfil