← Volver al listado de tecnologías

Testing Avanzado: MSW, Testing Library y Mejores Prácticas

Por: Artiko
react-nativetestingmswtesting-libraryjest

Capítulo 6.5: Testing Avanzado: MSW, Testing Library y Mejores Prácticas

En este capítulo especial profundizaremos en técnicas avanzadas de testing, configuración completa de MSW y patterns profesionales para testing en React Native.

🎯 Objetivos del Capítulo

📦 Configuración Avanzada de MSW

Instalación Completa

# MSW y polyfills
npm install --save-dev msw @mswjs/react-native-polyfills

# Utilidades adicionales para testing
npm install --save-dev @testing-library/user-event
npm install --save-dev jest-environment-jsdom

# Para testing de componentes con animaciones
npm install --save-dev react-native-testing-mocks

Configuración Global de MSW

// src/test/mocks/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

// Configurar servidor MSW
export const server = setupServer(...handlers);

// Setup global para Jest
export const setupMSWGlobal = () => {
  // Configurar polyfills para React Native
  require('@mswjs/react-native-polyfills');

  beforeAll(() => {
    // Iniciar servidor
    server.listen({
      onUnhandledRequest: 'warn',
    });
  });

  beforeEach(() => {
    // Limpiar mocks antes de cada test
    jest.clearAllMocks();
  });

  afterEach(() => {
    // Reset handlers después de cada test
    server.resetHandlers();
  });

  afterAll(() => {
    // Cerrar servidor
    server.close();
  });
};

Handlers Avanzados con Escenarios

// src/test/mocks/handlers/todoHandlers.ts
import { rest } from 'msw';
import { Todo, Category } from '@/types/store';

// Datos mock más realistas
const mockTodos: Todo[] = [
  {
    id: '1',
    title: 'Completar proyecto React Native',
    description: 'Finalizar la aplicación TODO con todas las funcionalidades',
    completed: false,
    createdAt: new Date('2024-01-01'),
    updatedAt: new Date('2024-01-01'),
    userId: 'user-123',
    categoryId: 'work',
    priority: 'high',
    dueDate: new Date('2024-12-31'),
    tags: ['react-native', 'proyecto', 'urgente'],
  },
  {
    id: '2',
    title: 'Hacer compras del supermercado',
    description: 'Leche, pan, huevos, frutas',
    completed: true,
    createdAt: new Date('2024-01-02'),
    updatedAt: new Date('2024-01-03'),
    userId: 'user-123',
    categoryId: 'personal',
    priority: 'medium',
    dueDate: new Date('2024-01-05'),
    tags: ['compras', 'hogar'],
  },
];

const mockCategories: Category[] = [
  {
    id: 'work',
    name: 'Trabajo',
    color: '#3B82F6',
    icon: 'briefcase',
    userId: 'user-123',
  },
  {
    id: 'personal',
    name: 'Personal',
    color: '#10B981',
    icon: 'home',
    userId: 'user-123',
  },
];

// Helper para simular delays de red
const networkDelay = () => Math.random() * 1000 + 500;

export const todoHandlers = [
  // GET /todos - Con filtros y paginación
  rest.get('/api/todos', async (req, res, ctx) => {
    const url = req.url;
    const userId = url.searchParams.get('userId');
    const category = url.searchParams.get('category');
    const completed = url.searchParams.get('completed');
    const search = url.searchParams.get('search');
    const page = parseInt(url.searchParams.get('page') || '1');
    const limit = parseInt(url.searchParams.get('limit') || '10');

    // Simular delay de red
    await ctx.delay(networkDelay());

    let filteredTodos = mockTodos.filter(todo => 
      !userId || todo.userId === userId
    );

    // Aplicar filtros
    if (category) {
      filteredTodos = filteredTodos.filter(todo => todo.categoryId === category);
    }

    if (completed !== null) {
      const isCompleted = completed === 'true';
      filteredTodos = filteredTodos.filter(todo => todo.completed === isCompleted);
    }

    if (search) {
      const searchLower = search.toLowerCase();
      filteredTodos = filteredTodos.filter(todo =>
        todo.title.toLowerCase().includes(searchLower) ||
        todo.description.toLowerCase().includes(searchLower) ||
        todo.tags.some(tag => tag.toLowerCase().includes(searchLower))
      );
    }

    // Paginación
    const startIndex = (page - 1) * limit;
    const endIndex = startIndex + limit;
    const paginatedTodos = filteredTodos.slice(startIndex, endIndex);

    return res(
      ctx.status(200),
      ctx.json({
        todos: paginatedTodos,
        pagination: {
          page,
          limit,
          total: filteredTodos.length,
          totalPages: Math.ceil(filteredTodos.length / limit),
        },
      })
    );
  }),

  // POST /todos - Con validación
  rest.post('/api/todos', async (req, res, ctx) => {
    const todoData = await req.json();

    // Simular validación
    if (!todoData.title || todoData.title.length < 3) {
      return res(
        ctx.status(400),
        ctx.json({
          error: 'Title must be at least 3 characters long',
          field: 'title',
        })
      );
    }

    await ctx.delay(networkDelay());

    const newTodo: Todo = {
      ...todoData,
      id: `todo-${Date.now()}`,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    mockTodos.push(newTodo);

    return res(
      ctx.status(201),
      ctx.json({ todo: newTodo })
    );
  }),

  // PUT /todos/:id - Con optimistic updates
  rest.put('/api/todos/:id', async (req, res, ctx) => {
    const { id } = req.params;
    const updates = await req.json();

    await ctx.delay(networkDelay());

    const todoIndex = mockTodos.findIndex(todo => todo.id === id);
    if (todoIndex === -1) {
      return res(
        ctx.status(404),
        ctx.json({ error: 'Todo not found' })
      );
    }

    mockTodos[todoIndex] = {
      ...mockTodos[todoIndex],
      ...updates,
      updatedAt: new Date(),
    };

    return res(
      ctx.status(200),
      ctx.json({ todo: mockTodos[todoIndex] })
    );
  }),

  // DELETE /todos/:id
  rest.delete('/api/todos/:id', async (req, res, ctx) => {
    const { id } = req.params;

    await ctx.delay(networkDelay());

    const todoIndex = mockTodos.findIndex(todo => todo.id === id);
    if (todoIndex === -1) {
      return res(
        ctx.status(404),
        ctx.json({ error: 'Todo not found' })
      );
    }

    mockTodos.splice(todoIndex, 1);

    return res(ctx.status(204));
  }),

  // GET /categories
  rest.get('/api/categories', async (req, res, ctx) => {
    const userId = req.url.searchParams.get('userId');

    await ctx.delay(networkDelay());

    const filteredCategories = mockCategories.filter(category =>
      !userId || category.userId === userId
    );

    return res(
      ctx.status(200),
      ctx.json({ categories: filteredCategories })
    );
  }),
];

// Handlers para escenarios de error
export const errorHandlers = [
  // Simular error de servidor
  rest.get('/api/todos', (req, res, ctx) => {
    return res(
      ctx.status(500),
      ctx.json({ error: 'Internal server error' })
    );
  }),

  // Simular error de red
  rest.post('/api/todos', (req, res, ctx) => {
    return res.networkError('Network connection failed');
  }),

  // Simular timeout
  rest.put('/api/todos/:id', async (req, res, ctx) => {
    await ctx.delay(10000); // 10 segundos
    return res(ctx.status(408), ctx.json({ error: 'Request timeout' }));
  }),
];

Configuración de Escenarios de Testing

// src/test/mocks/scenarios.ts
import { server } from './setup';
import { errorHandlers, todoHandlers } from './handlers/todoHandlers';

export const testScenarios = {
  // Escenario normal
  normal: () => {
    server.use(...todoHandlers);
  },

  // Escenario de errores
  serverError: () => {
    server.use(...errorHandlers);
  },

  // Escenario de datos vacíos
  emptyData: () => {
    server.use(
      rest.get('/api/todos', (req, res, ctx) => {
        return res(
          ctx.status(200),
          ctx.json({
            todos: [],
            pagination: { page: 1, limit: 10, total: 0, totalPages: 0 },
          })
        );
      })
    );
  },

  // Escenario de carga lenta
  slowNetwork: () => {
    server.use(
      rest.get('/api/todos', async (req, res, ctx) => {
        await ctx.delay(5000);
        return res(
          ctx.status(200),
          ctx.json({
            todos: [],
            pagination: { page: 1, limit: 10, total: 0, totalPages: 0 },
          })
        );
      })
    );
  },
};

🧪 Testing Avanzado de Componentes

Testing de Componentes con Estados Complejos

// src/components/__tests__/TodoList.test.tsx
import React from 'react';
import { render, fireEvent, waitFor, within } from '@testing-library/react-native';
import { TodoList } from '../TodoList';
import { renderWithProviders } from '@/test/utils';
import { testScenarios } from '@/test/mocks/scenarios';
import { setupMSWGlobal } from '@/test/mocks/setup';

// Setup MSW
setupMSWGlobal();

describe('TodoList Component', () => {
  beforeEach(() => {
    testScenarios.normal();
  });

  describe('Loading States', () => {
    it('should show loading indicator while fetching todos', async () => {
      testScenarios.slowNetwork();

      const { getByTestId } = renderWithProviders(<TodoList />);

      expect(getByTestId('loading-indicator')).toBeTruthy();

      await waitFor(
        () => {
          expect(() => getByTestId('loading-indicator')).toThrow();
        },
        { timeout: 6000 }
      );
    });

    it('should show skeleton loading for better UX', async () => {
      testScenarios.slowNetwork();

      const { getByTestId } = renderWithProviders(<TodoList />);

      expect(getByTestId('skeleton-loader')).toBeTruthy();
    });
  });

  describe('Error States', () => {
    it('should show error message when API fails', async () => {
      testScenarios.serverError();

      const { getByText, getByTestId } = renderWithProviders(<TodoList />);

      await waitFor(() => {
        expect(getByText('Error al cargar las tareas')).toBeTruthy();
      });

      expect(getByTestId('retry-button')).toBeTruthy();
    });

    it('should retry loading when retry button is pressed', async () => {
      testScenarios.serverError();

      const { getByTestId } = renderWithProviders(<TodoList />);

      await waitFor(() => {
        expect(getByTestId('retry-button')).toBeTruthy();
      });

      // Cambiar a escenario normal
      testScenarios.normal();

      fireEvent.press(getByTestId('retry-button'));

      await waitFor(() => {
        expect(getByTestId('todo-list')).toBeTruthy();
      });
    });
  });

  describe('Empty States', () => {
    it('should show empty state when no todos exist', async () => {
      testScenarios.emptyData();

      const { getByText, getByTestId } = renderWithProviders(<TodoList />);

      await waitFor(() => {
        expect(getByText('No tienes tareas pendientes')).toBeTruthy();
      });

      expect(getByTestId('add-first-todo-button')).toBeTruthy();
    });

    it('should show filtered empty state', async () => {
      const { getByTestId, getByText } = renderWithProviders(
        <TodoList filter={{ completed: true }} />
      );

      await waitFor(() => {
        expect(getByText('No hay tareas completadas')).toBeTruthy();
      });
    });
  });

  describe('Interaction Testing', () => {
    it('should toggle todo completion', async () => {
      const { getByTestId } = renderWithProviders(<TodoList />);

      await waitFor(() => {
        expect(getByTestId('todo-item-1')).toBeTruthy();
      });

      const todoItem = getByTestId('todo-item-1');
      const checkbox = within(todoItem).getByTestId('todo-checkbox');

      fireEvent.press(checkbox);

      await waitFor(() => {
        expect(within(todoItem).getByTestId('completed-todo')).toBeTruthy();
      });
    });

    it('should navigate to todo detail on press', async () => {
      const mockNavigate = jest.fn();
      jest.mock('@/hooks/useTypedNavigation', () => ({
        useTodoNavigation: () => ({
          navigateToTodoDetail: mockNavigate,
        }),
      }));

      const { getByTestId } = renderWithProviders(<TodoList />);

      await waitFor(() => {
        expect(getByTestId('todo-item-1')).toBeTruthy();
      });

      fireEvent.press(getByTestId('todo-item-1'));

      expect(mockNavigate).toHaveBeenCalledWith('1');
    });
  });

  describe('Accessibility Testing', () => {
    it('should have proper accessibility labels', async () => {
      const { getByTestId } = renderWithProviders(<TodoList />);

      await waitFor(() => {
        expect(getByTestId('todo-item-1')).toBeTruthy();
      });

      const todoItem = getByTestId('todo-item-1');
      expect(todoItem.props.accessibilityLabel).toContain('Tarea: Completar proyecto React Native');
      expect(todoItem.props.accessibilityHint).toBe('Toca para ver detalles');
    });

    it('should support screen reader navigation', async () => {
      const { getByTestId } = renderWithProviders(<TodoList />);

      await waitFor(() => {
        expect(getByTestId('todo-list')).toBeTruthy();
      });

      const todoList = getByTestId('todo-list');
      expect(todoList.props.accessibilityRole).toBe('list');
    });
  });

  describe('Performance Testing', () => {
    it('should handle large lists efficiently', async () => {
      // Mock una lista grande
      const largeTodoList = Array.from({ length: 1000 }, (_, i) => ({
        id: `todo-${i}`,
        title: `Tarea ${i}`,
        description: `Descripción ${i}`,
        completed: i % 3 === 0,
        createdAt: new Date(),
        updatedAt: new Date(),
        userId: 'user-123',
        categoryId: 'work',
        priority: 'medium',
        dueDate: null,
        tags: [],
      }));

      server.use(
        rest.get('/api/todos', (req, res, ctx) => {
          return res(
            ctx.status(200),
            ctx.json({
              todos: largeTodoList,
              pagination: { page: 1, limit: 1000, total: 1000, totalPages: 1 },
            })
          );
        })
      );

      const startTime = performance.now();
      const { getByTestId } = renderWithProviders(<TodoList />);

      await waitFor(() => {
        expect(getByTestId('todo-list')).toBeTruthy();
      });

      const endTime = performance.now();
      const renderTime = endTime - startTime;

      // El render no debería tomar más de 2 segundos
      expect(renderTime).toBeLessThan(2000);
    });
  });
});

Testing de Hooks Personalizados

// src/hooks/__tests__/useTodoOperations.test.ts
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { useTodoOperations } from '../useTodoOperations';
import { setupMSWGlobal } from '@/test/mocks/setup';
import { testScenarios } from '@/test/mocks/scenarios';
import { createWrapper } from '@/test/utils';

setupMSWGlobal();

describe('useTodoOperations Hook', () => {
  const wrapper = createWrapper();

  beforeEach(() => {
    testScenarios.normal();
  });

  describe('Creating Todos', () => {
    it('should create a new todo successfully', async () => {
      const { result } = renderHook(() => useTodoOperations(), { wrapper });

      const newTodo = {
        title: 'Nueva tarea',
        description: 'Descripción de la nueva tarea',
        completed: false,
        userId: 'user-123',
        categoryId: 'work',
        priority: 'medium' as const,
        dueDate: null,
        tags: ['nueva'],
      };

      await act(async () => {
        await result.current.createTodo(newTodo);
      });

      expect(result.current.isLoading).toBe(false);
      expect(result.current.error).toBeNull();
    });

    it('should handle validation errors', async () => {
      const { result } = renderHook(() => useTodoOperations(), { wrapper });

      const invalidTodo = {
        title: 'AB', // Muy corto
        description: '',
        completed: false,
        userId: 'user-123',
        categoryId: 'work',
        priority: 'medium' as const,
        dueDate: null,
        tags: [],
      };

      await act(async () => {
        await result.current.createTodo(invalidTodo);
      });

      expect(result.current.error).toContain('Title must be at least 3 characters');
    });
  });

  describe('Updating Todos', () => {
    it('should update todo optimistically', async () => {
      const { result } = renderHook(() => useTodoOperations(), { wrapper });

      // Primero cargar todos
      await act(async () => {
        await result.current.loadTodos();
      });

      const todoId = '1';
      const updates = { title: 'Título actualizado' };

      await act(async () => {
        await result.current.updateTodo(todoId, updates);
      });

      // Verificar que la actualización fue optimista
      expect(result.current.todos.find(t => t.id === todoId)?.title).toBe('Título actualizado');
    });

    it('should revert optimistic update on error', async () => {
      testScenarios.serverError();

      const { result } = renderHook(() => useTodoOperations(), { wrapper });

      // Cargar datos iniciales con escenario normal
      testScenarios.normal();
      await act(async () => {
        await result.current.loadTodos();
      });

      const originalTitle = result.current.todos.find(t => t.id === '1')?.title;

      // Cambiar a escenario de error
      testScenarios.serverError();

      const updates = { title: 'Título que fallará' };

      await act(async () => {
        await result.current.updateTodo('1', updates);
      });

      // Verificar que se revirtió la actualización
      expect(result.current.todos.find(t => t.id === '1')?.title).toBe(originalTitle);
      expect(result.current.error).toBeTruthy();
    });
  });

  describe('Batch Operations', () => {
    it('should handle multiple operations in sequence', async () => {
      const { result } = renderHook(() => useTodoOperations(), { wrapper });

      const operations = [
        () => result.current.createTodo({
          title: 'Tarea 1',
          description: '',
          completed: false,
          userId: 'user-123',
          categoryId: 'work',
          priority: 'medium' as const,
          dueDate: null,
          tags: [],
        }),
        () => result.current.createTodo({
          title: 'Tarea 2',
          description: '',
          completed: false,
          userId: 'user-123',
          categoryId: 'personal',
          priority: 'high' as const,
          dueDate: null,
          tags: [],
        }),
        () => result.current.updateTodo('1', { completed: true }),
      ];

      await act(async () => {
        await Promise.all(operations.map(op => op()));
      });

      expect(result.current.isLoading).toBe(false);
      expect(result.current.error).toBeNull();
    });
  });
});

🎭 Mocking Avanzado y Fixtures

Factory para Datos de Testing

// src/test/factories/todoFactory.ts
import { Todo, Category } from '@/types/store';
import { faker } from '@faker-js/faker';

export class TodoFactory {
  static create(overrides: Partial<Todo> = {}): Todo {
    return {
      id: faker.string.uuid(),
      title: faker.lorem.sentence({ min: 3, max: 8 }),
      description: faker.lorem.paragraph(),
      completed: faker.datatype.boolean(),
      createdAt: faker.date.past(),
      updatedAt: faker.date.recent(),
      userId: faker.string.uuid(),
      categoryId: faker.string.uuid(),
      priority: faker.helpers.arrayElement(['low', 'medium', 'high']),
      dueDate: faker.datatype.boolean() ? faker.date.future() : null,
      tags: faker.lorem.words({ min: 1, max: 5 }).split(' '),
      ...overrides,
    };
  }

  static createMany(count: number, overrides: Partial<Todo> = {}): Todo[] {
    return Array.from({ length: count }, () => this.create(overrides));
  }

  static createCompleted(overrides: Partial<Todo> = {}): Todo {
    return this.create({ completed: true, ...overrides });
  }

  static createPending(overrides: Partial<Todo> = {}): Todo {
    return this.create({ completed: false, ...overrides });
  }

  static createOverdue(overrides: Partial<Todo> = {}): Todo {
    return this.create({
      dueDate: faker.date.past(),
      completed: false,
      ...overrides,
    });
  }

  static createByCategory(categoryId: string, count: number = 1): Todo[] {
    return this.createMany(count, { categoryId });
  }
}

export class CategoryFactory {
  static create(overrides: Partial<Category> = {}): Category {
    return {
      id: faker.string.uuid(),
      name: faker.commerce.department(),
      color: faker.internet.color(),
      icon: faker.helpers.arrayElement(['briefcase', 'home', 'heart', 'star']),
      userId: faker.string.uuid(),
      ...overrides,
    };
  }

  static createMany(count: number, overrides: Partial<Category> = {}): Category[] {
    return Array.from({ length: count }, () => this.create(overrides));
  }
}

Fixtures Complejas

// src/test/fixtures/userScenarios.ts
import { TodoFactory, CategoryFactory } from '../factories';

export const userScenarios = {
  // Usuario productivo con muchas tareas
  productiveUser: {
    userId: 'productive-user',
    categories: CategoryFactory.createMany(5, { userId: 'productive-user' }),
    todos: [
      ...TodoFactory.createMany(15, { 
        userId: 'productive-user',
        completed: false,
      }),
      ...TodoFactory.createMany(25, { 
        userId: 'productive-user',
        completed: true,
      }),
    ],
  },

  // Usuario nuevo sin datos
  newUser: {
    userId: 'new-user',
    categories: [],
    todos: [],
  },

  // Usuario con tareas vencidas
  procrastinatorUser: {
    userId: 'procrastinator-user',
    categories: CategoryFactory.createMany(3, { userId: 'procrastinator-user' }),
    todos: [
      ...TodoFactory.createMany(8, {
        userId: 'procrastinator-user',
        dueDate: new Date(Date.now() - 86400000), // Ayer
        completed: false,
      }),
      ...TodoFactory.createMany(3, {
        userId: 'procrastinator-user',
        dueDate: new Date(Date.now() - 86400000 * 7), // Hace una semana
        completed: false,
      }),
    ],
  },

  // Usuario organizado
  organizedUser: {
    userId: 'organized-user',
    categories: [
      CategoryFactory.create({ 
        id: 'work',
        name: 'Trabajo',
        userId: 'organized-user',
      }),
      CategoryFactory.create({ 
        id: 'personal',
        name: 'Personal',
        userId: 'organized-user',
      }),
    ],
    todos: [
      ...TodoFactory.createByCategory('work', 10).map(todo => ({
        ...todo,
        userId: 'organized-user',
        priority: 'high' as const,
      })),
      ...TodoFactory.createByCategory('personal', 5).map(todo => ({
        ...todo,
        userId: 'organized-user',
        priority: 'medium' as const,
      })),
    ],
  },
};

🔍 Testing de Integración

Testing de Flujos Completos

// src/__tests__/integration/todoFlow.test.tsx
import React from 'react';
import { render, fireEvent, waitFor, within } from '@testing-library/react-native';
import { App } from '@/App';
import { setupMSWGlobal } from '@/test/mocks/setup';
import { testScenarios } from '@/test/mocks/scenarios';
import { userScenarios } from '@/test/fixtures/userScenarios';

setupMSWGlobal();

describe('Todo Management Flow', () => {
  beforeEach(() => {
    testScenarios.normal();
  });

  it('should complete full todo lifecycle', async () => {
    // Mock usuario autenticado
    jest.mock('@/stores/authStore', () => ({
      useAuthStore: () => ({
        isAuthenticated: true,
        user: { id: 'test-user' },
        loading: false,
      }),
    }));

    const { getByTestId, getByText } = render(<App />);

    // 1. Verificar que carga la lista de todos
    await waitFor(() => {
      expect(getByTestId('todo-list')).toBeTruthy();
    });

    // 2. Crear nueva tarea
    fireEvent.press(getByTestId('add-todo-button'));

    await waitFor(() => {
      expect(getByTestId('todo-form')).toBeTruthy();
    });

    fireEvent.changeText(getByTestId('title-input'), 'Nueva tarea de integración');
    fireEvent.changeText(getByTestId('description-input'), 'Descripción de la tarea');
    fireEvent.press(getByTestId('save-todo-button'));

    // 3. Verificar que aparece en la lista
    await waitFor(() => {
      expect(getByText('Nueva tarea de integración')).toBeTruthy();
    });

    // 4. Marcar como completada
    const todoItem = getByTestId('todo-item-nueva-tarea-de-integracion');
    const checkbox = within(todoItem).getByTestId('todo-checkbox');
    fireEvent.press(checkbox);

    // 5. Verificar estado completado
    await waitFor(() => {
      expect(within(todoItem).getByTestId('completed-todo')).toBeTruthy();
    });

    // 6. Editar tarea
    fireEvent.press(todoItem);

    await waitFor(() => {
      expect(getByTestId('todo-detail')).toBeTruthy();
    });

    fireEvent.press(getByTestId('edit-todo-button'));

    await waitFor(() => {
      expect(getByTestId('todo-form')).toBeTruthy();
    });

    fireEvent.changeText(getByTestId('title-input'), 'Tarea editada');
    fireEvent.press(getByTestId('save-todo-button'));

    // 7. Verificar cambios
    await waitFor(() => {
      expect(getByText('Tarea editada')).toBeTruthy();
    });

    // 8. Eliminar tarea
    fireEvent.press(getByTestId('delete-todo-button'));
    fireEvent.press(getByTestId('confirm-delete-button'));

    // 9. Verificar que se eliminó
    await waitFor(() => {
      expect(() => getByText('Tarea editada')).toThrow();
    });
  });

  it('should handle offline scenario', async () => {
    // Simular estado offline
    jest.mock('@react-native-community/netinfo', () => ({
      addEventListener: jest.fn(),
      getCurrentState: jest.fn(() => Promise.resolve({ isConnected: false })),
    }));

    testScenarios.serverError();

    const { getByTestId, getByText } = render(<App />);

    // Verificar mensaje de offline
    await waitFor(() => {
      expect(getByText('Sin conexión a internet')).toBeTruthy();
    });

    // Intentar crear tarea offline
    fireEvent.press(getByTestId('add-todo-button'));
    fireEvent.changeText(getByTestId('title-input'), 'Tarea offline');
    fireEvent.press(getByTestId('save-todo-button'));

    // Verificar que se guarda localmente
    await waitFor(() => {
      expect(getByText('Tarea offline')).toBeTruthy();
      expect(getByTestId('sync-pending-indicator')).toBeTruthy();
    });
  });
});

✅ Scripts de Testing Avanzados

Configuración de Jest Avanzada

// package.json (actualización)
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:integration": "jest --testPathPattern=integration",
    "test:unit": "jest --testPathPattern=__tests__ --testPathIgnorePatterns=integration",
    "test:components": "jest --testPathPattern=components",
    "test:hooks": "jest --testPathPattern=hooks",
    "test:stores": "jest --testPathPattern=stores",
    "test:e2e": "detox test",
    "test:accessibility": "jest --testNamePattern='accessibility'",
    "test:performance": "jest --testNamePattern='performance'",
    "test:debug": "jest --runInBand --verbose",
    "test:ci": "jest --ci --coverage --watchAll=false"
  },
  "jest": {
    "preset": "react-native",
    "setupFilesAfterEnv": [
      "@testing-library/jest-native/extend-expect",
      "<rootDir>/src/test/setup.ts"
    ],
    "testMatch": [
      "**/__tests__/**/*.(ts|tsx|js)",
      "**/*.(test|spec).(ts|tsx|js)"
    ],
    "collectCoverageFrom": [
      "src/**/*.{ts,tsx}",
      "!src/**/*.d.ts",
      "!src/test/**/*",
      "!src/**/*.stories.*",
      "!src/**/*.config.*"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    },
    "coverageReporters": ["text", "lcov", "html"],
    "transformIgnorePatterns": [
      "node_modules/(?!(react-native|@react-native|react-native-reanimated|zustand|@react-navigation)/)"
    ],
    "testEnvironment": "jsdom"
  }
}

📝 Resumen

En este capítulo hemos:

Próximos Pasos

En el siguiente capítulo implementaremos:

¡El testing está completamente configurado con las mejores prácticas! 🎉

🔗 Navegación