← Volver al listado de tecnologías

Navegación con React Navigation y Testing

Por: Artiko
react-nativenavigationtestingtypescript

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

📦 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:

Próximos Pasos

En el siguiente capítulo implementaremos:

¡La navegación está completamente configurada y testeada! 🎉

🔗 Navegación