🧪 Testing Guide
Index
Configuración
Unit Tests con Jest
Testing de Componentes
Testing de Hooks
Testing de Contextos
Mocking
E2E Tests con Maestro
Best Practices
Configuración
Instalar dependencias
# Dependencias de testing
bun add -d jest jest-expo @testing-library/react-native @testing-library/jest-native
# Tipos de TypeScript
bun add -d @types/jest
jest.config.js
module.exports = {
preset: 'jest-expo',
setupFilesAfterEnv: [
'@testing-library/jest-native/extend-expect',
'<rootDir>/jest.setup.js'
],
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|lucide-react-native)'
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1'
},
collectCoverageFrom: [
'components/**/*.{ts,tsx}',
'contexts/**/*.{ts,tsx}',
'utils/**/*.{ts,tsx}',
'!**/*.d.ts'
]
};
jest.setup.js
import '@testing-library/jest-native/extend-expect';
// Mock AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);
// Mock expo-haptics
jest.mock('expo-haptics', () => ({
impactAsync: jest.fn(),
notificationAsync: jest.fn(),
selectionAsync: jest.fn(),
ImpactFeedbackStyle: {
Light: 'light',
Medium: 'medium',
Heavy: 'heavy',
},
NotificationFeedbackType: {
Success: 'success',
Warning: 'warning',
Error: 'error',
},
}));
// Mock expo-router
jest.mock('expo-router', () => ({
useRouter: () => ({
push: jest.fn(),
back: jest.fn(),
replace: jest.fn(),
}),
useLocalSearchParams: () => ({}),
useSegments: () => [],
Link: 'Link',
}));
// Silence console warnings in tests
console.warn = jest.fn();
package.json scripts
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --maxWorkers=2"
}
}
Unit Tests con Jest
Estructura de archivos
├── __tests__/
│ ├── components/
│ │ ├── Button.test.tsx
│ │ ├── Card.test.tsx
│ │ └── Input.test.tsx
│ ├── contexts/
│ │ ├── AppContext.test.tsx
│ │ └── ThemeContext.test.tsx
│ ├── hooks/
│ │ └── useDebounce.test.ts
│ └── utils/
│ └── helpers.test.ts
Ejecutar tests
# Todos los tests
bun test
# Tests específicos
bun test Button
# Watch mode
bun test:watch
# Con coverage
bun test:coverage
Testing de Componentes
Test básico de componente
// __tests__/components/Button.test.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { Button } from '@/components/Button';
// Mock del contexto de tema
jest.mock('@/contexts/ThemeContext', () => ({
useTheme: () => ({
colors: {
primary: '#007AFF',
text: '#000000',
textSecondary: '#666666',
surface: '#FFFFFF',
border: '#E0E0E0',
},
isDark: false,
}),
}));
describe('Button', () => {
it('renders with correct title', () => {
const { getByText } = render(
<Button title="Press me" onPress={() => {}} />
);
expect(getByText('Press me')).toBeTruthy();
});
it('calls onPress when pressed', () => {
const onPress = jest.fn();
const { getByText } = render(
<Button title="Press me" onPress={onPress} />
);
fireEvent.press(getByText('Press me'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('is disabled when loading', () => {
const onPress = jest.fn();
const { getByText } = render(
<Button title="Loading" onPress={onPress} loading />
);
fireEvent.press(getByText('Loading'));
expect(onPress).not.toHaveBeenCalled();
});
it('is disabled when disabled prop is true', () => {
const onPress = jest.fn();
const { getByText } = render(
<Button title="Disabled" onPress={onPress} disabled />
);
fireEvent.press(getByText('Disabled'));
expect(onPress).not.toHaveBeenCalled();
});
it('renders with testID', () => {
const { getByTestId } = render(
<Button title="Test" onPress={() => {}} testID="my-button" />
);
expect(getByTestId('my-button')).toBeTruthy();
});
});
Test de Card
// __tests__/components/Card.test.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { Card } from '@/components/Card';
jest.mock('@/contexts/ThemeContext', () => ({
useTheme: () => ({
colors: {
surface: '#FFFFFF',
text: '#000000',
textSecondary: '#666666',
border: '#E0E0E0',
error: '#FF3B30',
},
}),
}));
const mockItem = {
id: '1',
title: 'Test Item',
description: 'Test description',
image: 'https://example.com/image.jpg',
category: 'Tech',
author: 'John Doe',
authorAvatar: 'https://example.com/avatar.jpg',
date: '2024-01-20',
likes: 100,
comments: 50,
};
describe('Card', () => {
it('renders item information', () => {
const { getByText } = render(
<Card item={mockItem} onPress={() => {}} />
);
expect(getByText('Test Item')).toBeTruthy();
expect(getByText('Test description')).toBeTruthy();
expect(getByText('John Doe')).toBeTruthy();
});
it('calls onPress with item when pressed', () => {
const onPress = jest.fn();
const { getByText } = render(
<Card item={mockItem} onPress={onPress} />
);
fireEvent.press(getByText('Test Item'));
expect(onPress).toHaveBeenCalledWith(mockItem);
});
it('shows favorite icon when isFavorite is true', () => {
const { getByTestId } = render(
<Card
item={mockItem}
onPress={() => {}}
isFavorite={true}
testID="card"
/>
);
// Verificar que el icono de favorito está lleno
expect(getByTestId('card')).toBeTruthy();
});
});
// __tests__/components/Input.test.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { Input } from '@/components/Input';
jest.mock('@/contexts/ThemeContext', () => ({
useTheme: () => ({
colors: {
text: '#000000',
textSecondary: '#666666',
surface: '#FFFFFF',
border: '#E0E0E0',
primary: '#007AFF',
error: '#FF3B30',
},
}),
}));
describe('Input', () => {
it('renders with placeholder', () => {
const { getByPlaceholderText } = render(
<Input placeholder="Enter text" value="" onChangeText={() => {}} />
);
expect(getByPlaceholderText('Enter text')).toBeTruthy();
});
it('calls onChangeText when text changes', () => {
const onChangeText = jest.fn();
const { getByPlaceholderText } = render(
<Input placeholder="Enter text" value="" onChangeText={onChangeText} />
);
fireEvent.changeText(getByPlaceholderText('Enter text'), 'Hello');
expect(onChangeText).toHaveBeenCalledWith('Hello');
});
it('shows error message when error prop is provided', () => {
const { getByText } = render(
<Input
placeholder="Email"
value=""
onChangeText={() => {}}
error="Invalid email"
/>
);
expect(getByText('Invalid email')).toBeTruthy();
});
it('shows label when provided', () => {
const { getByText } = render(
<Input
label="Email"
placeholder="Enter email"
value=""
onChangeText={() => {}}
/>
);
expect(getByText('Email')).toBeTruthy();
});
});
Testing de Hooks
Test de hook personalizado
// __tests__/hooks/useDebounce.test.ts
import { renderHook, act } from '@testing-library/react-native';
import { useState } from 'react';
// Hook de ejemplo
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
React.useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
describe('useDebounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('initial', 500));
expect(result.current).toBe('initial');
});
it('updates value after delay', () => {
let value = 'initial';
const { result, rerender } = renderHook(() => useDebounce(value, 500));
value = 'updated';
rerender(() => useDebounce(value, 500));
// Antes del delay
expect(result.current).toBe('initial');
// Después del delay
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current).toBe('updated');
});
});
Testing de Contextos
Test de AppContext
// __tests__/contexts/AppContext.test.tsx
import React from 'react';
import { renderHook, act } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AppProvider, useApp } from '@/contexts/AppContext';
// Wrapper con providers necesarios
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<AppProvider>{children}</AppProvider>
</QueryClientProvider>
);
};
describe('AppContext', () => {
it('provides initial state', () => {
const { result } = renderHook(() => useApp(), {
wrapper: createWrapper(),
});
expect(result.current.favorites).toEqual([]);
expect(result.current.user).toBeDefined();
});
it('toggleFavorite adds and removes favorites', () => {
const { result } = renderHook(() => useApp(), {
wrapper: createWrapper(),
});
// Agregar favorito
act(() => {
result.current.toggleFavorite('item-1');
});
expect(result.current.isFavorite('item-1')).toBe(true);
// Remover favorito
act(() => {
result.current.toggleFavorite('item-1');
});
expect(result.current.isFavorite('item-1')).toBe(false);
});
it('updateUser updates user data', () => {
const { result } = renderHook(() => useApp(), {
wrapper: createWrapper(),
});
act(() => {
result.current.updateUser({ name: 'New Name' });
});
expect(result.current.user?.name).toBe('New Name');
});
});
Test de ThemeContext
// __tests__/contexts/ThemeContext.test.tsx
import React from 'react';
import { renderHook, act } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider, useTheme } from '@/contexts/ThemeContext';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
);
};
describe('ThemeContext', () => {
it('provides colors based on theme', () => {
const { result } = renderHook(() => useTheme(), {
wrapper: createWrapper(),
});
expect(result.current.colors).toBeDefined();
expect(result.current.colors.primary).toBeDefined();
expect(result.current.colors.background).toBeDefined();
});
it('setTheme changes theme mode', () => {
const { result } = renderHook(() => useTheme(), {
wrapper: createWrapper(),
});
act(() => {
result.current.setTheme('dark');
});
expect(result.current.themeMode).toBe('dark');
expect(result.current.isDark).toBe(true);
});
it('toggles between light and dark', () => {
const { result } = renderHook(() => useTheme(), {
wrapper: createWrapper(),
});
const initialDark = result.current.isDark;
act(() => {
result.current.setTheme(initialDark ? 'light' : 'dark');
});
expect(result.current.isDark).toBe(!initialDark);
});
});
Mocking
Mock de AsyncStorage
// __mocks__/@react-native-async-storage/async-storage.ts
let store: Record<string, string> = {};
export default {
setItem: jest.fn((key: string, value: string) => {
store[key] = value;
return Promise.resolve();
}),
getItem: jest.fn((key: string) => {
return Promise.resolve(store[key] || null);
}),
removeItem: jest.fn((key: string) => {
delete store[key];
return Promise.resolve();
}),
clear: jest.fn(() => {
store = {};
return Promise.resolve();
}),
getAllKeys: jest.fn(() => {
return Promise.resolve(Object.keys(store));
}),
};
Mock de API calls
// __tests__/api.test.ts
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/items', (req, res, ctx) => {
return res(
ctx.json([
{ id: '1', title: 'Item 1' },
{ id: '2', title: 'Item 2' },
])
);
}),
rest.post('/api/items', (req, res, ctx) => {
return res(ctx.json({ id: '3', title: 'New Item' }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('fetches items', async () => {
const response = await fetch('/api/items');
const data = await response.json();
expect(data).toHaveLength(2);
});
Mock de expo-router
const mockRouter = {
push: jest.fn(),
back: jest.fn(),
replace: jest.fn(),
canGoBack: jest.fn(() => true),
};
jest.mock('expo-router', () => ({
useRouter: () => mockRouter,
useLocalSearchParams: () => ({ id: '123' }),
useSegments: () => ['(tabs)', 'home'],
Link: ({ children }: { children: React.ReactNode }) => children,
}));
// En el test
test('navigates to detail', () => {
const { getByText } = render(<MyComponent />);
fireEvent.press(getByText('View Details'));
expect(mockRouter.push).toHaveBeenCalledWith('/item/123');
});
E2E Tests con Maestro
Instalación
# macOS
curl -Ls "https://get.maestro.mobile.dev" | bash
# Verificar instalación
maestro --version
Estructura de tests
e2e/
├── flows/
│ ├── auth/
│ │ ├── login.yaml
│ │ └── logout.yaml
│ ├── home/
│ │ ├── browse-items.yaml
│ │ └── favorites.yaml
│ └── profile/
│ └── edit-profile.yaml
└── utils/
└── common.yaml
Test de login
# e2e/flows/auth/login.yaml
appId: com.yourapp.app
---
- launchApp:
clearState: true
- tapOn: "Email"
- inputText: "test@example.com"
- tapOn: "Password"
- inputText: "password123"
- tapOn: "Sign In"
- assertVisible: "Welcome"
Test de navegación
# e2e/flows/home/browse-items.yaml
appId: com.yourapp.app
---
- launchApp
# Navegar a Explore
- tapOn:
id: "tab-explore"
- assertVisible: "Explore"
# Buscar un item
- tapOn:
id: "search-bar"
- inputText: "React"
- assertVisible: "React Native"
# Abrir detalle
- tapOn: "React Native"
- assertVisible: "Item Details"
# Volver
- tapOn:
id: "back-button"
- assertVisible: "Explore"
Test de favoritos
# e2e/flows/home/favorites.yaml
appId: com.yourapp.app
---
- launchApp
# Ir a un item
- tapOn: "First Item"
# Agregar a favoritos
- tapOn:
id: "favorite-button"
# Verificar toast
- assertVisible: "Added to favorites"
# Ir a favoritos
- tapOn:
id: "tab-favorites"
# Verificar que aparece
- assertVisible: "First Item"
Ejecutar tests
# Test individual
maestro test e2e/flows/auth/login.yaml
# Todos los tests
maestro test e2e/flows/
# Con grabación
maestro record e2e/flows/auth/login.yaml
# En CI
maestro test --format junit e2e/flows/
Best Practices
1. Usar testID consistentemente
// components/Button.tsx
<TouchableOpacity testID={testID || 'button'} ... />
// En tests
const { getByTestId } = render(<Button testID="submit-btn" ... />);
fireEvent.press(getByTestId('submit-btn'));
2. Crear helpers de testing
// __tests__/helpers/renderWithProviders.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react-native';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { AppProvider } from '@/contexts/AppContext';
export function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AppProvider>
{ui}
</AppProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
// Uso
import { renderWithProviders } from '../helpers/renderWithProviders';
test('component with all providers', () => {
const { getByText } = renderWithProviders(<MyComponent />);
// ...
});
3. Organizar tests por feature
__tests__/
├── features/
│ ├── auth/
│ │ ├── LoginScreen.test.tsx
│ │ └── useAuth.test.ts
│ ├── items/
│ │ ├── ItemList.test.tsx
│ │ └── ItemDetail.test.tsx
│ └── profile/
│ └── ProfileScreen.test.tsx
├── components/
├── contexts/
└── utils/
4. Snapshot testing para UI
import { render } from '@testing-library/react-native';
import { Card } from '@/components/Card';
test('Card matches snapshot', () => {
const tree = render(
<Card item={mockItem} onPress={() => {}} />
).toJSON();
expect(tree).toMatchSnapshot();
});
5. Test de accesibilidad
import { render } from '@testing-library/react-native';
test('Button is accessible', () => {
const { getByRole } = render(
<Button
title="Submit"
onPress={() => {}}
accessibilityLabel="Submit form"
accessibilityHint="Double tap to submit the form"
/>
);
const button = getByRole('button');
expect(button).toHaveAccessibilityValue({ text: 'Submit form' });
});
6. Coverage mínimo
// jest.config.js
module.exports = {
// ...
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
};
Recursos