🧪 Testing Guide

Index

  1. Configuración
  2. Unit Tests con Jest
  3. Testing de Componentes
  4. Testing de Hooks
  5. Testing de Contextos
  6. Mocking
  7. E2E Tests con Maestro
  8. 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();
  });
});

Test de Input

// __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