Testing y Depuración en React Native 2026: Jest 30, RNTL, Maestro y DevTools

Guía completa de testing y depuración para React Native en 2026. Aprende a usar Jest 30, React Native Testing Library, MSW, Maestro para pruebas E2E con YAML, y las nuevas React Native DevTools que reemplazaron a Flipper.

Por qué testing y depuración importan más que nunca en 2026

Seamos honestos: durante años, el testing en React Native fue ese tema que todos sabíamos que era importante pero que, en la práctica, se dejaba para "después". Y ese "después" nunca llegaba. Las apps se lanzaban con tests mínimos, los bugs se descubrían en producción, y la depuración era un ejercicio de frustración con Flipper congelándose cada dos minutos.

En 2026, esa excusa ya no vale. Y te lo digo porque el ecosistema ha cambiado radicalmente. Con la New Architecture como estándar obligatorio desde React Native 0.82, con Fabric, TurboModules y el Bridgeless Mode como base de todo, la complejidad de las aplicaciones ha aumentado. Pero también han mejorado enormemente las herramientas. Jest 30 es brutalmente más rápido. React Native Testing Library ha madurado hasta convertirse en el estándar indiscutible. Maestro ha revolucionado las pruebas E2E con una simplicidad que Detox nunca logró. Y React Native DevTools ha reemplazado a Flipper con algo que realmente funciona. Por fin.

En esta guía vamos al grano. Vamos a recorrer toda la pirámide de testing — desde unit tests hasta E2E — con las herramientas que deberías estar usando hoy. Veremos código real, configuraciones prácticas, y las mejores prácticas que separan un proyecto profesional de uno que reza cada vez que hace deploy. Si ya leíste nuestros artículos sobre Navegación, Performance y Gestión de Estado, este es el complemento natural: de nada sirve una app rápida y bien arquitectada si no puedes verificar que funciona correctamente.

La pirámide de testing en React Native: estrategia antes que herramientas

Antes de hablar de Jest, RNTL o Maestro, necesitamos hablar de estrategia. Porque la herramienta más potente del mundo es inútil si no sabes dónde aplicarla.

La pirámide de testing sigue siendo el modelo más práctico para organizar tus pruebas:

        /\
       /  \        E2E (Maestro)
      /    \       — Flujos completos del usuario
     /------\
    /        \     Integration (RNTL)
   /          \    — Componentes + estado + navegación
  /------------\
 /              \   Unit (Jest)
/                \  — Funciones puras, hooks, stores
──────────────────
  • Unit tests (base de la pirámide): Son los más rápidos, los más baratos de escribir y mantener, y deberían cubrir la mayor parte de tu lógica. Funciones de utilidad, transformaciones de datos, stores de Zustand o Jotai, custom hooks — todo lo que es puro JavaScript se testea aquí con Jest.
  • Tests de componentes e integración (medio): Aquí verificas que tus componentes renderizan correctamente, responden a interacciones del usuario, y se integran bien con su estado y contexto. React Native Testing Library es la herramienta para esto.
  • Tests E2E (cima): Los más costosos pero los más cercanos a la experiencia real del usuario. Flujos completos como login, navegación, compra — aquí Maestro brilla con su sintaxis YAML declarativa.

La regla general: un 70% unit tests, un 20% integration tests, y un 10% E2E. Estos porcentajes no son dogma, pero te dan una dirección clara. Un error común es invertir la pirámide — escribir muchos tests E2E y pocos unit tests — lo que resulta en suites lentas, frágiles y difíciles de mantener.

Jest 30: más rápido, más ligero y con TypeScript nativo

Jest 30 llegó en 2025 y, francamente, es la actualización más significativa en años. Los números hablan por sí solos: 37% más rápido en ejecución y 77% menos uso de memoria. Si tu suite de tests tardaba 3 minutos, ahora tarda menos de 2. Si tu CI se quedaba sin memoria con proyectos grandes, ese problema probablemente desapareció.

Novedades clave de Jest 30

  • Soporte nativo para configuración TypeScript: Por fin puedes escribir jest.config.ts sin transpiladores adicionales. Jest 30 entiende TypeScript directamente.
  • unrs-resolver: El nuevo resolvedor de módulos escrito en Rust que es responsable de gran parte de la mejora de rendimiento. Reemplaza al resolvedor JavaScript anterior y es significativamente más rápido en la resolución de paths.
  • Explicit Resource Management con using: Soporte para la nueva sintaxis de TC39 que permite limpiar recursos automáticamente al final de un scope — perfecto para mocks y timers.
  • Eliminación de Node 14, 16, 19 y 21: Solo se soportan versiones LTS activas. Esto permitió optimizaciones internas importantes.
  • TypeScript 5.4 como mínimo: Si estás en una versión anterior, necesitas actualizar antes de migrar a Jest 30.

Configuración de Jest 30 para React Native

La configuración ahora se puede escribir directamente en TypeScript:

// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  preset: 'react-native',
  setupFilesAfterSetup: ['./jest.setup.ts'],
  transformIgnorePatterns: [
    'node_modules/(?!(react-native|@react-native|@react-navigation|expo|@expo|react-native-reanimated)/)',
  ],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{ts,tsx}',
  ],
  coverageThreshold: {
    global: {
      branches: 70,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

export default config;

Explicit Resource Management: el fin de los afterEach olvidados

Una de las novedades más elegantes de Jest 30 es el soporte para using, que permite que los mocks se limpien automáticamente:

// Antes de Jest 30 — fácil olvidar el cleanup
describe('API service', () => {
  let fetchSpy: jest.SpyInstance;

  beforeEach(() => {
    fetchSpy = jest.spyOn(global, 'fetch');
  });

  afterEach(() => {
    fetchSpy.mockRestore(); // Si olvidas esto, contaminas otros tests
  });

  it('hace la petición correcta', async () => {
    fetchSpy.mockResolvedValue(new Response('{"ok": true}'));
    // ...
  });
});

// Con Jest 30 y explicit resource management
describe('API service', () => {
  it('hace la petición correcta', async () => {
    using fetchSpy = jest.spyOn(global, 'fetch');
    fetchSpy.mockResolvedValue(new Response('{"ok": true}'));

    const result = await apiService.getData();
    expect(result).toEqual({ ok: true });
    // fetchSpy se restaura automáticamente al salir del scope
  });
});

Esto elimina una de las fuentes más comunes de tests que fallan intermitentemente: mocks que se quedan activos entre tests porque alguien olvidó el afterEach.

Unit tests prácticos con Jest 30

Veamos cómo se testean los patrones más comunes en una app React Native:

// utils/formatters.ts
export function formatCurrency(amount: number, currency = 'EUR'): string {
  return new Intl.NumberFormat('es-ES', {
    style: 'currency',
    currency,
  }).format(amount);
}

export function truncateText(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength - 3) + '...';
}

export function parseApiError(error: unknown): string {
  if (error instanceof Error) return error.message;
  if (typeof error === 'string') return error;
  return 'Error desconocido';
}
// __tests__/utils/formatters.test.ts
import { formatCurrency, truncateText, parseApiError } from '@/utils/formatters';

describe('formatCurrency', () => {
  it('formatea euros correctamente', () => {
    expect(formatCurrency(29.99)).toBe('29,99 €');
  });

  it('formatea dólares cuando se especifica', () => {
    expect(formatCurrency(29.99, 'USD')).toContain('29,99');
  });

  it('maneja valores negativos', () => {
    expect(formatCurrency(-15.5)).toBe('-15,50 €');
  });
});

describe('truncateText', () => {
  it('no trunca textos cortos', () => {
    expect(truncateText('Hola', 10)).toBe('Hola');
  });

  it('trunca y añade puntos suspensivos', () => {
    expect(truncateText('Este texto es muy largo', 10)).toBe('Este te...');
  });
});

describe('parseApiError', () => {
  it('extrae mensaje de Error', () => {
    expect(parseApiError(new Error('Network error'))).toBe('Network error');
  });

  it('devuelve strings directamente', () => {
    expect(parseApiError('Timeout')).toBe('Timeout');
  });

  it('maneja tipos desconocidos', () => {
    expect(parseApiError(42)).toBe('Error desconocido');
    expect(parseApiError(null)).toBe('Error desconocido');
  });
});

Testing de stores Zustand con Jest 30

Si leíste nuestro artículo sobre gestión de estado, sabes que Zustand es la opción más popular en 2026. La buena noticia es que testear stores Zustand es tremendamente sencillo:

// stores/useCartStore.ts
import { create } from 'zustand';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
  totalPrice: () => number;
}

export const useCartStore = create<CartState>()((set, get) => ({
  items: [],
  addItem: (item) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === item.id);
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
          ),
        };
      }
      return { items: [...state.items, { ...item, quantity: 1 }] };
    }),
  removeItem: (id) =>
    set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
  clearCart: () => set({ items: [] }),
  totalPrice: () =>
    get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}));
// __tests__/stores/useCartStore.test.ts
import { useCartStore } from '@/stores/useCartStore';

describe('CartStore', () => {
  beforeEach(() => {
    useCartStore.setState({ items: [] });
  });

  it('añade un producto al carrito', () => {
    useCartStore.getState().addItem({
      id: '1',
      name: 'Café Especial',
      price: 12.99,
    });

    const { items } = useCartStore.getState();
    expect(items).toHaveLength(1);
    expect(items[0].name).toBe('Café Especial');
    expect(items[0].quantity).toBe(1);
  });

  it('incrementa cantidad si el producto ya existe', () => {
    const addItem = useCartStore.getState().addItem;
    addItem({ id: '1', name: 'Café', price: 12.99 });
    addItem({ id: '1', name: 'Café', price: 12.99 });

    const { items } = useCartStore.getState();
    expect(items).toHaveLength(1);
    expect(items[0].quantity).toBe(2);
  });

  it('calcula el precio total correctamente', () => {
    const { addItem } = useCartStore.getState();
    addItem({ id: '1', name: 'Café', price: 10 });
    addItem({ id: '2', name: 'Tostadas', price: 5 });
    addItem({ id: '1', name: 'Café', price: 10 }); // +1 café

    expect(useCartStore.getState().totalPrice()).toBe(25);
  });

  it('elimina un producto del carrito', () => {
    useCartStore.getState().addItem({ id: '1', name: 'Café', price: 10 });
    useCartStore.getState().addItem({ id: '2', name: 'Tostadas', price: 5 });

    useCartStore.getState().removeItem('1');

    const { items } = useCartStore.getState();
    expect(items).toHaveLength(1);
    expect(items[0].id).toBe('2');
  });

  it('limpia todo el carrito', () => {
    useCartStore.getState().addItem({ id: '1', name: 'Café', price: 10 });
    useCartStore.getState().addItem({ id: '2', name: 'Tostadas', price: 5 });

    useCartStore.getState().clearCart();

    expect(useCartStore.getState().items).toHaveLength(0);
  });
});

Fíjate cómo no necesitamos renderizar ningún componente React. Los stores de Zustand son testables como JavaScript puro, lo que hace que estos tests sean extremadamente rápidos — estamos hablando de milisegundos por test.

React Native Testing Library: tests centrados en el usuario

Si Jest es el motor de tus tests, React Native Testing Library (RNTL) es el volante. Es la herramienta que te permite renderizar componentes React Native en un entorno de test y interactuar con ellos como lo haría un usuario real.

La filosofía de RNTL es clara: testea lo que el usuario ve y hace, no los detalles de implementación. No testeas si un estado interno cambió, ni si un hook se ejecutó. Testeas que cuando el usuario pulsa un botón, aparece el texto correcto en pantalla.

Queries fundamentales

RNTL ofrece tres familias de queries con comportamientos distintos:

  • getBy* — Lanza un error si no encuentra el elemento. Úsalo cuando el elemento debe estar presente.
  • queryBy* — Devuelve null si no encuentra el elemento. Úsalo para verificar que algo no está en pantalla.
  • findBy* — Es asíncrono, espera a que el elemento aparezca. Úsalo para elementos que aparecen después de una operación async.

Las queries más importantes en orden de preferencia:

  1. getByRole — La opción más accesible y robusta. Busca por rol semántico.
  2. getByText — Busca por el texto visible. Muy intuitivo.
  3. getByPlaceholderText — Para inputs con placeholder.
  4. getByDisplayValue — Para inputs con valor.
  5. getByTestId — El último recurso. Útil cuando no hay alternativa semántica.

Ejemplo práctico: testeando un componente de login

// components/LoginForm.tsx
import { useState } from 'react';
import { View, Text, TextInput, Pressable, ActivityIndicator, StyleSheet } from 'react-native';

interface LoginFormProps {
  onSubmit: (email: string, password: string) => Promise<void>;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async () => {
    if (!email.trim() || !password.trim()) {
      setError('Por favor completa todos los campos');
      return;
    }
    setError('');
    setIsLoading(true);
    try {
      await onSubmit(email, password);
    } catch (e) {
      setError('Credenciales incorrectas');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Iniciar Sesión</Text>

      {error ? <Text style={styles.error}>{error}</Text> : null}

      <TextInput
        placeholder="Correo electrónico"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
        style={styles.input}
      />

      <TextInput
        placeholder="Contraseña"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
        style={styles.input}
      />

      <Pressable
        onPress={handleSubmit}
        disabled={isLoading}
        style={styles.button}
        accessibilityRole="button"
      >
        {isLoading ? (
          <ActivityIndicator color="#fff" />
        ) : (
          <Text style={styles.buttonText}>Entrar</Text>
        )}
      </Pressable>
    </View>
  );
}
// __tests__/components/LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import { LoginForm } from '@/components/LoginForm';

describe('LoginForm', () => {
  const mockOnSubmit = jest.fn();

  beforeEach(() => {
    mockOnSubmit.mockReset();
  });

  it('renderiza los campos y el botón', () => {
    render(<LoginForm onSubmit={mockOnSubmit} />);

    expect(screen.getByText('Iniciar Sesión')).toBeTruthy();
    expect(screen.getByPlaceholderText('Correo electrónico')).toBeTruthy();
    expect(screen.getByPlaceholderText('Contraseña')).toBeTruthy();
    expect(screen.getByRole('button', { name: 'Entrar' })).toBeTruthy();
  });

  it('muestra error si los campos están vacíos', () => {
    render(<LoginForm onSubmit={mockOnSubmit} />);

    fireEvent.press(screen.getByRole('button', { name: 'Entrar' }));

    expect(screen.getByText('Por favor completa todos los campos')).toBeTruthy();
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });

  it('llama onSubmit con email y password', async () => {
    mockOnSubmit.mockResolvedValue(undefined);
    render(<LoginForm onSubmit={mockOnSubmit} />);

    fireEvent.changeText(
      screen.getByPlaceholderText('Correo electrónico'),
      '[email protected]'
    );
    fireEvent.changeText(
      screen.getByPlaceholderText('Contraseña'),
      'mypassword123'
    );
    fireEvent.press(screen.getByRole('button', { name: 'Entrar' }));

    await waitFor(() => {
      expect(mockOnSubmit).toHaveBeenCalledWith(
        '[email protected]',
        'mypassword123'
      );
    });
  });

  it('muestra error cuando las credenciales son incorrectas', async () => {
    mockOnSubmit.mockRejectedValue(new Error('Invalid credentials'));
    render(<LoginForm onSubmit={mockOnSubmit} />);

    fireEvent.changeText(
      screen.getByPlaceholderText('Correo electrónico'),
      '[email protected]'
    );
    fireEvent.changeText(
      screen.getByPlaceholderText('Contraseña'),
      'wrongpassword'
    );
    fireEvent.press(screen.getByRole('button', { name: 'Entrar' }));

    await waitFor(() => {
      expect(screen.getByText('Credenciales incorrectas')).toBeTruthy();
    });
  });

  it('desactiva el botón durante la carga', async () => {
    mockOnSubmit.mockImplementation(
      () => new Promise((resolve) => setTimeout(resolve, 1000))
    );
    render(<LoginForm onSubmit={mockOnSubmit} />);

    fireEvent.changeText(
      screen.getByPlaceholderText('Correo electrónico'),
      '[email protected]'
    );
    fireEvent.changeText(
      screen.getByPlaceholderText('Contraseña'),
      'mypassword123'
    );
    fireEvent.press(screen.getByRole('button'));

    // El texto "Entrar" desaparece durante la carga
    await waitFor(() => {
      expect(screen.queryByText('Entrar')).toBeNull();
    });
  });
});

Mejores prácticas con RNTL

Después de años trabajando con esta librería, estos son los patrones que realmente marcan la diferencia:

  • Prefiere getByRole y getByText sobre getByTestId. Si necesitas testID en todo, probablemente tu componente tiene problemas de accesibilidad.
  • Usa screen en lugar de destructurar el resultado de render. Es más limpio y consistente con la API de Testing Library para web.
  • No testees estados internos. No hagas expect(component.state.isLoading).toBe(true). Testea lo que el usuario ve: un spinner, un texto de carga, un botón desactivado.
  • Usa waitFor para operaciones asíncronas en lugar de timeouts arbitrarios.
  • Mockea las dependencias externas, no los componentes hijos. Cuanto más real sea tu test, más confianza te da.

MSW: mocking de APIs que no da vergüenza

Mock Service Worker (MSW) ha cambiado completamente la forma en que mockeamos APIs en tests. En lugar de interceptar fetch a nivel de JavaScript con jest.fn() (y créeme, he intentado que eso escale — no escala), MSW intercepta las peticiones a nivel de red, lo que significa que tu código de fetching se ejecuta exactamente como lo haría en producción.

En React Native, MSW requiere algunos polyfills porque el entorno no tiene todas las APIs web. Pero la configuración es sencilla:

Instalación y setup

# Dependencias necesarias
npx expo install msw react-native-url-polyfill fast-text-encoding
// jest.setup.ts
import 'react-native-url-polyfill/auto';
import 'fast-text-encoding';

// Configuración adicional para RNTL
import '@testing-library/react-native/extend-expect';

Definiendo handlers

// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

const API_URL = 'https://api.miapp.com';

export const handlers = [
  // GET — lista de productos
  http.get(`${API_URL}/products`, () => {
    return HttpResponse.json([
      { id: '1', name: 'Café Colombiano', price: 14.99, inStock: true },
      { id: '2', name: 'Té Matcha', price: 19.99, inStock: true },
      { id: '3', name: 'Chocolate Belga', price: 9.99, inStock: false },
    ]);
  }),

  // GET — producto individual
  http.get(`${API_URL}/products/:id`, ({ params }) => {
    const { id } = params;
    if (id === '999') {
      return HttpResponse.json(
        { message: 'Producto no encontrado' },
        { status: 404 }
      );
    }
    return HttpResponse.json({
      id,
      name: 'Café Colombiano',
      price: 14.99,
      inStock: true,
      description: 'El mejor café de origen único.',
    });
  }),

  // POST — login
  http.post(`${API_URL}/auth/login`, async ({ request }) => {
    const body = await request.json() as { email: string; password: string };

    if (body.email === '[email protected]' && body.password === 'correct') {
      return HttpResponse.json({
        user: { id: '1', name: 'Usuario Test', email: body.email },
        token: 'fake-jwt-token-12345',
      });
    }

    return HttpResponse.json(
      { message: 'Credenciales inválidas' },
      { status: 401 }
    );
  }),

  // POST — crear pedido
  http.post(`${API_URL}/orders`, async ({ request }) => {
    const body = await request.json() as { items: Array<{ id: string; quantity: number }> };
    return HttpResponse.json(
      {
        orderId: 'ORD-' + Date.now(),
        items: body.items,
        status: 'confirmed',
      },
      { status: 201 }
    );
  }),
];

Configurando el servidor para tests

// mocks/server.ts
import { setupServer } from 'msw/native';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// jest.setup.ts (actualizado)
import 'react-native-url-polyfill/auto';
import 'fast-text-encoding';
import '@testing-library/react-native/extend-expect';
import { server } from './mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Usando MSW en tests de integración

// __tests__/screens/ProductListScreen.test.tsx
import { render, screen, waitFor } from '@testing-library/react-native';
import { server } from '@/mocks/server';
import { http, HttpResponse } from 'msw';
import { ProductListScreen } from '@/screens/ProductListScreen';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function renderWithProviders(ui: React.ReactElement) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });
  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  );
}

describe('ProductListScreen', () => {
  it('muestra la lista de productos', async () => {
    renderWithProviders(<ProductListScreen />);

    await waitFor(() => {
      expect(screen.getByText('Café Colombiano')).toBeTruthy();
      expect(screen.getByText('Té Matcha')).toBeTruthy();
    });
  });

  it('muestra estado de error cuando la API falla', async () => {
    server.use(
      http.get('https://api.miapp.com/products', () => {
        return HttpResponse.json(
          { message: 'Error del servidor' },
          { status: 500 }
        );
      })
    );

    renderWithProviders(<ProductListScreen />);

    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeTruthy();
    });
  });

  it('muestra indicador de carga inicialmente', () => {
    renderWithProviders(<ProductListScreen />);
    expect(screen.getByTestId('loading-indicator')).toBeTruthy();
  });
});

Lo potente de MSW es que puedes sobrescribir handlers por test con server.use(). Tus handlers por defecto cubren el "happy path", y en tests específicos simulas errores, datos vacíos, o respuestas lentas. Después de cada test, server.resetHandlers() restaura los handlers originales.

Maestro: tests E2E que no te hacen sufrir

Y te lo digo porque he sufrido con Detox. Configuraciones que se rompen con cada actualización de React Native, builds de test separados, flakiness constante, y un setup inicial que puede tomar días. Maestro cambió completamente el juego.

Maestro es un framework de pruebas E2E creado por mobile.dev que usa sintaxis YAML declarativa. No escribes código, describes flujos. No necesitas builds de test especiales — funciona con tu app normal. Y la integración con el ecosistema Expo/EAS es de primera clase.

Instalación

# macOS
curl -fsSL "https://get.maestro.mobile.dev" | bash

# Verificar instalación
maestro --version

Tu primer flujo: login completo

# e2e/login.yaml
appId: com.miapp.reactnative
---
- launchApp
- tapOn: "Correo electrónico"
- inputText: "[email protected]"
- tapOn: "Contraseña"
- inputText: "mypassword123"
- tapOn: "Entrar"
- assertVisible: "Bienvenido"
- assertVisible: "Usuario Test"

Así de simple. Sin código, sin imports, sin configuraciones de build. Maestro interactúa con tu app como lo haría un usuario real.

Eso fue lo que me convenció de migrar todos nuestros tests E2E.

Flujo avanzado: navegación y compra

# e2e/purchase-flow.yaml
appId: com.miapp.reactnative
---
# Login
- launchApp:
    clearState: true
- tapOn: "Correo electrónico"
- inputText: "[email protected]"
- tapOn: "Contraseña"
- inputText: "mypassword123"
- tapOn: "Entrar"
- assertVisible: "Bienvenido"

# Navegar al catálogo
- tapOn: "Catálogo"
- assertVisible: "Productos Disponibles"

# Buscar un producto
- tapOn: "Buscar productos..."
- inputText: "Café"
- assertVisible: "Café Colombiano"

# Añadir al carrito
- tapOn: "Café Colombiano"
- assertVisible: "14,99 €"
- tapOn: "Añadir al Carrito"
- assertVisible: "Producto añadido"

# Verificar carrito
- tapOn: "Carrito"
- assertVisible: "Café Colombiano"
- assertVisible: "1 artículo"
- assertVisible: "Total: 14,99 €"

# Completar compra
- tapOn: "Proceder al Pago"
- assertVisible: "Confirmar Pedido"
- tapOn: "Confirmar"
- assertVisible: "Pedido confirmado"

Flujo con formulario y validación

# e2e/registration.yaml
appId: com.miapp.reactnative
---
- launchApp:
    clearState: true
- tapOn: "Crear Cuenta"

# Intentar enviar formulario vacío
- tapOn: "Registrarme"
- assertVisible: "El nombre es obligatorio"

# Completar formulario
- tapOn: "Nombre completo"
- inputText: "María García"
- tapOn: "Correo electrónico"
- inputText: "[email protected]"
- tapOn: "Contraseña"
- inputText: "SecurePass123!"
- tapOn: "Confirmar contraseña"
- inputText: "SecurePass123!"

# Scroll para llegar al botón
- scrollDown

# Aceptar términos
- tapOn: "Acepto los términos y condiciones"

# Enviar
- tapOn: "Registrarme"
- assertVisible: "Cuenta creada exitosamente"

Maestro Studio: escribe tests visualmente

Una de las funcionalidades más impresionantes de Maestro es Maestro Studio, que te permite crear tests interactivamente:

# Abre Maestro Studio en tu navegador
maestro studio

Esto abre una interfaz web que muestra la pantalla de tu dispositivo/emulador en tiempo real. Puedes hacer clic en elementos, escribir texto, y Maestro genera automáticamente el YAML correspondiente. Es perfecto para descubrir selectores y construir flujos de forma visual antes de pulirlos manualmente.

Maestro vs Detox: comparación directa

La pregunta que todo el mundo se hace. Aquí va mi opinión honesta:

  • Setup: Maestro se instala con un comando. Detox requiere configurar builds de test separados para iOS y Android, integrar con Jest, y luchar con las dependencias nativas. Ventaja clara: Maestro.
  • Sintaxis: YAML declarativo vs JavaScript/TypeScript. Los flows de Maestro son legibles por cualquier persona del equipo, incluyendo QA y producto. Detox requiere conocimientos de programación.
  • Estabilidad: Maestro tiene mecanismos de auto-espera integrados. Detox sufre de flakiness notorio que requiere sincronización manual.
  • Velocidad de escritura: Un flow de Maestro se escribe en minutos. Un test equivalente en Detox puede tomar horas, especialmente la primera vez.
  • Debugging: Maestro Studio permite iterar visualmente. Detox depende de logs y screenshots.
  • CI/CD: Ambos se integran bien, pero Maestro tiene integración nativa con EAS Workflows.
  • Limitación de Maestro: Para escenarios muy complejos que requieren lógica condicional o manipulación de datos entre pasos, Detox ofrece más flexibilidad al ser JavaScript completo. Maestro cubre el 90% de los casos con su sintaxis declarativa.

Integración con EAS Workflows

Si usas Expo y EAS, la integración con Maestro es casi automática:

# eas.json — añadir perfil de test
{
  "build": {
    "test": {
      "ios": {
        "simulator": true,
        "buildConfiguration": "Release"
      },
      "android": {
        "buildType": "apk",
        "distribution": "internal"
      }
    }
  }
}
# .eas/workflows/e2e-tests.yaml
name: E2E Tests
on:
  push:
    branches: [main, develop]

jobs:
  e2e-ios:
    name: iOS E2E Tests
    runs-on: macos
    steps:
      - uses: actions/checkout@v4
      - uses: expo/expo-github-action@v8
        with:
          expo-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: eas build --profile test --platform ios --local
      - run: |
          maestro test e2e/ --format junit --output results.xml

  e2e-android:
    name: Android E2E Tests
    runs-on: ubuntu
    steps:
      - uses: actions/checkout@v4
      - uses: expo/expo-github-action@v8
        with:
          expo-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: eas build --profile test --platform android --local
      - run: |
          maestro test e2e/ --format junit --output results.xml

React Native DevTools: el reemplazo de Flipper que sí funciona

Flipper está muerto. Y honestamente, no lo extrañamos. Era lento y consumía una cantidad absurda de RAM. Se desconectaba constantemente, y cada actualización de React Native rompía algo. React Native DevTools es su sucesor oficial, y la diferencia es abismal.

React Native DevTools viene integrado en React Native 0.76+ y se ejecuta directamente en tu navegador Chrome o Edge. No necesitas instalar aplicaciones de escritorio separadas.

Cómo abrirlo

# Con el dev server corriendo, simplemente pulsa 'j' en la terminal
# O abre la URL directamente:
# http://localhost:8081/debugger-frontend

Las pestañas que necesitas conocer

Console: La consola JavaScript con acceso completo al contexto de tu app. Puedes ejecutar código, inspeccionar variables, y ver logs filtrados por nivel (info, warn, error). Funciona directamente sobre Hermes, lo que significa que ves exactamente lo que tu motor de JS está procesando.

Sources: Navega y depura tu código fuente con breakpoints. Con Hermes, los source maps funcionan correctamente, así que ves tu código TypeScript original, no el bundle transpilado. Puedes poner breakpoints condicionales, watch expressions, y step through tu código línea por línea.

Network: Inspecciona todas las peticiones HTTP/HTTPS que hace tu app. Ve headers, bodies de request y response, tiempos de carga, y códigos de estado. Esto era una de las cosas que más costaba hacer con Flipper y ahora funciona de forma nativa.

Memory: Toma snapshots del heap de memoria, compara entre snapshots para detectar memory leaks, y analiza qué objetos están consumiendo más memoria. Esencial para apps que mantienen muchos datos en memoria.

React Components: Inspecciona el árbol de componentes React, ve las props y el estado de cada componente, y modifícalos en tiempo real. Es el React DevTools que ya conoces, pero integrado directamente.

Profiler: Graba sesiones de rendering para identificar componentes lentos, re-renders innecesarios, y cuellos de botella. Lo veremos en detalle más adelante.

Integración con Hermes

React Native DevTools está diseñado específicamente para trabajar con Hermes, el motor de JavaScript que es el estándar en React Native desde hace varias versiones. Esto significa:

  • Debugging real: Los breakpoints se ejecutan en el motor nativo, no en un proxy de Chrome V8 como hacía el antiguo debugger.
  • Source maps precisos: Ves tu código TypeScript/JSX original, con números de línea correctos.
  • Performance accuracy: Los tiempos que ves en el profiler reflejan la ejecución real en Hermes, no una aproximación de otro motor.
  • Memory inspection real: Los heap snapshots muestran la memoria real de Hermes, incluyendo bytecode compilado.

Expo DevTools Plugins y el workflow shift+m

Si usas Expo (y en 2026, la mayoría de los proyectos lo hacen), tienes acceso a un ecosistema de plugins de DevTools específicos que se integran con el flujo de desarrollo.

El menú shift+m

Con el dev server de Expo corriendo, pulsa shift+m en la terminal para abrir el menú de DevTools plugins. Desde aquí puedes acceder a:

  • React Navigation DevTools: Inspecciona el estado de navegación, el historial de rutas, y los parámetros de cada pantalla.
  • TanStack Query DevTools: Ve el estado de todas las queries, su estado de cache, cuándo se refrescaron por última vez, y fuerza refetch manualmente.
  • Async Storage / MMKV viewer: Inspecciona y modifica los datos persistidos en tu app.
  • Expo Atlas: Analiza el tamaño de tu bundle y identifica dependencias pesadas.
// Para instalar un plugin de DevTools de Expo
npx expo install expo-dev-client

// En tu app, registra los plugins que quieras usar
// app/_layout.tsx (con Expo Router)
import { useReactNavigationDevTools } from '@dev-plugins/react-navigation';
import { useNavigationContainerRef } from 'expo-router';

export default function RootLayout() {
  const navigationRef = useNavigationContainerRef();
  useReactNavigationDevTools(navigationRef);

  return <Slot />;
}
// Para TanStack Query DevTools
import { useReactQueryDevTools } from '@dev-plugins/react-query';
import { queryClient } from '@/lib/queryClient';

export default function RootLayout() {
  useReactQueryDevTools(queryClient);

  return (
    <QueryClientProvider client={queryClient}>
      <Slot />
    </QueryClientProvider>
  );
}

La ventaja de los plugins de Expo es que se ejecutan dentro del contexto de tu app, así que tienen acceso directo al estado real. No son herramientas externas que intentan adivinar qué pasa — son parte de tu proceso de desarrollo.

Profiling de performance con React DevTools Profiler

De nada sirve tener tests si tu app va lenta. He visto proyectos con 90% de cobertura que tardaban 4 segundos en renderizar una lista. El React DevTools Profiler es tu herramienta principal para identificar esos problemas de rendimiento a nivel de componentes.

Cómo usarlo efectivamente

  1. Abre React Native DevTools (pulsa j en la terminal del dev server).
  2. Ve a la pestaña Profiler.
  3. Activa "Record why each component rendered" en los ajustes del profiler.
  4. Pulsa Record, interactúa con tu app, y pulsa Stop.
  5. Analiza los resultados: busca componentes que se renderizan muchas veces o que tardan más de 16ms.

Profiling programático para producción

Para medir performance en builds de producción, usa el componente Profiler de React:

// lib/performanceMonitor.ts
import { type ProfilerOnRenderCallback } from 'react';

interface RenderMetric {
  id: string;
  phase: 'mount' | 'update';
  actualDuration: number;
  baseDuration: number;
  timestamp: number;
}

const metrics: RenderMetric[] = [];

export const onRenderCallback: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) => {
  const metric: RenderMetric = {
    id,
    phase: phase as 'mount' | 'update',
    actualDuration,
    baseDuration,
    timestamp: commitTime,
  };

  metrics.push(metric);

  // Alerta si un render toma más de 16ms (por debajo de 60fps)
  if (actualDuration > 16) {
    console.warn(
      `[Perf] ${id} tardó ${actualDuration.toFixed(1)}ms en ${phase}`
    );
  }
};

export function getPerformanceReport() {
  const slowRenders = metrics.filter((m) => m.actualDuration > 16);
  const avgDuration =
    metrics.reduce((sum, m) => sum + m.actualDuration, 0) / metrics.length;

  return {
    totalRenders: metrics.length,
    slowRenders: slowRenders.length,
    averageDuration: avgDuration.toFixed(2),
    slowestComponents: slowRenders
      .sort((a, b) => b.actualDuration - a.actualDuration)
      .slice(0, 5),
  };
}
// Uso en componentes críticos
import { Profiler } from 'react';
import { onRenderCallback } from '@/lib/performanceMonitor';

function App() {
  return (
    <Profiler id="ProductList" onRender={onRenderCallback}>
      <ProductListScreen />
    </Profiler>
  );
}

Patrones para detectar re-renders innecesarios

// hooks/useRenderCount.ts — útil durante desarrollo
import { useRef } from 'react';

export function useRenderCount(componentName: string) {
  const renderCount = useRef(0);
  renderCount.current += 1;

  if (__DEV__) {
    console.log(`[Render] ${componentName}: #${renderCount.current}`);
  }
}

// Uso en un componente
function ProductCard({ product }: { product: Product }) {
  useRenderCount('ProductCard');
  // Si ves "ProductCard: #47" al scroll,
  // probablemente necesitas React.memo
  return (
    <View>
      <Text>{product.name}</Text>
    </View>
  );
}

CI/CD: automatizando todo el ciclo de testing

Los tests que no se ejecutan automáticamente son tests que eventualmente se ignoran — por eso un pipeline de CI/CD bien configurado es tan importante como los tests mismos.

GitHub Actions para unit tests e integration tests

# .github/workflows/test.yml
name: Tests
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  unit-and-integration:
    name: Unit & Integration Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - run: npm ci

      - name: Type Check
        run: npx tsc --noEmit

      - name: Lint
        run: npx eslint . --max-warnings 0

      - name: Unit Tests
        run: npx jest --ci --coverage --maxWorkers=2

      - name: Upload Coverage
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./coverage/lcov.info

Pipeline completo con E2E usando Maestro y EAS

# .github/workflows/e2e.yml
name: E2E Tests
on:
  push:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1-5'  # Cada mañana de lunes a viernes

jobs:
  e2e-android:
    name: Android E2E
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - run: npm ci

      - name: Setup EAS
        uses: expo/expo-github-action@v8
        with:
          expo-version: latest
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}

      - name: Build APK for testing
        run: eas build --profile test --platform android --local --output ./app-test.apk

      - name: Install Maestro
        run: |
          curl -fsSL "https://get.maestro.mobile.dev" | bash
          echo "$HOME/.maestro/bin" >> $GITHUB_PATH

      - name: Setup Android Emulator
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          arch: x86_64
          script: |
            adb install app-test.apk
            maestro test e2e/ --format junit --output e2e-results.xml

      - name: Upload Test Results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: e2e-results
          path: e2e-results.xml

  e2e-ios:
    name: iOS E2E
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - run: npm ci

      - name: Setup EAS
        uses: expo/expo-github-action@v8
        with:
          expo-version: latest
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}

      - name: Build for iOS Simulator
        run: eas build --profile test --platform ios --local --output ./app-test.app

      - name: Install Maestro
        run: |
          curl -fsSL "https://get.maestro.mobile.dev" | bash
          echo "$HOME/.maestro/bin" >> $GITHUB_PATH

      - name: Boot Simulator and Run Tests
        run: |
          xcrun simctl boot "iPhone 16"
          xcrun simctl install booted ./app-test.app
          maestro test e2e/ --format junit --output e2e-results.xml

      - name: Upload Test Results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: e2e-results-ios
          path: e2e-results.xml

Estructura recomendada del proyecto para testing

mi-app/
├── src/
│   ├── components/
│   │   ├── LoginForm.tsx
│   │   └── ProductCard.tsx
│   ├── screens/
│   │   ├── HomeScreen.tsx
│   │   └── ProductListScreen.tsx
│   ├── stores/
│   │   └── useCartStore.ts
│   ├── hooks/
│   │   └── useProducts.ts
│   ├── utils/
│   │   └── formatters.ts
│   └── lib/
│       └── performanceMonitor.ts
├── __tests__/
│   ├── components/
│   │   ├── LoginForm.test.tsx
│   │   └── ProductCard.test.tsx
│   ├── screens/
│   │   └── ProductListScreen.test.tsx
│   ├── stores/
│   │   └── useCartStore.test.ts
│   ├── hooks/
│   │   └── useProducts.test.ts
│   └── utils/
│       └── formatters.test.ts
├── mocks/
│   ├── handlers.ts
│   └── server.ts
├── e2e/
│   ├── login.yaml
│   ├── purchase-flow.yaml
│   └── registration.yaml
├── jest.config.ts
├── jest.setup.ts
└── .github/
    └── workflows/
        ├── test.yml
        └── e2e.yml

Mejores prácticas: lo que separa a los equipos profesionales

Dicho esto, después de trabajar con equipos de todos los tamaños que desarrollan apps React Native, estos son los patrones que consistentemente hacen la diferencia:

1. Test lo importante primero

No intentes llegar al 100% de cobertura. Empieza por lo que más dolor causa cuando falla:

  • Flujos de autenticación: Login, logout, renovación de tokens. Si esto falla, nadie puede usar tu app.
  • Flujos de pago: Si manejas dinero, los tests aquí no son opcionales.
  • Lógica de negocio crítica: Cálculos de precios, validación de formularios, transformación de datos.
  • Manejo de errores: Los tests de error son más valiosos que los tests del happy path. Asegúrate de que tu app se comporta correctamente cuando las cosas van mal.

2. Escribe tests que sobrevivan al refactoring

// MAL — test frágil atado a la implementación
it('actualiza el estado interno', () => {
  const { result } = renderHook(() => useLoginForm());
  act(() => result.current.setEmail('[email protected]'));
  expect(result.current.formState.email).toBe('[email protected]');
});

// BIEN — test que verifica comportamiento del usuario
it('permite al usuario escribir su email', () => {
  render(<LoginForm onSubmit={jest.fn()} />);
  fireEvent.changeText(
    screen.getByPlaceholderText('Correo electrónico'),
    '[email protected]'
  );
  expect(screen.getByDisplayValue('[email protected]')).toBeTruthy();
});

3. Usa factories para datos de test

// test-utils/factories.ts
let idCounter = 0;

export function createProduct(overrides: Partial<Product> = {}): Product {
  idCounter += 1;
  return {
    id: `product-${idCounter}`,
    name: `Producto ${idCounter}`,
    price: 9.99 + idCounter,
    inStock: true,
    category: 'general',
    ...overrides,
  };
}

export function createUser(overrides: Partial<User> = {}): User {
  idCounter += 1;
  return {
    id: `user-${idCounter}`,
    name: `Usuario ${idCounter}`,
    email: `user${idCounter}@test.com`,
    avatar: null,
    ...overrides,
  };
}

// Uso en tests
it('muestra productos agotados correctamente', () => {
  const product = createProduct({ inStock: false, name: 'Café Agotado' });
  render(<ProductCard product={product} />);
  expect(screen.getByText('Agotado')).toBeTruthy();
});

4. Un helper para renderizar con providers

// test-utils/render.tsx
import { render, type RenderOptions } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { NavigationContainer } from '@react-navigation/native';

interface CustomRenderOptions extends RenderOptions {
  queryClient?: QueryClient;
}

export function renderWithProviders(
  ui: React.ReactElement,
  options: CustomRenderOptions = {}
) {
  const {
    queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
        mutations: { retry: false },
      },
    }),
    ...renderOptions
  } = options;

  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <NavigationContainer>
          {children}
        </NavigationContainer>
      </QueryClientProvider>
    );
  }

  return {
    ...render(ui, { wrapper: Wrapper, ...renderOptions }),
    queryClient,
  };
}

// Re-exportar todo de RNTL
export * from '@testing-library/react-native';
export { renderWithProviders as render };

5. Los tests E2E de Maestro como documentación viva

Algo que descubrí con el tiempo: los flujos YAML de Maestro son excelente documentación. Un nuevo miembro del equipo puede leer purchase-flow.yaml y entender exactamente cómo funciona el proceso de compra, paso a paso. Nombra tus archivos y pasos descriptivamente:

# e2e/user-can-recover-password.yaml
appId: com.miapp.reactnative
tags:
  - auth
  - critical
---
# Escenario: un usuario que olvidó su contraseña puede recuperarla
- launchApp:
    clearState: true

# Paso 1: El usuario va a la pantalla de login
- assertVisible: "Iniciar Sesión"

# Paso 2: Pulsa "Olvidé mi contraseña"
- tapOn: "¿Olvidaste tu contraseña?"
- assertVisible: "Recuperar Contraseña"

# Paso 3: Introduce su email
- tapOn: "Correo electrónico"
- inputText: "[email protected]"
- tapOn: "Enviar Enlace"

# Paso 4: Ve la confirmación
- assertVisible: "Revisa tu correo"
- assertVisible: "Hemos enviado un enlace"

6. Ejecuta tests en paralelo y por capas

// package.json — scripts organizados por tipo
{
  "scripts": {
    "test": "jest",
    "test:unit": "jest --testPathPattern='__tests__/(utils|stores|hooks)'",
    "test:components": "jest --testPathPattern='__tests__/components'",
    "test:integration": "jest --testPathPattern='__tests__/screens'",
    "test:coverage": "jest --coverage",
    "test:watch": "jest --watch",
    "test:ci": "jest --ci --coverage --maxWorkers=2",
    "e2e": "maestro test e2e/",
    "e2e:login": "maestro test e2e/login.yaml",
    "e2e:critical": "maestro test e2e/ --tags critical"
  }
}

En tu pipeline de CI, ejecuta los tests por capas: primero los unit tests (rápidos y baratos), luego los de componentes, luego integración, y finalmente E2E. Si los unit tests fallan, no tiene sentido gastar minutos de CI en E2E.

Conclusión: testing como inversión, no como costo

Seamos realistas: nadie disfruta escribiendo tests. Pero en 2026, con la complejidad que implica la New Architecture, con aplicaciones que manejan autenticación, pagos, datos en tiempo real y múltiples plataformas, no testear es simplemente irresponsable.

La buena noticia es que las herramientas nunca han sido mejores:

  • Jest 30 es 37% más rápido y consume 77% menos memoria. Tu suite de tests ya no es excusa para saltarse la ejecución.
  • React Native Testing Library te da tests que verifican comportamiento real del usuario, no detalles de implementación.
  • MSW hace que mockear APIs sea limpio, mantenible y realista.
  • Maestro ha democratizado los tests E2E. Si puedes escribir YAML, puedes escribir tests E2E.
  • React Native DevTools ha reemplazado a Flipper con algo que realmente funciona, integrado con Hermes y sin las constantes desconexiones.
  • Expo DevTools Plugins te dan visibilidad sobre navegación, queries y estado directamente desde tu flujo de desarrollo.

Mi recomendación final: empieza pequeño. No intentes implementar toda la pirámide de testing de un día para otro. Empieza con unit tests para tu lógica de negocio crítica. Añade tests de componentes para tus pantallas principales. Escribe un par de flujos Maestro para tus flujos más importantes. Y configura CI desde el primer día.

Con el tiempo, esos tests se convierten en una red de seguridad que te permite refactorizar sin miedo, actualizar dependencias con confianza, y hacer deploy un viernes por la tarde sin que te tiemble el pulso. Bueno, quizás el viernes por la tarde todavía no — pero al menos el jueves.

Si este artículo te resultó útil, revisa también nuestras guías sobre Navegación con Expo Router, Optimización de Performance y Gestión de Estado. Juntos, estos cuatro pilares — navegación, estado, performance y testing — forman la base de cualquier aplicación React Native profesional en 2026.

Sobre el Autor Editorial Team

Our team of expert writers and editors.