react-native-sop-docs/06-testing-quality-assurance/unit-testing.md

11 KiB

Unit Testing Setup

Installation

yarn add -D jest @testing-library/react-native @testing-library/jest-native

Jest Configuration

jest.config.js:

module.exports = {
  preset: 'react-native',
  setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect'],
  transformIgnorePatterns: [
    'node_modules/(?!(react-native|@react-native|react-navigation)/)',
  ],
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{ts,tsx}',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

Test Setup

// src/__tests__/setup.ts
import 'react-native-gesture-handler/jestSetup';

jest.mock('react-native-reanimated', () => {
  const Reanimated = require('react-native-reanimated/mock');
  Reanimated.default.call = () => {};
  return Reanimated;
});

jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');

// Mock AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () =>
  require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);

// Mock react-native-config
jest.mock('react-native-config', () => ({
  API_BASE_URL: 'https://test-api.example.com',
  APP_ENV: 'test',
}));

Component Testing

// src/components/Button/__tests__/Button.test.tsx
import React from 'react';
import {render, fireEvent} from '@testing-library/react-native';
import {Button} from '../Button';

describe('Button Component', () => {
  it('renders correctly', () => {
    const {getByText} = render(
      <Button title="Test Button" onPress={() => {}} />
    );
    expect(getByText('Test Button')).toBeTruthy();
  });

  it('calls onPress when pressed', () => {
    const mockOnPress = jest.fn();
    const {getByText} = render(
      <Button title="Test Button" onPress={mockOnPress} />
    );
    
    fireEvent.press(getByText('Test Button'));
    expect(mockOnPress).toHaveBeenCalledTimes(1);
  });

  it('applies correct styles for different variants', () => {
    const {getByText, rerender} = render(
      <Button title="Primary" onPress={() => {}} variant="primary" />
    );
    
    const button = getByText('Primary').parent;
    expect(button).toHaveStyle({backgroundColor: '#007AFF'});

    rerender(
      <Button title="Secondary" onPress={() => {}} variant="secondary" />
    );
    
    const secondaryButton = getByText('Secondary').parent;
    expect(secondaryButton).toHaveStyle({backgroundColor: '#6C757D'});
  });

  it('is disabled when disabled prop is true', () => {
    const mockOnPress = jest.fn();
    const {getByText} = render(
      <Button title="Disabled" onPress={mockOnPress} disabled />
    );
    
    const button = getByText('Disabled').parent;
    expect(button).toHaveStyle({opacity: 0.5});
    
    fireEvent.press(getByText('Disabled'));
    expect(mockOnPress).not.toHaveBeenCalled();
  });
});

Screen Testing

// src/screens/Login/__tests__/LoginScreen.test.tsx
import React from 'react';
import {render, fireEvent, waitFor} from '@testing-library/react-native';
import {Provider} from 'react-redux';
import {configureStore} from '@reduxjs/toolkit';
import LoginScreen from '../LoginScreen';
import authSlice from '@redux/slices/authSlice';

const createMockStore = (initialState = {}) => {
  return configureStore({
    reducer: {
      auth: authSlice,
    },
    preloadedState: initialState,
  });
};

const renderWithProvider = (component: React.ReactElement, store = createMockStore()) => {
  return render(
    <Provider store={store}>
      {component}
    </Provider>
  );
};

describe('LoginScreen', () => {
  it('renders login form correctly', () => {
    const {getByPlaceholderText, getByText} = renderWithProvider(
      <LoginScreen navigation={{} as any} route={{} as any} />
    );

    expect(getByPlaceholderText('Email')).toBeTruthy();
    expect(getByPlaceholderText('Password')).toBeTruthy();
    expect(getByText('Login')).toBeTruthy();
  });

  it('shows validation errors for empty fields', async () => {
    const {getByText, getByTestId} = renderWithProvider(
      <LoginScreen navigation={{} as any} route={{} as any} />
    );

    fireEvent.press(getByText('Login'));

    await waitFor(() => {
      expect(getByText('Email is required')).toBeTruthy();
      expect(getByText('Password is required')).toBeTruthy();
    });
  });

  it('calls login API with correct credentials', async () => {
    const mockLogin = jest.fn();
    const {getByPlaceholderText, getByText} = renderWithProvider(
      <LoginScreen navigation={{} as any} route={{} as any} />
    );

    fireEvent.changeText(getByPlaceholderText('Email'), 'test@example.com');
    fireEvent.changeText(getByPlaceholderText('Password'), 'password123');
    fireEvent.press(getByText('Login'));

    await waitFor(() => {
      expect(mockLogin).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
      });
    });
  });
});

Redux Testing

// src/redux/slices/__tests__/authSlice.test.ts
import authSlice, {
  loginStart,
  loginSuccess,
  loginFailure,
  logout,
  updateUser,
} from '../authSlice';

describe('authSlice', () => {
  const initialState = {
    isAuthenticated: false,
    user: null,
    token: null,
    refreshToken: null,
    loading: false,
    error: null,
  };

  it('should return the initial state', () => {
    expect(authSlice(undefined, {type: 'unknown'})).toEqual(initialState);
  });

  it('should handle loginStart', () => {
    const actual = authSlice(initialState, loginStart());
    expect(actual.loading).toBe(true);
    expect(actual.error).toBe(null);
  });

  it('should handle loginSuccess', () => {
    const payload = {
      user: {
        id: '1',
        email: 'test@example.com',
        firstName: 'John',
        lastName: 'Doe',
      },
      token: 'access-token',
      refreshToken: 'refresh-token',
    };

    const actual = authSlice(initialState, loginSuccess(payload));
    expect(actual.loading).toBe(false);
    expect(actual.isAuthenticated).toBe(true);
    expect(actual.user).toEqual(payload.user);
    expect(actual.token).toBe(payload.token);
    expect(actual.refreshToken).toBe(payload.refreshToken);
  });

  it('should handle loginFailure', () => {
    const errorMessage = 'Invalid credentials';
    const actual = authSlice(initialState, loginFailure(errorMessage));
    expect(actual.loading).toBe(false);
    expect(actual.isAuthenticated).toBe(false);
    expect(actual.error).toBe(errorMessage);
  });

  it('should handle logout', () => {
    const loggedInState = {
      ...initialState,
      isAuthenticated: true,
      user: {id: '1', email: 'test@example.com', firstName: 'John', lastName: 'Doe'},
      token: 'token',
    };

    const actual = authSlice(loggedInState, logout());
    expect(actual).toEqual(initialState);
  });
});

API Testing

// src/services/__tests__/authAPI.test.ts
import {authAPI} from '../authAPI';
import {store} from '@redux/store';

describe('authAPI', () => {
  beforeEach(() => {
    // Reset store state
    store.dispatch({type: 'RESET'});
  });

  it('should login successfully', async () => {
    const credentials = {
      email: 'test@example.com',
      password: 'password123',
    };

    const mockResponse = {
      user: {
        id: '1',
        email: 'test@example.com',
        firstName: 'John',
        lastName: 'Doe',
      },
      token: 'access-token',
      refreshToken: 'refresh-token',
    };

    // Mock fetch
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve(mockResponse),
      })
    ) as jest.Mock;

    const result = await store.dispatch(
      authAPI.endpoints.login.initiate(credentials)
    );

    expect(result.data).toEqual(mockResponse);
  });

  it('should handle login error', async () => {
    const credentials = {
      email: 'test@example.com',
      password: 'wrongpassword',
    };

    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: false,
        status: 401,
        json: () => Promise.resolve({message: 'Invalid credentials'}),
      })
    ) as jest.Mock;

    const result = await store.dispatch(
      authAPI.endpoints.login.initiate(credentials)
    );

    expect(result.error).toBeDefined();
  });
});

Custom Hooks Testing

// src/hooks/__tests__/useAPI.test.ts
import {renderHook, act} from '@testing-library/react-hooks';
import {useAPI} from '../useAPI';

describe('useAPI', () => {
  it('should initialize with correct default state', () => {
    const {result} = renderHook(() => useAPI());

    expect(result.current.data).toBe(null);
    expect(result.current.loading).toBe(false);
    expect(result.current.error).toBe(null);
  });

  it('should handle successful API call', async () => {
    const {result} = renderHook(() => useAPI());
    const mockData = {id: 1, name: 'Test'};
    const mockApiCall = jest.fn(() => Promise.resolve(mockData));

    await act(async () => {
      await result.current.execute(mockApiCall);
    });

    expect(result.current.data).toEqual(mockData);
    expect(result.current.loading).toBe(false);
    expect(result.current.error).toBe(null);
  });

  it('should handle API call error', async () => {
    const {result} = renderHook(() => useAPI());
    const mockError = new Error('API Error');
    const mockApiCall = jest.fn(() => Promise.reject(mockError));

    await act(async () => {
      try {
        await result.current.execute(mockApiCall);
      } catch (error) {
        // Expected to throw
      }
    });

    expect(result.current.data).toBe(null);
    expect(result.current.loading).toBe(false);
    expect(result.current.error).toBeDefined();
  });
});

Test Utilities

// src/__tests__/utils/testUtils.tsx
import React from 'react';
import {render, RenderOptions} from '@testing-library/react-native';
import {Provider} from 'react-redux';
import {NavigationContainer} from '@react-navigation/native';
import {configureStore} from '@reduxjs/toolkit';
import authSlice from '@redux/slices/authSlice';

interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  preloadedState?: any;
  store?: any;
}

const createTestStore = (preloadedState = {}) => {
  return configureStore({
    reducer: {
      auth: authSlice,
    },
    preloadedState,
  });
};

const AllTheProviders: React.FC<{children: React.ReactNode; store: any}> = ({
  children,
  store,
}) => {
  return (
    <Provider store={store}>
      <NavigationContainer>
        {children}
      </NavigationContainer>
    </Provider>
  );
};

const customRender = (
  ui: React.ReactElement,
  {
    preloadedState = {},
    store = createTestStore(preloadedState),
    ...renderOptions
  }: CustomRenderOptions = {}
) => {
  const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) => (
    <AllTheProviders store={store}>{children}</AllTheProviders>
  );

  return render(ui, {wrapper: Wrapper, ...renderOptions});
};

export * from '@testing-library/react-native';
export {customRender as render};