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
Enfoque | Enfoque | Mantenimiento | Confianza Usuario |
---|---|---|---|
Enzyme (obsoleto) | Detalles implementación | Alto | Baja |
React Native Testing Library | Comportamiento usuario | Bajo | Alta |
Detox (E2E) | Flujos completos app | Medio | Muy 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:
- getByRole / getByLabelText: Accesible para todos los usuarios
- getByPlaceholderText: Para elementos de formulario
- getByText: Para elementos no interactivos
- getByTestId: Último recurso para detalles de implementación
Objetivos de Cobertura
Métrica | Objetivo | Prioridad |
---|---|---|
Statements | 80%+ | Alta |
Branches | 75%+ | Alta |
Functions | 80%+ | Media |
Lines | 80%+ | 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.