UTA DevHub

Testing Patterns for UI Components

Comprehensive guide to testing UI components across all architectural layers with practical examples and best practices.

UI Testing Patterns

Overview

Testing Strategy by Layer

Foundation Components Testing

Foundation components are atomic UI elements with no business logic. Testing focuses on:

  • (Do ✅) Test prop variations and visual states
  • (Do ✅) Verify accessibility attributes
  • (Do ✅) Test responsive behavior
  • (Don't ❌) Test implementation details
  • (Don't ❌) Mock child components

Example: Testing a Button Component

// src/ui/foundation/Button/Button.tsx
import React from 'react';
import { TouchableOpacity, Text, ActivityIndicator } from 'react-native';
import { useTheme } from '@/core/shared/styles';
 
interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  loading?: boolean;
  onPress: () => void;
  children: React.ReactNode;
  testID?: string;
}
 
export const Button: React.FC<ButtonProps> = ({
  variant = 'primary',
  size = 'medium',
  disabled = false,
  loading = false,
  onPress,
  children,
  testID = 'button',
}) => {
  const theme = useTheme();
  const styles = getStyles(theme, variant, size, disabled);
 
  return (
    <TouchableOpacity
      style={styles.container}
      onPress={onPress}
      disabled={disabled || loading}
      testID={testID}
      accessibilityRole="button"
      accessibilityState={{ disabled: disabled || loading }}
    >
      {loading ? (
        <ActivityIndicator color={styles.text.color} testID={`${testID}-loader`} />
      ) : (
        <Text style={styles.text}>{children}</Text>
      )}
    </TouchableOpacity>
  );
};

Pattern Components Testing

Pattern components compose foundation components and may have internal state. Testing focuses on:

  • (Do ✅) Test component composition behavior
  • (Do ✅) Verify state management works correctly
  • (Do ✅) Test interaction between child components
  • (Consider 🤔) Integration tests for complex patterns
  • (Don't ❌) Test foundation component internals

Example: Testing a Form Pattern

// src/ui/patterns/Form/Form.test.tsx
import React from 'react';
import { render, fireEvent, waitFor } from '@/ui/test-utils';
import { Form, FormField } from './Form';
 
describe('Form Pattern', () => {
  const mockSubmit = jest.fn();
  
  const TestForm = () => (
    <Form onSubmit={mockSubmit}>
      <FormField
        name="email"
        label="Email"
        rules={{ required: 'Email is required' }}
      />
      <FormField
        name="password"
        label="Password"
        type="password"
        rules={{ required: 'Password is required' }}
      />
    </Form>
  );
 
  beforeEach(() => {
    mockSubmit.mockClear();
  });
 
  it('validates required fields on submit', async () => {
    const { getByText } = render(<TestForm />);
    
    fireEvent.press(getByText('Submit'));
    
    await waitFor(() => {
      expect(getByText('Email is required')).toBeTruthy();
      expect(getByText('Password is required')).toBeTruthy();
    });
    
    expect(mockSubmit).not.toHaveBeenCalled();
  });
 
  it('submits valid form data', async () => {
    const { getByLabelText, getByText } = render(<TestForm />);
    
    fireEvent.changeText(getByLabelText('Email'), 'test@example.com');
    fireEvent.changeText(getByLabelText('Password'), 'password123');
    fireEvent.press(getByText('Submit'));
    
    await waitFor(() => {
      expect(mockSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123'
      });
    });
  });
});

Business Components Testing

Business components integrate with domain logic and external services. Testing strategy:

  • (Do ✅) Mock external dependencies (API calls, navigation)
  • (Do ✅) Test loading, error, and success states
  • (Do ✅) Verify business logic execution
  • (Do ✅) Test edge cases and error scenarios
  • (Consider 🤔) Integration tests with real API in CI

Example: Testing a Product Card

// src/ui/business/ProductCard/ProductCard.tsx
import React from 'react';
import { View, Text } from 'react-native';
import { Button } from '@/ui/foundation/Button';
import { Card } from '@/ui/patterns/Card';
import { useCart } from '@/features/cart/hooks';
import { useProductDetails } from '@/features/products/hooks';
import { formatPrice } from '@shared/utils';
 
interface ProductCardProps {
  productId: string;
  onPress?: () => void;
}
 
export const ProductCard: React.FC<ProductCardProps> = ({ 
  productId, 
  onPress 
}) => {
  const { data: product, isLoading, error } = useProductDetails(productId);
  const { addToCart, isAddingToCart } = useCart();
 
  if (isLoading) return <Card.Skeleton />;
  if (error) return <Card.Error message="Failed to load product" />;
  if (!product) return null;
 
  const handleAddToCart = async () => {
    try {
      await addToCart(product);
    } catch (error) {
      // Error handling is done in the hook
    }
  };
 
  return (
    <Card onPress={onPress} testID="product-card">
      <Card.Image source={{ uri: product.imageUrl }} />
      <Card.Content>
        <Text testID="product-name">{product.name}</Text>
        <Text testID="product-price">{formatPrice(product.price)}</Text>
        <Button
          onPress={handleAddToCart}
          loading={isAddingToCart}
          testID="add-to-cart"
        >
          Add to Cart
        </Button>
      </Card.Content>
    </Card>
  );
};

Testing Best Practices

1. Test Organization

Group by Feature

Organize tests to mirror component structure:

__tests__/
├── foundation/
│   ├── Button.test.tsx
│   └── Input.test.tsx
├── patterns/
│   ├── Form.test.tsx
│   └── Modal.test.tsx
└── business/
    ├── ProductCard.test.tsx
    └── UserProfile.test.tsx

Use Descriptive Names

  • Test files: ComponentName.test.tsx
  • Test suites: describe('ComponentName', ...)
  • Test cases: it('should verb when condition', ...)

Follow AAA Pattern

it('should update count when increment button is pressed', () => {
  // Arrange
  const { getByTestId } = render(<Counter initialCount={0} />);
  
  // Act
  fireEvent.press(getByTestId('increment-button'));
  
  // Assert
  expect(getByTestId('count-display')).toHaveTextContent('1');
});

2. Testing Utilities

Create reusable testing utilities for common scenarios:

// src/test/utils/gesture.ts
export const swipeLeft = (element: ReactTestInstance) => {
  fireEvent(element, 'swipeableHandlerStateChange', {
    nativeEvent: { state: State.ACTIVE }
  });
};
 
// src/test/utils/async.ts
export const waitForLoadingToFinish = async () => {
  await waitFor(() => {
    expect(screen.queryByTestId('loading')).toBeNull();
  });
};

3. Snapshot Testing

Use snapshots judiciously:

  • (Do ✅) Snapshot small, stable components
  • (Do ✅) Review snapshot changes carefully
  • (Don't ❌) Snapshot entire screens
  • (Don't ❌) Ignore snapshot updates
describe('Button Snapshots', () => {
  it('matches snapshot for primary variant', () => {
    const tree = render(
      <Button variant="primary" onPress={jest.fn()}>
        Click me
      </Button>
    ).toJSON();
    
    expect(tree).toMatchSnapshot();
  });
});

Performance Testing

Measuring Component Performance

// src/ui/foundation/List/List.perf.test.tsx
import React from 'react';
import { measurePerformance } from '@test/utils/performance';
import { List } from './List';
 
describe('List Performance', () => {
  it('renders 1000 items within performance budget', async () => {
    const items = Array.from({ length: 1000 }, (_, i) => ({
      id: `item-${i}`,
      title: `Item ${i}`
    }));
 
    const { renderTime, rerenderTime } = await measurePerformance(
      <List data={items} renderItem={({ item }) => <Text>{item.title}</Text>} />
    );
 
    expect(renderTime).toBeLessThan(100); // Initial render < 100ms
    expect(rerenderTime).toBeLessThan(50); // Re-render < 50ms
  });
});

Common Testing Patterns

Testing Hooks

// src/features/auth/hooks/useAuth.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
import { useAuth } from './useAuth';
import { AuthProvider } from '../providers/AuthProvider';
 
describe('useAuth', () => {
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <AuthProvider>{children}</AuthProvider>
  );
 
  it('logs in user successfully', async () => {
    const { result } = renderHook(() => useAuth(), { wrapper });
 
    await act(async () => {
      await result.current.login('user@example.com', 'password');
    });
 
    expect(result.current.isAuthenticated).toBe(true);
    expect(result.current.user).toEqual(
      expect.objectContaining({
        email: 'user@example.com'
      })
    );
  });
});

Testing Navigation

// src/features/navigation/Navigation.test.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { render, fireEvent } from '@testing-library/react-native';
import { AppNavigator } from './AppNavigator';
 
const TestNavigator = () => (
  <NavigationContainer>
    <AppNavigator />
  </NavigationContainer>
);
 
describe('Navigation', () => {
  it('navigates to product details on card press', () => {
    const { getByTestId } = render(<TestNavigator />);
    
    fireEvent.press(getByTestId('product-card-1'));
    
    expect(getByTestId('product-details-screen')).toBeTruthy();
  });
});

Testing Checklist

Before considering your component tests complete:

  • All props are tested
  • User interactions work correctly
  • Loading states are handled
  • Error states show appropriate UI
  • Accessibility props are verified
  • Edge cases are covered
  • Performance is within budget
  • Mocks are properly cleaned up

Common Pitfalls

  1. Over-mocking: Don't mock everything - keep integration points when valuable
  2. Testing Implementation: Focus on behavior, not how it's achieved
  3. Ignoring Async: Always handle async operations with proper waitFor
  4. Missing Cleanup: Clean up timers, mocks, and subscriptions