React Native Testing Library (RNTL) se ha convertido en el estándar para el testing de componentes en aplicaciones React Native, proporcionando utilidades de prueba que fomentan buenas prácticas haciendo que las pruebas se asemejen a cómo los usuarios interactúan con los componentes. Según la encuesta React Native Community 2023, el 68% de los desarrolladores de React Native ahora usa RNTL para testing de componentes, frente al 31% en 2020, convirtiéndola en la herramienta de testing de más rápido crecimiento en el ecosistema. Según investigaciones publicadas en el Journal of Systems and Software, las aplicaciones probadas con el enfoque centrado en el usuario de RNTL muestran un 35% menos de bugs de regresión en componentes de UI. Para los ingenieros de QA y desarrolladores de React Native, dominar las utilidades async, consultas personalizadas, mocking de módulos nativos e integración con Jest permite un testing comprensivo de componentes.

TL;DR: React Native Testing Library proporciona testing de componentes centrado en el usuario con consultas como getByText, getByRole, fireEvent y act(). Patrones clave: renderiza componente → consulta por etiqueta de accesibilidad/texto → dispara eventos → verifica resultados visibles para el usuario. Usa jest.mock() para módulos nativos. El testing asíncrono requiere waitFor() o consultas findBy*.

Introducción al Testing de React Native

React Native Testing Library (RNTL) es el estándar de la industria para probar aplicaciones React Native. Construida sobre los principios de probar el comportamiento del usuario en lugar de detalles de implementación, RNTL fomenta escribir tests mantenibles que te dan confianza en la funcionalidad de tu aplicación.

Esta guía cubre estrategias integrales de testing, desde tests básicos de componentes hasta escenarios complejos de integración, mocking de módulos nativos y mejores prácticas de testing de rendimiento.

“React Native Testing Library cambió cómo pensamos sobre el testing de componentes móviles. En lugar de probar detalles de implementación, probamos comportamiento — lo que significa que las pruebas sobreviven la refactorización y realmente capturan los bugs que importan a los usuarios.” — Yuri Kan, Senior QA Lead

¿Por Qué React Native Testing Library?

RNTL sigue el principio rector: “Cuanto más se parezcan tus tests a la forma en que se usa tu software, más confianza te pueden dar.”

Ventajas Clave

  • Testing Centrado en el Usuario: Prueba componentes como los usuarios interactúan con ellos
  • Independencia de Implementación: Refactoriza componentes sin romper tests
  • Utilidades Async: Helpers integrados para operaciones asíncronas
  • Enfoque en Accesibilidad: Fomenta el diseño de componentes accesibles
  • Integración con Jest: Integración fluida con el ecosistema Jest

Comparación con Otros Enfoques de Testing

EnfoqueEnfoqueMantenimientoConfianza Usuario
Enzyme (obsoleto)Detalles implementaciónAltoBaja
React Native Testing LibraryComportamiento usuarioBajoAlta
Detox (E2E)Flujos completos appMedioMuy Alta

Configuración del Entorno

Instalación

npm install --save-dev @testing-library/react-native
npm install --save-dev @testing-library/jest-native
npm install --save-dev jest @types/jest

Configuración de Jest

Configura jest.config.js:

module.exports = {
  preset: 'react-native' (como se discute en [Detox: Grey-Box Testing for React Native Applications](/es/blog/detox-react-native-grey-box)),
  setupFilesAfterEnv: [
    '@testing-library/jest-native/extend-expect',
    '<rootDir>/jest.setup.js'
  ],
  transformIgnorePatterns: [
    'node_modules/(?!(react-native|@react-native|@react-navigation|react-native-.*)/)'
  ],
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.test.{js,jsx,ts,tsx}',
    '!src/**/__tests__/**'
  ],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  }
};

Crea jest.setup.js:

import 'react-native-gesture-handler/jestSetup';

jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');

global.fetch = jest.fn();

beforeEach(() => {
  jest (como se discute en [API Testing Architecture: From Monoliths to Microservices](/es/blog/api-testing-architecture-microservices)).clearAllMocks();
});

Fundamentos del Testing de Componentes

Test Básico de Componente

// src/components/Button.tsx
import React from 'react';
import { TouchableOpacity, Text, StyleSheet } from 'react-native';

interface ButtonProps {
  onPress: () => void;
  title: string;
  disabled?: boolean;
}

export const Button: React.FC<ButtonProps> = ({
  onPress,
  title,
  disabled = false
}) => (
  <TouchableOpacity
    testID="custom-button"
    onPress={onPress}
    disabled={disabled}
    style={[styles.button, disabled && styles.disabled]}
  >
    <Text style={styles.text}>{title}</Text>
  </TouchableOpacity>
);

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#007AFF',
    padding: 12,
    borderRadius: 8,
  },
  disabled: {
    backgroundColor: '#CCC',
  },
  text: {
    color: 'white',
    fontSize: 16,
  },
});

// src/components/__tests__/Button.test.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { Button } from '../Button';

describe('Componente Button', () => {
  it('renderiza correctamente con título', () => {
    const { getByText } = render(
      <Button onPress={() => {}} title="Clic Aquí" />
    );

    expect(getByText('Clic Aquí')).toBeTruthy();
  });

  it('llama onPress cuando se presiona', () => {
    const onPressMock = jest (como se discute en [GraphQL Testing: Complete Guide with Examples](/es/blog/graphql-testing-guide)).fn();
    const { getByTestId } = render(
      <Button onPress={onPressMock} title="Presionar" />
    );

    fireEvent.press(getByTestId('custom-button'));
    expect(onPressMock).toHaveBeenCalledTimes(1);
  });

  it('no llama onPress cuando está deshabilitado', () => {
    const onPressMock = jest.fn();
    const { getByTestId } = render(
      <Button onPress={onPressMock} title="Presionar" disabled />
    );

    fireEvent.press(getByTestId('custom-button'));
    expect(onPressMock).not.toHaveBeenCalled();
  });
});

Probando Formularios y Entrada

// src/components/LoginForm.tsx
import React, { useState } from 'react';
import { View, TextInput, Text } from 'react-native';
import { Button } from './Button';

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

export const LoginForm: React.FC<LoginFormProps> = ({ onSubmit }) => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = () => {
    if (!email || !password) {
      setError('Email y contraseña son requeridos');
      return;
    }

    if (!email.includes('@')) {
      setError('Formato de email inválido');
      return;
    }

    setError('');
    onSubmit(email, password);
  };

  return (
    <View testID="login-form">
      <TextInput
        testID="email-input"
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />
      <TextInput
        testID="password-input"
        placeholder="Contraseña"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      {error ? <Text testID="error-message">{error}</Text> : null}
      <Button onPress={handleSubmit} title="Iniciar Sesión" />
    </View>
  );
};

// src/components/__tests__/LoginForm.test.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { LoginForm } from '../LoginForm';

describe('LoginForm', () => {
  it('envía formulario con credenciales válidas', () => {
    const onSubmitMock = jest.fn();
    const { getByTestId } = render(
      <LoginForm onSubmit={onSubmitMock} />
    );

    fireEvent.changeText(
      getByTestId('email-input'),
      'user@example.com'
    );
    fireEvent.changeText(
      getByTestId('password-input'),
      'password123'
    );
    fireEvent.press(getByTestId('custom-button'));

    expect(onSubmitMock).toHaveBeenCalledWith(
      'user@example.com',
      'password123'
    );
  });

  it('muestra error para campos vacíos', () => {
    const { getByTestId, getByText } = render(
      <LoginForm onSubmit={() => {}} />
    );

    fireEvent.press(getByTestId('custom-button'));

    expect(
      getByText('Email y contraseña son requeridos')
    ).toBeTruthy();
  });

  it('muestra error para formato de email inválido', () => {
    const { getByTestId, getByText } = render(
      <LoginForm onSubmit={() => {}} />
    );

    fireEvent.changeText(getByTestId('email-input'), 'invalido');
    fireEvent.changeText(getByTestId('password-input'), 'pass');
    fireEvent.press(getByTestId('custom-button'));

    expect(getByText('Formato de email inválido')).toBeTruthy();
  });
});

Testing Asíncrono

Probando Llamadas API

// src/services/api.ts
export const fetchUserProfile = async (userId: string) => {
  const response = await fetch(
    `https://api.example.com/users/${userId}`
  );
  if (!response.ok) {
    throw new Error('Error al obtener usuario');
  }
  return response.json();
};

// src/components/UserProfile.tsx
import React, { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
import { fetchUserProfile } from '../services/api';

interface User {
  id: string;
  name: string;
  email: string;
}

export const UserProfile: React.FC<{ userId: string }> = ({
  userId
}) => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');

  useEffect(() => {
    const loadUser = async () => {
      try {
        const data = await fetchUserProfile(userId);
        setUser(data);
      } catch (err) {
        setError('Error al cargar usuario');
      } finally {
        setLoading(false);
      }
    };

    loadUser();
  }, [userId]);

  if (loading) {
    return <ActivityIndicator testID="loading" />;
  }

  if (error) {
    return <Text testID="error">{error}</Text>;
  }

  return (
    <View testID="user-profile">
      <Text testID="user-name">{user?.name}</Text>
      <Text testID="user-email">{user?.email}</Text>
    </View>
  );
};

// src/components/__tests__/UserProfile.test.tsx
import React from 'react';
import {
  render,
  waitFor,
  screen
} from '@testing-library/react-native';
import { UserProfile } from '../UserProfile';
import * as api from '../../services/api';

jest.mock('../../services/api');

describe('UserProfile', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('muestra estado de carga inicialmente', () => {
    (api.fetchUserProfile as jest.Mock).mockImplementation(
      () => new Promise(() => {}) // Nunca se resuelve
    );

    const { getByTestId } = render(
      <UserProfile userId="123" />
    );

    expect(getByTestId('loading')).toBeTruthy();
  });

  it('muestra datos de usuario después de fetch exitoso', async () => {
    const mockUser = {
      id: '123',
      name: 'Juan Pérez',
      email: 'juan@example.com'
    };

    (api.fetchUserProfile as jest.Mock).mockResolvedValue(
      mockUser
    );

    const { getByTestId } = render(
      <UserProfile userId="123" />
    );

    await waitFor(() => {
      expect(getByTestId('user-name')).toHaveTextContent(
        'Juan Pérez'
      );
    });

    expect(getByTestId('user-email')).toHaveTextContent(
      'juan@example.com'
    );
  });

  it('muestra mensaje de error en fallo de fetch', async () => {
    (api.fetchUserProfile as jest.Mock).mockRejectedValue(
      new Error('Error de red')
    );

    const { getByTestId } = render(
      <UserProfile userId="123" />
    );

    await waitFor(() => {
      expect(getByTestId('error')).toHaveTextContent(
        'Error al cargar usuario'
      );
    });
  });
});

Mocking de Módulos Nativos

Mocking de APIs de React Native

// jest.setup.js adiciones
jest.mock('react-native/Libraries/Alert/Alert', () => ({
  alert: jest.fn(),
}));

jest.mock('@react-native-async-storage/async-storage', () => ({
  setItem: jest.fn(() => Promise.resolve()),
  getItem: jest.fn(() => Promise.resolve(null)),
  removeItem: jest.fn(() => Promise.resolve()),
  clear: jest.fn(() => Promise.resolve()),
}));

// Ejemplo de test usando AsyncStorage mockeado
import AsyncStorage from '@react-native-async-storage/async-storage';
import { saveUserToken } from '../storage';

it('guarda token en AsyncStorage', async () => {
  await saveUserToken('abc123');

  expect(AsyncStorage.setItem).toHaveBeenCalledWith(
    'userToken',
    'abc123'
  );
});

Mejores Prácticas

Prioridad de Queries

Sigue esta prioridad al seleccionar elementos:

  1. getByRole / getByLabelText: Accesible para todos los usuarios
  2. getByPlaceholderText: Para elementos de formulario
  3. getByText: Para elementos no interactivos
  4. getByTestId: Último recurso para detalles de implementación

Objetivos de Cobertura

MétricaObjetivoPrioridad
Statements80%+Alta
Branches75%+Alta
Functions80%+Media
Lines80%+Media

Integración Continua

Ejemplo de GitHub Actions

name: React Native Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v3

      - name: Configurar Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Instalar dependencias
        run: npm ci

      - name: Ejecutar tests
        run: npm test -- --coverage --watchAll=false

      - name: Subir cobertura a Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

Conclusión

React Native Testing Library proporciona una base robusta para probar aplicaciones móviles con un enfoque centrado en el usuario. Al enfocarte en cómo los usuarios interactúan con tu app en lugar de detalles de implementación, construyes una suite de tests resistente al refactoring y que proporciona confianza genuina en el comportamiento de tu aplicación.

Puntos Clave:

  • Prioriza queries basadas en accesibilidad (role, label) sobre testID
  • Usa waitFor para operaciones async y actualizaciones de estado
  • Mockea módulos nativos y dependencias externas apropiadamente
  • Mantén alta cobertura de tests evitando sobre-testear
  • Integra testing en pipelines CI/CD para aseguramiento continuo de calidad

Comienza probando flujos críticos de usuario y expande gradualmente la cobertura para lograr protección integral de tests.

Recursos Oficiales

FAQ

¿Qué es React Native Testing Library y en qué se diferencia de Enzyme?

RNTL proporciona consultas que imitan las interacciones del usuario (getByText, getByRole, getByTestId). A diferencia de Enzyme que prueba los internos del componente, RNTL fomenta probar el comportamiento visible para el usuario, haciendo las pruebas más resistentes a la refactorización.

¿Cómo mockeo módulos nativos en pruebas de React Native?

Usa jest.mock() para módulos nativos. Para módulos personalizados, crea mocks manuales en __mocks__. RNTL proporciona un preset react-native que automockea TouchableOpacity, FlatList y otros componentes.

¿Cómo pruebo operaciones asíncronas en RNTL?

Usa waitFor() para esperar actualizaciones. Usa consultas findBy* para elementos asíncronos. Envuelve actualizaciones de estado en act(). Para llamadas API: mockea fetch/axios, activa la acción, luego waitFor el estado esperado.

¿Cuál es la diferencia entre RNTL y Detox?

RNTL es para testing de componentes en Node.js (milisegundos). Detox es para E2E en dispositivos reales (minutos). Usa RNTL para el 70-80% de las pruebas, Detox para flujos de usuario críticos.

See Also