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.

¿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](/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](/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](/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.