UTA DevHub

Testing Strategies

Comprehensive testing approaches for React Native components

Testing Strategies

Overview

Testing React Native components ensures reliability, maintainability, and confidence in your code. This guide covers unit testing, integration testing, and visual regression testing strategies.

Testing Philosophy

Good tests should be:

  • Fast - Run quickly for rapid feedback
  • Reliable - Consistent results across runs
  • Isolated - Test one thing at a time
  • Readable - Clear intent and failure messages
  • Maintainable - Easy to update with code changes

Component Testing Patterns

Basic Component Tests

Start with testing component rendering and basic interactions:

// ui/foundation/Button/__tests__/Button.test.tsx
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react-native';
import { Button } from '../Button';
 
describe('Button Component', () => {
  it('renders correctly with required props', () => {
    render(<Button title="Click me" onPress={() => {}} />);
    
    expect(screen.getByText('Click me')).toBeTruthy();
  });
 
  it('calls onPress when pressed', () => {
    const onPress = jest.fn();
    render(<Button title="Click me" onPress={onPress} />);
    
    fireEvent.press(screen.getByText('Click me'));
    
    expect(onPress).toHaveBeenCalledTimes(1);
  });
 
  it('shows loading state', () => {
    render(
      <Button 
        title="Click me" 
        onPress={() => {}} 
        loading 
        testID="button" 
      />
    );
    
    expect(screen.getByTestId('button-loading')).toBeTruthy();
    expect(screen.queryByText('Click me')).toBeNull();
  });
 
  it('disables interaction when disabled', () => {
    const onPress = jest.fn();
    render(
      <Button 
        title="Click me" 
        onPress={onPress} 
        disabled 
        testID="button"
      />
    );
    
    const button = screen.getByTestId('button');
    fireEvent.press(button);
    
    expect(onPress).not.toHaveBeenCalled();
    expect(button.props.accessibilityState.disabled).toBe(true);
  });
});

Testing Props and State

Test component behavior with different prop combinations:

// features/products/components/__tests__/ProductCard.test.tsx
import React from 'react';
import { render, fireEvent, screen, waitFor } from '@testing-library/react-native';
import { ProductCard } from '../ProductCard';
import { mockProduct } from '@/test/mocks';
 
describe('ProductCard Component', () => {
  const defaultProps = {
    product: mockProduct,
    onPress: jest.fn(),
    onAddToCart: jest.fn(),
  };
 
  beforeEach(() => {
    jest.clearAllMocks();
  });
 
  it('displays product information correctly', () => {
    render(<ProductCard {...defaultProps} />);
    
    expect(screen.getByText(mockProduct.name)).toBeTruthy();
    expect(screen.getByText(`$${mockProduct.price}`)).toBeTruthy();
    expect(screen.getByTestId('product-image')).toHaveProp('source', {
      uri: mockProduct.imageUrl,
    });
  });
 
  it('shows discount badge when product is on sale', () => {
    const productOnSale = {
      ...mockProduct,
      originalPrice: 100,
      price: 80,
    };
    
    render(<ProductCard {...defaultProps} product={productOnSale} />);
    
    expect(screen.getByText('20% OFF')).toBeTruthy();
    expect(screen.getByText('$100')).toHaveStyle({
      textDecorationLine: 'line-through',
    });
  });
 
  it('handles favorite toggle', async () => {
    const { rerender } = render(
      <ProductCard {...defaultProps} isFavorite={false} />
    );
    
    const favoriteButton = screen.getByTestId('favorite-button');
    fireEvent.press(favoriteButton);
    
    // Simulate prop update from parent
    rerender(<ProductCard {...defaultProps} isFavorite={true} />);
    
    await waitFor(() => {
      expect(screen.getByTestId('favorite-icon-filled')).toBeTruthy();
    });
  });
});

Testing Async Behavior

Handle asynchronous operations in tests:

// features/search/components/__tests__/SearchBar.test.tsx
import React from 'react';
import { render, fireEvent, screen, waitFor } from '@testing-library/react-native';
import { SearchBar } from '../SearchBar';
 
describe('SearchBar Component', () => {
  jest.useFakeTimers();
 
  it('debounces search input', async () => {
    const onSearch = jest.fn();
    render(<SearchBar onSearch={onSearch} debounceMs={300} />);
    
    const input = screen.getByPlaceholderText('Search...');
    
    // Type multiple characters quickly
    fireEvent.changeText(input, 'a');
    fireEvent.changeText(input, 'ab');
    fireEvent.changeText(input, 'abc');
    
    // onSearch should not be called immediately
    expect(onSearch).not.toHaveBeenCalled();
    
    // Fast-forward timers
    jest.advanceTimersByTime(300);
    
    // Should be called once with final value
    expect(onSearch).toHaveBeenCalledTimes(1);
    expect(onSearch).toHaveBeenCalledWith('abc');
  });
 
  it('shows loading state during search', async () => {
    const onSearch = jest.fn().mockImplementation(() => 
      new Promise(resolve => setTimeout(resolve, 1000))
    );
    
    render(<SearchBar onSearch={onSearch} />);
    
    const input = screen.getByPlaceholderText('Search...');
    fireEvent.changeText(input, 'test');
    fireEvent(input, 'submitEditing');
    
    // Should show loading
    await waitFor(() => {
      expect(screen.getByTestId('search-loading')).toBeTruthy();
    });
    
    // Complete the search
    jest.advanceTimersByTime(1000);
    
    // Loading should disappear
    await waitFor(() => {
      expect(screen.queryByTestId('search-loading')).toBeNull();
    });
  });
});

Hook Testing

Custom Hook Tests

Test custom hooks in isolation:

// features/cart/hooks/__tests__/useCart.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
import { useCart } from '../useCart';
import { mockProduct } from '@/test/mocks';
 
describe('useCart Hook', () => {
  it('adds items to cart', () => {
    const { result } = renderHook(() => useCart());
    
    expect(result.current.items).toHaveLength(0);
    expect(result.current.total).toBe(0);
    
    act(() => {
      result.current.addItem(mockProduct);
    });
    
    expect(result.current.items).toHaveLength(1);
    expect(result.current.items[0]).toEqual({
      ...mockProduct,
      quantity: 1,
    });
    expect(result.current.total).toBe(mockProduct.price);
  });
 
  it('updates quantity for existing items', () => {
    const { result } = renderHook(() => useCart());
    
    act(() => {
      result.current.addItem(mockProduct);
      result.current.addItem(mockProduct);
    });
    
    expect(result.current.items).toHaveLength(1);
    expect(result.current.items[0].quantity).toBe(2);
    expect(result.current.total).toBe(mockProduct.price * 2);
  });
 
  it('removes items from cart', () => {
    const { result } = renderHook(() => useCart());
    
    act(() => {
      result.current.addItem(mockProduct);
    });
    
    expect(result.current.items).toHaveLength(1);
    
    act(() => {
      result.current.removeItem(mockProduct.id);
    });
    
    expect(result.current.items).toHaveLength(0);
    expect(result.current.total).toBe(0);
  });
 
  it('clears cart', () => {
    const { result } = renderHook(() => useCart());
    
    act(() => {
      result.current.addItem(mockProduct);
      result.current.addItem({ ...mockProduct, id: '2' });
    });
    
    expect(result.current.items).toHaveLength(2);
    
    act(() => {
      result.current.clearCart();
    });
    
    expect(result.current.items).toHaveLength(0);
    expect(result.current.total).toBe(0);
  });
});

Testing Hooks with Context

Test hooks that depend on context:

// core/auth/hooks/__tests__/useAuth.test.tsx
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { AuthProvider } from '../../context/AuthContext';
import { useAuth } from '../useAuth';
import { authService } from '../../services/authService';
 
// Mock the auth service
jest.mock('../../services/authService');
 
describe('useAuth Hook', () => {
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <AuthProvider>{children}</AuthProvider>
  );
 
  beforeEach(() => {
    jest.clearAllMocks();
  });
 
  it('starts with unauthenticated state', () => {
    const { result } = renderHook(() => useAuth(), { wrapper });
    
    expect(result.current.isAuthenticated).toBe(false);
    expect(result.current.user).toBeNull();
    expect(result.current.isLoading).toBe(false);
  });
 
  it('handles login successfully', async () => {
    const mockUser = { id: '1', email: 'test@example.com' };
    (authService.login as jest.Mock).mockResolvedValue({
      user: mockUser,
      token: 'mock-token',
    });
    
    const { result } = renderHook(() => useAuth(), { wrapper });
    
    await act(async () => {
      await result.current.login('test@example.com', 'password');
    });
    
    expect(result.current.isAuthenticated).toBe(true);
    expect(result.current.user).toEqual(mockUser);
    expect(authService.login).toHaveBeenCalledWith(
      'test@example.com',
      'password'
    );
  });
 
  it('handles login failure', async () => {
    const error = new Error('Invalid credentials');
    (authService.login as jest.Mock).mockRejectedValue(error);
    
    const { result } = renderHook(() => useAuth(), { wrapper });
    
    await expect(
      act(async () => {
        await result.current.login('test@example.com', 'wrong-password');
      })
    ).rejects.toThrow('Invalid credentials');
    
    expect(result.current.isAuthenticated).toBe(false);
    expect(result.current.user).toBeNull();
  });
});

Integration Testing

Screen Integration Tests

Test complete user flows:

// features/checkout/__tests__/CheckoutFlow.test.tsx
import React from 'react';
import { render, fireEvent, screen, waitFor } from '@testing-library/react-native';
import { NavigationContainer } from '@react-navigation/native';
import { CheckoutNavigator } from '../navigation/CheckoutNavigator';
import { CartProvider } from '@/features/cart/context/CartContext';
import { mockProduct } from '@/test/mocks';
import * as api from '@/core/api';
 
// Mock API calls
jest.mock('@/core/api');
 
describe('Checkout Flow Integration', () => {
  const renderCheckout = () => {
    return render(
      <NavigationContainer>
        <CartProvider>
          <CheckoutNavigator />
        </CartProvider>
      </NavigationContainer>
    );
  };
 
  it('completes full checkout flow', async () => {
    (api.processPayment as jest.Mock).mockResolvedValue({
      success: true,
      orderId: '12345',
    });
 
    renderCheckout();
    
    // Add item to cart
    fireEvent.press(screen.getByText('Add to Cart'));
    
    // Navigate to checkout
    fireEvent.press(screen.getByText('Checkout'));
    
    // Fill shipping info
    await waitFor(() => {
      expect(screen.getByText('Shipping Information')).toBeTruthy();
    });
    
    fireEvent.changeText(
      screen.getByPlaceholderText('Full Name'),
      'John Doe'
    );
    fireEvent.changeText(
      screen.getByPlaceholderText('Address'),
      '123 Main St'
    );
    
    fireEvent.press(screen.getByText('Continue to Payment'));
    
    // Fill payment info
    await waitFor(() => {
      expect(screen.getByText('Payment Information')).toBeTruthy();
    });
    
    fireEvent.changeText(
      screen.getByPlaceholderText('Card Number'),
      '4242424242424242'
    );
    
    // Complete order
    fireEvent.press(screen.getByText('Place Order'));
    
    // Verify success screen
    await waitFor(() => {
      expect(screen.getByText('Order Confirmed!')).toBeTruthy();
      expect(screen.getByText('Order #12345')).toBeTruthy();
    });
  });
});

Testing Best Practices

Arrange, Act, Assert

it('follows AAA pattern', () => {
  // Arrange - Set up test data and conditions
  const onPress = jest.fn();
  const { getByText } = render(
    <Button title="Click" onPress={onPress} />
  );
  
  // Act - Perform the action
  fireEvent.press(getByText('Click'));
  
  // Assert - Verify the outcome
  expect(onPress).toHaveBeenCalledTimes(1);
});

Use Testing IDs

// Component
<TouchableOpacity
  testID="submit-button"
  accessibilityLabel="Submit form"
  onPress={handleSubmit}
>
  <Text>Submit</Text>
</TouchableOpacity>
 
// Test
const submitButton = screen.getByTestId('submit-button');
fireEvent.press(submitButton);

Mock External Dependencies

// Mock navigation
const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
  useNavigation: () => ({
    navigate: mockNavigate,
  }),
}));
 
// Mock async storage
jest.mock('@react-native-async-storage/async-storage', () => ({
  getItem: jest.fn(),
  setItem: jest.fn(),
  removeItem: jest.fn(),
}));

Snapshot Testing

Use snapshots for stable UI components:

// ui/Card/__tests__/Card.test.tsx
import React from 'react';
import renderer from 'react-test-renderer';
import { Card } from '../Card';
 
describe('Card Component Snapshots', () => {
  it('renders default card correctly', () => {
    const tree = renderer.create(
      <Card>
        <Card.Header title="Title" subtitle="Subtitle" />
        <Card.Body>
          <Text>Content</Text>
        </Card.Body>
      </Card>
    ).toJSON();
    
    expect(tree).toMatchSnapshot();
  });
 
  it('renders elevated card correctly', () => {
    const tree = renderer.create(
      <Card variant="elevated" elevated>
        <Card.Header title="Elevated Card" />
      </Card>
    ).toJSON();
    
    expect(tree).toMatchSnapshot();
  });
});

Performance Testing

Test component performance characteristics:

// ui/LargeList/__tests__/LargeList.performance.test.tsx
import React from 'react';
import { render, measure } from '@testing-library/react-native';
import { LargeList } from '../LargeList';
 
describe('LargeList Performance', () => {
  it('renders large dataset efficiently', async () => {
    const items = Array.from({ length: 1000 }, (_, i) => ({
      id: `item-${i}`,
      title: `Item ${i}`,
    }));
    
    const renderTime = await measure(() => {
      render(<LargeList data={items} />);
    });
    
    // Should render in under 100ms
    expect(renderTime).toBeLessThan(100);
  });
 
  it('handles rapid scrolling without dropping frames', () => {
    const { getByTestId } = render(
      <LargeList data={generateLargeDataset()} />
    );
    
    const list = getByTestId('large-list');
    const frameDrops = measureFrameDrops(() => {
      // Simulate rapid scrolling
      for (let i = 0; i < 10; i++) {
        fireEvent.scroll(list, {
          nativeEvent: {
            contentOffset: { y: i * 1000 },
          },
        });
      }
    });
    
    // Should maintain 60fps (no frame drops)
    expect(frameDrops).toBe(0);
  });
});

Test Utilities

Create reusable test utilities:

// test/utils/render.tsx
import React from 'react';
import { render as rtlRender } from '@testing-library/react-native';
import { ThemeProvider } from '@/ui/theme';
import { NavigationContainer } from '@react-navigation/native';
 
// Custom render with providers
export function render(
  ui: React.ReactElement,
  options?: {
    initialState?: any;
    theme?: 'light' | 'dark';
  }
) {
  const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
    <NavigationContainer>
      <ThemeProvider theme={options?.theme || 'light'}>
        {children}
      </ThemeProvider>
    </NavigationContainer>
  );
 
  return rtlRender(ui, { wrapper: Wrapper, ...options });
}
 
// Re-export everything
export * from '@testing-library/react-native';

Best Practices Summary

Testing Best Practices

  1. Test behavior, not implementation - Focus on user interactions
  2. Keep tests isolated - Each test should be independent
  3. Use descriptive test names - Clear what's being tested
  4. Avoid testing internals - Test public APIs only
  5. Mock external dependencies - Keep tests fast and reliable
  6. Test edge cases - Empty states, errors, loading
  7. Maintain test coverage - Aim for 80%+ coverage

Testing Guidelines

  • (Do ✅) Write tests first (TDD) when possible
  • (Do ✅) Test user interactions over implementation
  • (Do ✅) Use semantic queries (by role, label, text)
  • (Do ✅) Test accessibility features
  • (Don't ❌) Test framework code or third-party libraries
  • (Don't ❌) Mock everything - some integration is good
  • (Consider 🤔) Visual regression tests for UI consistency
  • (Be Aware ❗) Of async behavior - use waitFor appropriately

Summary

Comprehensive testing ensures:

  • Confidence in code changes
  • Documentation through test cases
  • Regression prevention for future changes
  • Better design through testability
  • Faster debugging with clear test failures

Invest in good tests - they pay dividends in maintainability.