← Volver al listado de tecnologías
Navegación con React Navigation y Testing
Capítulo 6: Navegación con React Navigation y Testing
En este capítulo implementaremos un sistema de navegación completo con React Navigation, incluyendo tipado TypeScript y testing exhaustivo.
🎯 Objetivos del Capítulo
- Configurar React Navigation con TypeScript
- Implementar Stack y Tab Navigation
- Testing de navegación con React Navigation Testing Library
- Deep linking y URL handling
- Navegación condicional basada en autenticación
📦 Instalación de Dependencias
# React Navigation core
npm install @react-navigation/native
# Dependencias requeridas para React Native
npm install react-native-screens react-native-safe-area-context
# Navigators
npm install @react-navigation/stack @react-navigation/bottom-tabs
npm install @react-navigation/drawer
# Dependencias para Stack Navigator
npm install react-native-gesture-handler
# Testing de navegación
npm install --save-dev @react-navigation/testing-library
# Deep linking
npm install react-native-url-polyfill
Configuración iOS (si usas iOS)
cd ios && pod install
🗺️ Configuración de Tipos de Navegación
Definición de Tipos de Rutas
// src/types/navigation.ts
import { NavigatorScreenParams } from '@react-navigation/native';
import { StackScreenProps } from '@react-navigation/stack';
import { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
// Stack Principal
export type RootStackParamList = {
Auth: NavigatorScreenParams<AuthStackParamList>;
Main: NavigatorScreenParams<MainTabParamList>;
TodoDetail: { todoId: string };
TodoForm: { todoId?: string; categoryId?: string };
CategoryForm: { categoryId?: string };
Settings: undefined;
};
// Stack de Autenticación
export type AuthStackParamList = {
Welcome: undefined;
SignIn: undefined;
SignUp: undefined;
ForgotPassword: undefined;
};
// Tab Principal
export type MainTabParamList = {
TodoList: NavigatorScreenParams<TodoStackParamList>;
Categories: NavigatorScreenParams<CategoryStackParamList>;
Profile: undefined;
};
// Stack de Todos
export type TodoStackParamList = {
TodoHome: undefined;
TodoSearch: undefined;
TodoFilters: undefined;
};
// Stack de Categorías
export type CategoryStackParamList = {
CategoryHome: undefined;
CategoryDetail: { categoryId: string };
};
// Props para pantallas
export type RootStackScreenProps<T extends keyof RootStackParamList> =
StackScreenProps<RootStackParamList, T>;
export type AuthStackScreenProps<T extends keyof AuthStackParamList> =
StackScreenProps<AuthStackParamList, T>;
export type MainTabScreenProps<T extends keyof MainTabParamList> =
BottomTabScreenProps<MainTabParamList, T>;
export type TodoStackScreenProps<T extends keyof TodoStackParamList> =
StackScreenProps<TodoStackParamList, T>;
export type CategoryStackScreenProps<T extends keyof CategoryStackParamList> =
StackScreenProps<CategoryStackParamList, T>;
// Declaración global para TypeScript
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}
🧭 Configuración de Navegadores
Stack Navigator Principal
// src/navigation/RootNavigator.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { useAuthStore } from '@/stores/authStore';
import { AuthNavigator } from './AuthNavigator';
import { MainNavigator } from './MainNavigator';
import { TodoDetailScreen } from '@/screens/TodoDetailScreen';
import { TodoFormScreen } from '@/screens/TodoFormScreen';
import { CategoryFormScreen } from '@/screens/CategoryFormScreen';
import { SettingsScreen } from '@/screens/SettingsScreen';
import { RootStackParamList } from '@/types/navigation';
import { linking } from './linking';
const Stack = createStackNavigator<RootStackParamList>();
export const RootNavigator: React.FC = () => {
const isAuthenticated = useAuthStore(state => state.isAuthenticated);
const isLoading = useAuthStore(state => state.loading);
if (isLoading) {
return <LoadingScreen />;
}
return (
<NavigationContainer linking={linking}>
<Stack.Navigator
screenOptions={{
headerShown: false,
gestureEnabled: true,
cardStyleInterpolator: ({ current, layouts }) => ({
cardStyle: {
transform: [
{
translateX: current.progress.interpolate({
inputRange: [0, 1],
outputRange: [layouts.screen.width, 0],
}),
},
],
},
}),
}}
>
{!isAuthenticated ? (
<Stack.Screen name="Auth" component={AuthNavigator} />
) : (
<>
<Stack.Screen name="Main" component={MainNavigator} />
<Stack.Screen
name="TodoDetail"
component={TodoDetailScreen}
options={{
headerShown: true,
title: 'Detalle de Tarea',
}}
/>
<Stack.Screen
name="TodoForm"
component={TodoFormScreen}
options={({ route }) => ({
headerShown: true,
title: route.params?.todoId ? 'Editar Tarea' : 'Nueva Tarea',
presentation: 'modal',
})}
/>
<Stack.Screen
name="CategoryForm"
component={CategoryFormScreen}
options={({ route }) => ({
headerShown: true,
title: route.params?.categoryId ? 'Editar Categoría' : 'Nueva Categoría',
presentation: 'modal',
})}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}
options={{
headerShown: true,
title: 'Configuración',
}}
/>
</>
)}
</Stack.Navigator>
</NavigationContainer>
);
};
const LoadingScreen: React.FC = () => (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
<Text style={{ marginTop: 16 }}>Cargando...</Text>
</View>
);
Tab Navigator
// src/navigation/MainNavigator.tsx
import React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';
import { TodoNavigator } from './TodoNavigator';
import { CategoryNavigator } from './CategoryNavigator';
import { ProfileScreen } from '@/screens/ProfileScreen';
import { MainTabParamList } from '@/types/navigation';
import { useTheme } from '@/hooks/useTheme';
const Tab = createBottomTabNavigator<MainTabParamList>();
export const MainNavigator: React.FC = () => {
const { colors } = useTheme();
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused, color, size }) => {
let iconName: keyof typeof Ionicons.glyphMap;
switch (route.name) {
case 'TodoList':
iconName = focused ? 'checkbox' : 'checkbox-outline';
break;
case 'Categories':
iconName = focused ? 'folder' : 'folder-outline';
break;
case 'Profile':
iconName = focused ? 'person' : 'person-outline';
break;
default:
iconName = 'help-outline';
}
return <Ionicons name={iconName} size={size} color={color} />;
},
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textSecondary,
tabBarStyle: {
backgroundColor: colors.surface,
borderTopColor: colors.border,
},
headerShown: false,
})}
>
<Tab.Screen
name="TodoList"
component={TodoNavigator}
options={{
title: 'Tareas',
}}
/>
<Tab.Screen
name="Categories"
component={CategoryNavigator}
options={{
title: 'Categorías',
}}
/>
<Tab.Screen
name="Profile"
component={ProfileScreen}
options={{
title: 'Perfil',
}}
/>
</Tab.Navigator>
);
};
Stack Navigator de Todos
// src/navigation/TodoNavigator.tsx
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import { TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { TodoHomeScreen } from '@/screens/TodoHomeScreen';
import { TodoSearchScreen } from '@/screens/TodoSearchScreen';
import { TodoFiltersScreen } from '@/screens/TodoFiltersScreen';
import { TodoStackParamList } from '@/types/navigation';
import { useTheme } from '@/hooks/useTheme';
const Stack = createStackNavigator<TodoStackParamList>();
export const TodoNavigator: React.FC = () => {
const { colors } = useTheme();
return (
<Stack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: colors.surface,
},
headerTintColor: colors.text,
headerTitleStyle: {
fontWeight: 'bold',
},
}}
>
<Stack.Screen
name="TodoHome"
component={TodoHomeScreen}
options={({ navigation }) => ({
title: 'Mis Tareas',
headerRight: () => (
<TouchableOpacity
onPress={() => navigation.navigate('TodoSearch')}
style={{ marginRight: 16 }}
>
<Ionicons name="search" size={24} color={colors.text} />
</TouchableOpacity>
),
})}
/>
<Stack.Screen
name="TodoSearch"
component={TodoSearchScreen}
options={{
title: 'Buscar Tareas',
}}
/>
<Stack.Screen
name="TodoFilters"
component={TodoFiltersScreen}
options={{
title: 'Filtros',
presentation: 'modal',
}}
/>
</Stack.Navigator>
);
};
🔗 Deep Linking
Configuración de Linking
// src/navigation/linking.ts
import { LinkingOptions } from '@react-navigation/native';
import { RootStackParamList } from '@/types/navigation';
export const linking: LinkingOptions<RootStackParamList> = {
prefixes: ['todoapp://', 'https://todoapp.com'],
config: {
screens: {
Auth: {
screens: {
Welcome: 'welcome',
SignIn: 'signin',
SignUp: 'signup',
ForgotPassword: 'forgot-password',
},
},
Main: {
screens: {
TodoList: {
screens: {
TodoHome: 'todos',
TodoSearch: 'todos/search',
TodoFilters: 'todos/filters',
},
},
Categories: {
screens: {
CategoryHome: 'categories',
CategoryDetail: 'categories/:categoryId',
},
},
Profile: 'profile',
},
},
TodoDetail: 'todos/:todoId',
TodoForm: {
path: 'todos/form/:todoId?',
parse: {
todoId: (todoId: string) => todoId || undefined,
},
},
CategoryForm: {
path: 'categories/form/:categoryId?',
parse: {
categoryId: (categoryId: string) => categoryId || undefined,
},
},
Settings: 'settings',
},
},
};
🧪 Testing de Navegación
Setup de Testing para Navegación
// src/test/navigation-utils.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { render, RenderOptions } from '@testing-library/react-native';
// Mock del stack navigator para testing
const TestStack = createStackNavigator();
interface NavigationTestWrapperProps {
children: React.ReactNode;
initialRouteName?: string;
initialParams?: any;
}
const NavigationTestWrapper: React.FC<NavigationTestWrapperProps> = ({
children,
initialRouteName = 'TestScreen',
initialParams = {},
}) => {
return (
<NavigationContainer>
<TestStack.Navigator initialRouteName={initialRouteName}>
<TestStack.Screen
name="TestScreen"
children={() => children}
initialParams={initialParams}
/>
</TestStack.Navigator>
</NavigationContainer>
);
};
// Custom render para testing con navegación
export const renderWithNavigation = (
ui: React.ReactElement,
options?: RenderOptions & {
initialRouteName?: string;
initialParams?: any;
}
) => {
const { initialRouteName, initialParams, ...renderOptions } = options || {};
return render(ui, {
wrapper: ({ children }) => (
<NavigationTestWrapper
initialRouteName={initialRouteName}
initialParams={initialParams}
>
{children}
</NavigationTestWrapper>
),
...renderOptions,
});
};
// Mock de useNavigation
export const mockNavigation = {
navigate: jest.fn(),
goBack: jest.fn(),
dispatch: jest.fn(),
setParams: jest.fn(),
addListener: jest.fn(() => jest.fn()),
removeListener: jest.fn(),
canGoBack: jest.fn(() => false),
isFocused: jest.fn(() => true),
push: jest.fn(),
replace: jest.fn(),
pop: jest.fn(),
popToTop: jest.fn(),
reset: jest.fn(),
setOptions: jest.fn(),
getParent: jest.fn(),
getState: jest.fn(),
};
// Mock de useRoute
export const mockRoute = {
key: 'test-key',
name: 'TestScreen' as const,
params: {},
};
Tests de Navegación
// src/navigation/__tests__/RootNavigator.test.tsx
import React from 'react';
import { render, waitFor } from '@testing-library/react-native';
import { NavigationContainer } from '@react-navigation/native';
import { RootNavigator } from '../RootNavigator';
import { useAuthStore } from '@/stores/authStore';
// Mock de los stores
jest.mock('@/stores/authStore');
const mockUseAuthStore = useAuthStore as jest.MockedFunction<typeof useAuthStore>;
// Mock de las pantallas
jest.mock('@/screens/TodoHomeScreen', () => ({
TodoHomeScreen: () => null,
}));
jest.mock('../AuthNavigator', () => ({
AuthNavigator: () => null,
}));
jest.mock('../MainNavigator', () => ({
MainNavigator: () => null,
}));
describe('RootNavigator', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should show auth navigator when not authenticated', async () => {
mockUseAuthStore.mockImplementation((selector) => {
if (selector.toString().includes('isAuthenticated')) return false;
if (selector.toString().includes('loading')) return false;
return null;
});
const { getByTestId } = render(
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
);
await waitFor(() => {
// Verificar que se muestra el AuthNavigator
expect(mockUseAuthStore).toHaveBeenCalled();
});
});
it('should show main navigator when authenticated', async () => {
mockUseAuthStore.mockImplementation((selector) => {
if (selector.toString().includes('isAuthenticated')) return true;
if (selector.toString().includes('loading')) return false;
return null;
});
const { getByTestId } = render(
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
);
await waitFor(() => {
// Verificar que se muestra el MainNavigator
expect(mockUseAuthStore).toHaveBeenCalled();
});
});
it('should show loading screen when loading', async () => {
mockUseAuthStore.mockImplementation((selector) => {
if (selector.toString().includes('loading')) return true;
return null;
});
const { getByText } = render(
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
);
expect(getByText('Cargando...')).toBeTruthy();
});
});
🎯 Hooks de Navegación Personalizados
Hook para Navegación Tipada
// src/hooks/useTypedNavigation.ts
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { RootStackParamList } from '@/types/navigation';
export type NavigationProp = StackNavigationProp<RootStackParamList>;
export const useTypedNavigation = () => {
return useNavigation<NavigationProp>();
};
// Hook específico para pantallas de todos
export const useTodoNavigation = () => {
const navigation = useTypedNavigation();
const navigateToTodoDetail = (todoId: string) => {
navigation.navigate('TodoDetail', { todoId });
};
const navigateToTodoForm = (todoId?: string, categoryId?: string) => {
navigation.navigate('TodoForm', { todoId, categoryId });
};
const navigateToTodoSearch = () => {
navigation.navigate('Main', {
screen: 'TodoList',
params: {
screen: 'TodoSearch',
},
});
};
return {
navigateToTodoDetail,
navigateToTodoForm,
navigateToTodoSearch,
};
};
Tests de Hooks de Navegación
// src/hooks/__tests__/useTypedNavigation.test.ts
import { renderHook } from '@testing-library/react-native';
import { useTodoNavigation } from '../useTypedNavigation';
import { mockNavigation } from '@/test/navigation-utils';
jest.mock('@react-navigation/native', () => ({
useNavigation: () => mockNavigation,
}));
describe('useTodoNavigation', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should navigate to todo detail', () => {
const { result } = renderHook(() => useTodoNavigation());
result.current.navigateToTodoDetail('123');
expect(mockNavigation.navigate).toHaveBeenCalledWith('TodoDetail', {
todoId: '123',
});
});
it('should navigate to todo form without parameters', () => {
const { result } = renderHook(() => useTodoNavigation());
result.current.navigateToTodoForm();
expect(mockNavigation.navigate).toHaveBeenCalledWith('TodoForm', {
todoId: undefined,
categoryId: undefined,
});
});
it('should navigate to todo form with parameters', () => {
const { result } = renderHook(() => useTodoNavigation());
result.current.navigateToTodoForm('123', 'work');
expect(mockNavigation.navigate).toHaveBeenCalledWith('TodoForm', {
todoId: '123',
categoryId: 'work',
});
});
it('should navigate to todo search', () => {
const { result } = renderHook(() => useTodoNavigation());
result.current.navigateToTodoSearch();
expect(mockNavigation.navigate).toHaveBeenCalledWith('Main', {
screen: 'TodoList',
params: {
screen: 'TodoSearch',
},
});
});
});
✅ Verificación
Ejecuta los tests para verificar la navegación:
# Tests de navegación
npm test -- --testPathPattern=navigation
# Tests específicos de deep linking
npm test -- --testNamePattern="Deep Linking"
# Tests de hooks de navegación
npm test -- --testNamePattern="useTypedNavigation"
# Coverage de navegación
npm test -- --testPathPattern=navigation --coverage
📝 Resumen
En este capítulo hemos:
- ✅ Configurado React Navigation con TypeScript completo
- ✅ Implementado Stack, Tab y Modal navigation
- ✅ Configurado deep linking con URLs
- ✅ Creado testing completo de navegación
- ✅ Desarrollado hooks personalizados tipados
Próximos Pasos
En el siguiente capítulo implementaremos:
- Configuración de Clerk para autenticación
- OAuth providers (Google, Apple)
- Protección de rutas
- Testing de autenticación
¡La navegación está completamente configurada y testeada! 🎉