Testing React Native Apps: Unit, Integration, and E2E Testing

React Native, Testing, Jest, Detox, React Testing Library, Quality Assurance|SEPTEMBER 6, 2025|0 VIEWS
Complete guide to building a robust testing strategy for React Native applications with practical examples and best practices

Introduction

Testing is crucial for building reliable React Native applications that work consistently across different devices and platforms. A comprehensive testing strategy helps catch bugs early, improves code quality, and gives you confidence when shipping new features. In this guide, we'll explore the three main types of testing: unit testing, integration testing, and end-to-end (E2E) testing.

What You'll Learn

  • Setting up a complete testing environment for React Native
  • Writing effective unit tests for components and functions
  • Creating integration tests for connected components
  • Implementing E2E tests with Detox
  • Testing best practices and strategies
  • Debugging and troubleshooting test issues

Testing Pyramid Overview

The testing pyramid is a fundamental concept that guides how we distribute our testing efforts:

        ๐Ÿ”บ E2E Tests
       (Few, Slow, Expensive)

    ๐Ÿ”ท Integration Tests
   (Some, Medium Speed)

๐Ÿ”ธ Unit Tests
(Many, Fast, Cheap)

Unit Tests (70%)

  • Test individual functions and components in isolation
  • Fast execution and easy to debug
  • High code coverage with minimal setup

Integration Tests (20%)

  • Test how multiple components work together
  • Verify data flow and component interactions
  • Balance between speed and realistic testing

E2E Tests (10%)

  • Test complete user workflows
  • Verify the app works on real devices/simulators
  • Catch issues that unit and integration tests miss

Setting Up the Testing Environment

Installing Dependencies

Let's start by setting up a comprehensive testing environment:

# Core testing dependencies
npm install --save-dev jest @testing-library/react-native @testing-library/jest-native

# Additional testing utilities
npm install --save-dev react-test-renderer @testing-library/user-event

# For mocking
npm install --save-dev jest-environment-jsdom

# For E2E testing
npm install --save-dev detox

# For testing async code and timers
npm install --save-dev @testing-library/jest-dom

# TypeScript support (if using TypeScript)
npm install --save-dev @types/jest

Jest Configuration

Create or update your jest.config.js:

// jest.config.js
module.exports = {
  preset: "react-native",
  setupFilesAfterEnv: [
    "@testing-library/jest-native/extend-expect",
    "<rootDir>/src/setupTests.js",
  ],
  testPathIgnorePatterns: ["/node_modules/", "/android/", "/ios/"],
  transformIgnorePatterns: [
    "node_modules/(?!(react-native|@react-native|react-native-vector-icons)/)",
  ],
  collectCoverageFrom: [
    "src/**/*.{js,jsx,ts,tsx}",
    "!src/**/*.d.ts",
    "!src/index.js",
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  testEnvironment: "jsdom",
  moduleNameMapping: {
    "^@/(.*)$": "<rootDir>/src/$1",
  },
};

Setup File

Create src/setupTests.js:

// src/setupTests.js
import "react-native-gesture-handler/jestSetup";
import mockAsyncStorage from "@react-native-async-storage/async-storage/jest/async-storage-mock";

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

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

// Mock Linking
jest.mock("react-native/Libraries/Linking/Linking", () => ({
  openURL: jest.fn(() => Promise.resolve("mockOpenURL")),
  canOpenURL: jest.fn(() => Promise.resolve(true)),
  getInitialURL: jest.fn(() => Promise.resolve(null)),
}));

// Mock Dimensions
jest.mock("react-native/Libraries/Utilities/Dimensions", () => ({
  get: jest.fn().mockReturnValue({ width: 375, height: 812 }),
  addEventListener: jest.fn(),
  removeEventListener: jest.fn(),
}));

// Mock NetInfo
jest.mock("@react-native-community/netinfo", () => ({
  fetch: jest.fn(() => Promise.resolve({ isConnected: true })),
  addEventListener: jest.fn(() => jest.fn()),
}));

// Global test utilities
global.mockNavigate = jest.fn();
global.mockGoBack = jest.fn();

// Mock navigation
jest.mock("@react-navigation/native", () => ({
  useNavigation: () => ({
    navigate: global.mockNavigate,
    goBack: global.mockGoBack,
    dispatch: jest.fn(),
  }),
  useRoute: () => ({
    params: {},
  }),
  useFocusEffect: jest.fn(),
}));

// Silence console warnings in tests
global.console = {
  ...console,
  warn: jest.fn(),
  error: jest.fn(),
};

Unit Testing

Testing Components

Basic Component Testing

// src/components/Button.js
import React from "react";
import { TouchableOpacity, Text, StyleSheet } from "react-native";

const Button = ({ title, onPress, disabled, variant = "primary" }) => {
  return (
    <TouchableOpacity
      style={[styles.button, styles[variant], disabled && styles.disabled]}
      onPress={onPress}
      disabled={disabled}
      testID="custom-button"
    >
      <Text style={[styles.text, styles[`${variant}Text`]]}>{title}</Text>
    </TouchableOpacity>
  );
};

const styles = StyleSheet.create({
  button: {
    paddingHorizontal: 20,
    paddingVertical: 12,
    borderRadius: 8,
    alignItems: "center",
  },
  primary: {
    backgroundColor: "#007AFF",
  },
  secondary: {
    backgroundColor: "#6c757d",
  },
  disabled: {
    opacity: 0.5,
  },
  text: {
    fontSize: 16,
    fontWeight: "600",
  },
  primaryText: {
    color: "white",
  },
  secondaryText: {
    color: "white",
  },
});

export default Button;
// src/components/__tests__/Button.test.js
import React from "react";
import { render, fireEvent } from "@testing-library/react-native";
import Button from "../Button";

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

  it("calls onPress when pressed", () => {
    const mockOnPress = jest.fn();
    const { getByTestId } = render(
      <Button title="Press Me" onPress={mockOnPress} />
    );

    fireEvent.press(getByTestId("custom-button"));
    expect(mockOnPress).toHaveBeenCalledTimes(1);
  });

  it("does not call onPress when disabled", () => {
    const mockOnPress = jest.fn();
    const { getByTestId } = render(
      <Button title="Disabled" onPress={mockOnPress} disabled />
    );

    fireEvent.press(getByTestId("custom-button"));
    expect(mockOnPress).not.toHaveBeenCalled();
  });

  it("applies correct styles for different variants", () => {
    const { getByTestId, rerender } = render(
      <Button title="Primary" variant="primary" />
    );

    const button = getByTestId("custom-button");
    expect(button.props.style).toContainEqual(
      expect.objectContaining({ backgroundColor: "#007AFF" })
    );

    rerender(<Button title="Secondary" variant="secondary" />);
    expect(button.props.style).toContainEqual(
      expect.objectContaining({ backgroundColor: "#6c757d" })
    );
  });

  it("applies disabled styles when disabled", () => {
    const { getByTestId } = render(<Button title="Disabled" disabled />);

    const button = getByTestId("custom-button");
    expect(button.props.style).toContainEqual(
      expect.objectContaining({ opacity: 0.5 })
    );
  });
});

Testing Components with State

// src/components/Counter.js
import React, { useState } from "react";
import { View, Text, StyleSheet } from "react-native";
import Button from "./Button";

const Counter = ({ initialValue = 0, step = 1 }) => {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount((prev) => prev + step);
  const decrement = () => setCount((prev) => prev - step);
  const reset = () => setCount(initialValue);

  return (
    <View style={styles.container}>
      <Text testID="counter-value" style={styles.value}>
        {count}
      </Text>
      <View style={styles.buttons}>
        <Button
          title="-"
          onPress={decrement}
          variant="secondary"
          testID="decrement-button"
        />
        <Button title="Reset" onPress={reset} testID="reset-button" />
        <Button title="+" onPress={increment} testID="increment-button" />
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    alignItems: "center",
    padding: 20,
  },
  value: {
    fontSize: 48,
    fontWeight: "bold",
    marginBottom: 20,
  },
  buttons: {
    flexDirection: "row",
    gap: 10,
  },
});

export default Counter;
// src/components/__tests__/Counter.test.js
import React from "react";
import { render, fireEvent } from "@testing-library/react-native";
import Counter from "../Counter";

describe("Counter Component", () => {
  it("renders with initial value", () => {
    const { getByTestId } = render(<Counter initialValue={5} />);
    expect(getByTestId("counter-value")).toHaveTextContent("5");
  });

  it("increments count when increment button is pressed", () => {
    const { getByTestId } = render(<Counter />);
    const incrementButton = getByTestId("increment-button");
    const counterValue = getByTestId("counter-value");

    expect(counterValue).toHaveTextContent("0");

    fireEvent.press(incrementButton);
    expect(counterValue).toHaveTextContent("1");

    fireEvent.press(incrementButton);
    expect(counterValue).toHaveTextContent("2");
  });

  it("decrements count when decrement button is pressed", () => {
    const { getByTestId } = render(<Counter initialValue={5} />);
    const decrementButton = getByTestId("decrement-button");
    const counterValue = getByTestId("counter-value");

    expect(counterValue).toHaveTextContent("5");

    fireEvent.press(decrementButton);
    expect(counterValue).toHaveTextContent("4");
  });

  it("resets to initial value when reset button is pressed", () => {
    const { getByTestId } = render(<Counter initialValue={10} />);
    const incrementButton = getByTestId("increment-button");
    const resetButton = getByTestId("reset-button");
    const counterValue = getByTestId("counter-value");

    // Increment a few times
    fireEvent.press(incrementButton);
    fireEvent.press(incrementButton);
    expect(counterValue).toHaveTextContent("12");

    // Reset
    fireEvent.press(resetButton);
    expect(counterValue).toHaveTextContent("10");
  });

  it("uses custom step value", () => {
    const { getByTestId } = render(<Counter step={5} />);
    const incrementButton = getByTestId("increment-button");
    const counterValue = getByTestId("counter-value");

    fireEvent.press(incrementButton);
    expect(counterValue).toHaveTextContent("5");

    fireEvent.press(incrementButton);
    expect(counterValue).toHaveTextContent("10");
  });
});

Testing Utilities and Hooks

Testing Custom Hooks

// src/hooks/useCounter.js
import { useState } from "react";

export const useCounter = (initialValue = 0) => {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount((prev) => prev + 1);
  const decrement = () => setCount((prev) => prev - 1);
  const reset = () => setCount(initialValue);
  const setValue = (value) => setCount(value);

  return {
    count,
    increment,
    decrement,
    reset,
    setValue,
  };
};
// src/hooks/__tests__/useCounter.test.js
import { renderHook, act } from "@testing-library/react-native";
import { useCounter } from "../useCounter";

describe("useCounter Hook", () => {
  it("initializes with default value", () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it("initializes with custom value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it("increments count", () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it("decrements count", () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(4);
  });

  it("resets to initial value", () => {
    const { result } = renderHook(() => useCounter(10));

    act(() => {
      result.current.increment();
      result.current.increment();
    });

    expect(result.current.count).toBe(12);

    act(() => {
      result.current.reset();
    });

    expect(result.current.count).toBe(10);
  });

  it("sets custom value", () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.setValue(42);
    });

    expect(result.current.count).toBe(42);
  });
});

Testing Async Functions

// src/services/userService.js
class UserService {
  async fetchUser(userId) {
    const response = await fetch(`/api/users/${userId}`);

    if (!response.ok) {
      throw new Error(`Failed to fetch user: ${response.statusText}`);
    }

    return response.json();
  }

  async updateUser(userId, userData) {
    const response = await fetch(`/api/users/${userId}`, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(userData),
    });

    if (!response.ok) {
      throw new Error(`Failed to update user: ${response.statusText}`);
    }

    return response.json();
  }
}

export default new UserService();
// src/services/__tests__/userService.test.js
import userService from "../userService";

// Mock fetch globally
global.fetch = jest.fn();

describe("UserService", () => {
  beforeEach(() => {
    fetch.mockClear();
  });

  describe("fetchUser", () => {
    it("fetches user successfully", async () => {
      const mockUser = { id: 1, name: "John Doe", email: "john@example.com" };

      fetch.mockResolvedValueOnce({
        ok: true,
        json: async () => mockUser,
      });

      const result = await userService.fetchUser(1);

      expect(fetch).toHaveBeenCalledWith("/api/users/1");
      expect(result).toEqual(mockUser);
    });

    it("throws error when fetch fails", async () => {
      fetch.mockResolvedValueOnce({
        ok: false,
        statusText: "Not Found",
      });

      await expect(userService.fetchUser(999)).rejects.toThrow(
        "Failed to fetch user: Not Found"
      );
    });

    it("handles network errors", async () => {
      fetch.mockRejectedValueOnce(new Error("Network error"));

      await expect(userService.fetchUser(1)).rejects.toThrow("Network error");
    });
  });

  describe("updateUser", () => {
    it("updates user successfully", async () => {
      const userData = { name: "Jane Doe", email: "jane@example.com" };
      const updatedUser = { id: 1, ...userData };

      fetch.mockResolvedValueOnce({
        ok: true,
        json: async () => updatedUser,
      });

      const result = await userService.updateUser(1, userData);

      expect(fetch).toHaveBeenCalledWith("/api/users/1", {
        method: "PUT",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(userData),
      });
      expect(result).toEqual(updatedUser);
    });

    it("throws error when update fails", async () => {
      fetch.mockResolvedValueOnce({
        ok: false,
        statusText: "Bad Request",
      });

      await expect(userService.updateUser(1, {})).rejects.toThrow(
        "Failed to update user: Bad Request"
      );
    });
  });
});

Integration Testing

Testing Connected Components

Component with API Integration

// src/components/UserProfile.js
import React, { useState, useEffect } from "react";
import { View, Text, ActivityIndicator, Alert, StyleSheet } from "react-native";
import Button from "./Button";
import userService from "../services/userService";

const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [updating, setUpdating] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    loadUser();
  }, [userId]);

  const loadUser = async () => {
    try {
      setLoading(true);
      setError(null);
      const userData = await userService.fetchUser(userId);
      setUser(userData);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  const updateProfile = async () => {
    try {
      setUpdating(true);
      const updatedUser = await userService.updateUser(userId, {
        ...user,
        lastUpdated: new Date().toISOString(),
      });
      setUser(updatedUser);
      Alert.alert("Success", "Profile updated successfully");
    } catch (err) {
      Alert.alert("Error", err.message);
    } finally {
      setUpdating(false);
    }
  };

  if (loading) {
    return (
      <View style={styles.container} testID="loading-indicator">
        <ActivityIndicator size="large" />
        <Text>Loading user...</Text>
      </View>
    );
  }

  if (error) {
    return (
      <View style={styles.container} testID="error-view">
        <Text style={styles.error}>{error}</Text>
        <Button title="Retry" onPress={loadUser} />
      </View>
    );
  }

  return (
    <View style={styles.container} testID="user-profile">
      <Text style={styles.name} testID="user-name">
        {user.name}
      </Text>
      <Text style={styles.email} testID="user-email">
        {user.email}
      </Text>
      <Button
        title={updating ? "Updating..." : "Update Profile"}
        onPress={updateProfile}
        disabled={updating}
        testID="update-button"
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    padding: 20,
    alignItems: "center",
  },
  name: {
    fontSize: 24,
    fontWeight: "bold",
    marginBottom: 8,
  },
  email: {
    fontSize: 16,
    color: "#666",
    marginBottom: 20,
  },
  error: {
    color: "red",
    textAlign: "center",
    marginBottom: 20,
  },
});

export default UserProfile;
// src/components/__tests__/UserProfile.test.js
import React from "react";
import { render, fireEvent, waitFor } from "@testing-library/react-native";
import { Alert } from "react-native";
import UserProfile from "../UserProfile";
import userService from "../../services/userService";

// Mock the userService
jest.mock("../../services/userService");
jest.mock("react-native/Libraries/Alert/Alert", () => ({
  alert: jest.fn(),
}));

describe("UserProfile Integration", () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it("loads and displays user data", async () => {
    const mockUser = {
      id: 1,
      name: "John Doe",
      email: "john@example.com",
    };

    userService.fetchUser.mockResolvedValueOnce(mockUser);

    const { getByTestId, getByText } = render(<UserProfile userId={1} />);

    // Initially shows loading
    expect(getByTestId("loading-indicator")).toBeTruthy();

    // Wait for user data to load
    await waitFor(() => {
      expect(getByTestId("user-profile")).toBeTruthy();
    });

    // Check user data is displayed
    expect(getByTestId("user-name")).toHaveTextContent("John Doe");
    expect(getByTestId("user-email")).toHaveTextContent("john@example.com");
    expect(userService.fetchUser).toHaveBeenCalledWith(1);
  });

  it("handles loading error", async () => {
    userService.fetchUser.mockRejectedValueOnce(
      new Error("Failed to fetch user")
    );

    const { getByTestId, getByText } = render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(getByTestId("error-view")).toBeTruthy();
    });

    expect(getByText("Failed to fetch user")).toBeTruthy();
    expect(getByText("Retry")).toBeTruthy();
  });

  it("retries loading user on retry button press", async () => {
    const mockUser = {
      id: 1,
      name: "John Doe",
      email: "john@example.com",
    };

    // First call fails
    userService.fetchUser
      .mockRejectedValueOnce(new Error("Network error"))
      .mockResolvedValueOnce(mockUser);

    const { getByTestId, getByText } = render(<UserProfile userId={1} />);

    // Wait for error state
    await waitFor(() => {
      expect(getByTestId("error-view")).toBeTruthy();
    });

    // Press retry button
    fireEvent.press(getByText("Retry"));

    // Wait for successful load
    await waitFor(() => {
      expect(getByTestId("user-profile")).toBeTruthy();
    });

    expect(userService.fetchUser).toHaveBeenCalledTimes(2);
  });

  it("updates profile successfully", async () => {
    const mockUser = {
      id: 1,
      name: "John Doe",
      email: "john@example.com",
    };

    const updatedUser = {
      ...mockUser,
      lastUpdated: "2023-01-01T00:00:00.000Z",
    };

    userService.fetchUser.mockResolvedValueOnce(mockUser);
    userService.updateUser.mockResolvedValueOnce(updatedUser);

    const { getByTestId } = render(<UserProfile userId={1} />);

    // Wait for user data to load
    await waitFor(() => {
      expect(getByTestId("user-profile")).toBeTruthy();
    });

    // Press update button
    fireEvent.press(getByTestId("update-button"));

    // Wait for update to complete
    await waitFor(() => {
      expect(Alert.alert).toHaveBeenCalledWith(
        "Success",
        "Profile updated successfully"
      );
    });

    expect(userService.updateUser).toHaveBeenCalledWith(
      1,
      expect.objectContaining({
        id: 1,
        name: "John Doe",
        email: "john@example.com",
        lastUpdated: expect.any(String),
      })
    );
  });

  it("handles update error", async () => {
    const mockUser = {
      id: 1,
      name: "John Doe",
      email: "john@example.com",
    };

    userService.fetchUser.mockResolvedValueOnce(mockUser);
    userService.updateUser.mockRejectedValueOnce(new Error("Update failed"));

    const { getByTestId } = render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(getByTestId("user-profile")).toBeTruthy();
    });

    fireEvent.press(getByTestId("update-button"));

    await waitFor(() => {
      expect(Alert.alert).toHaveBeenCalledWith("Error", "Update failed");
    });
  });
});

Testing with Redux

Redux Connected Component

// src/components/PostList.js
import React, { useEffect } from "react";
import { FlatList, View, Text, StyleSheet } from "react-native";
import { useSelector, useDispatch } from "react-redux";
import { fetchPosts } from "../store/slices/postsSlice";
import Button from "./Button";

const PostList = () => {
  const dispatch = useDispatch();
  const { posts, loading, error } = useSelector((state) => state.posts);

  useEffect(() => {
    dispatch(fetchPosts());
  }, [dispatch]);

  const handleRefresh = () => {
    dispatch(fetchPosts());
  };

  const renderPost = ({ item }) => (
    <View style={styles.postItem} testID={`post-${item.id}`}>
      <Text style={styles.title}>{item.title}</Text>
      <Text style={styles.excerpt}>{item.excerpt}</Text>
    </View>
  );

  if (loading && posts.length === 0) {
    return (
      <View style={styles.container} testID="loading-view">
        <Text>Loading posts...</Text>
      </View>
    );
  }

  if (error) {
    return (
      <View style={styles.container} testID="error-view">
        <Text style={styles.error}>{error}</Text>
        <Button title="Retry" onPress={handleRefresh} />
      </View>
    );
  }

  return (
    <View style={styles.container} testID="post-list">
      <FlatList
        data={posts}
        renderItem={renderPost}
        keyExtractor={(item) => item.id.toString()}
        refreshing={loading}
        onRefresh={handleRefresh}
        testID="posts-flatlist"
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
  },
  postItem: {
    padding: 16,
    borderBottomWidth: 1,
    borderBottomColor: "#eee",
  },
  title: {
    fontSize: 18,
    fontWeight: "bold",
    marginBottom: 8,
  },
  excerpt: {
    fontSize: 14,
    color: "#666",
  },
  error: {
    color: "red",
    textAlign: "center",
    marginBottom: 16,
  },
});

export default PostList;
// src/components/__tests__/PostList.test.js
import React from "react";
import { render, fireEvent, waitFor } from "@testing-library/react-native";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import PostList from "../PostList";
import postsSlice, { fetchPosts } from "../../store/slices/postsSlice";

// Mock the fetchPosts thunk
jest.mock("../../store/slices/postsSlice", () => ({
  ...jest.requireActual("../../store/slices/postsSlice"),
  fetchPosts: jest.fn(),
}));

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

const renderWithRedux = (component, initialState) => {
  const store = createMockStore(initialState);
  return {
    ...render(<Provider store={store}>{component}</Provider>),
    store,
  };
};

describe("PostList Integration", () => {
  beforeEach(() => {
    fetchPosts.mockClear();
  });

  it("renders loading state initially", () => {
    const initialState = {
      posts: {
        posts: [],
        loading: true,
        error: null,
      },
    };

    const { getByTestId } = renderWithRedux(<PostList />, initialState);
    expect(getByTestId("loading-view")).toBeTruthy();
  });

  it("renders posts when loaded", () => {
    const mockPosts = [
      { id: 1, title: "Post 1", excerpt: "Excerpt 1" },
      { id: 2, title: "Post 2", excerpt: "Excerpt 2" },
    ];

    const initialState = {
      posts: {
        posts: mockPosts,
        loading: false,
        error: null,
      },
    };

    const { getByTestId, getByText } = renderWithRedux(
      <PostList />,
      initialState
    );

    expect(getByTestId("post-list")).toBeTruthy();
    expect(getByTestId("post-1")).toBeTruthy();
    expect(getByTestId("post-2")).toBeTruthy();
    expect(getByText("Post 1")).toBeTruthy();
    expect(getByText("Post 2")).toBeTruthy();
  });

  it("renders error state", () => {
    const initialState = {
      posts: {
        posts: [],
        loading: false,
        error: "Failed to load posts",
      },
    };

    const { getByTestId, getByText } = renderWithRedux(
      <PostList />,
      initialState
    );

    expect(getByTestId("error-view")).toBeTruthy();
    expect(getByText("Failed to load posts")).toBeTruthy();
    expect(getByText("Retry")).toBeTruthy();
  });

  it("dispatches fetchPosts on mount", () => {
    const initialState = {
      posts: {
        posts: [],
        loading: false,
        error: null,
      },
    };

    fetchPosts.mockReturnValue({ type: "posts/fetchPosts/pending" });

    renderWithRedux(<PostList />, initialState);

    expect(fetchPosts).toHaveBeenCalledTimes(1);
  });

  it("dispatches fetchPosts on retry button press", () => {
    const initialState = {
      posts: {
        posts: [],
        loading: false,
        error: "Network error",
      },
    };

    fetchPosts.mockReturnValue({ type: "posts/fetchPosts/pending" });

    const { getByText } = renderWithRedux(<PostList />, initialState);

    fireEvent.press(getByText("Retry"));

    expect(fetchPosts).toHaveBeenCalledTimes(2); // Once on mount, once on retry
  });
});

End-to-End (E2E) Testing with Detox

Setting up Detox

Installation and Configuration

# Install Detox CLI globally
npm install -g detox-cli

# Install Detox locally
npm install --save-dev detox

# iOS dependencies
npm install --save-dev detox-ios-simulator-utils

# For Android
# Ensure you have Android SDK and emulator setup

Detox Configuration

Create .detoxrc.js:

// .detoxrc.js
module.exports = {
  testRunner: "jest",
  runnerConfig: "e2e/config.json",
  configurations: {
    "ios.sim.debug": {
      device: {
        type: "ios.simulator",
        device: {
          type: "iPhone 14",
        },
      },
      app: {
        type: "ios.app",
        binaryPath:
          "ios/build/Build/Products/Debug-iphonesimulator/YourApp.app",
        build:
          "xcodebuild -workspace ios/YourApp.xcworkspace -scheme YourApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build",
      },
    },
    "android.emu.debug": {
      device: {
        type: "android.emulator",
        device: {
          avdName: "Pixel_4_API_30",
        },
      },
      app: {
        type: "android.apk",
        binaryPath: "android/app/build/outputs/apk/debug/app-debug.apk",
        build:
          "cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug",
      },
    },
  },
};

Jest Configuration for E2E

Create e2e/config.json:

{
  "setupFilesAfterEnv": ["./init.js"],
  "testEnvironment": "node",
  "reporters": ["detox/runners/jest/streamlineReporter"],
  "verbose": true,
  "bail": true,
  "testTimeout": 120000
}

Create e2e/init.js:

// e2e/init.js
const detox = require("detox");
const config = require("../.detoxrc.js");
const adapter = require("detox/runners/jest/adapter");

jest.setTimeout(120000);
jasmine.getEnv().addReporter(adapter);

beforeAll(async () => {
  await detox.init(config);
});

beforeEach(async () => {
  await adapter.beforeEach();
});

afterAll(async () => {
  await adapter.afterAll();
  await detox.cleanup();
});

Writing E2E Tests

Basic Navigation Test

// e2e/navigation.e2e.js
describe("App Navigation", () => {
  beforeAll(async () => {
    await device.launchApp();
  });

  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it("should navigate between screens", async () => {
    // Check home screen is visible
    await expect(element(by.testID("home-screen"))).toBeVisible();

    // Navigate to profile screen
    await element(by.testID("profile-tab")).tap();
    await expect(element(by.testID("profile-screen"))).toBeVisible();

    // Navigate to settings
    await element(by.testID("settings-button")).tap();
    await expect(element(by.testID("settings-screen"))).toBeVisible();

    // Go back
    await element(by.testID("back-button")).tap();
    await expect(element(by.testID("profile-screen"))).toBeVisible();
  });

  it("should show user profile information", async () => {
    await element(by.testID("profile-tab")).tap();

    await expect(element(by.testID("user-name"))).toBeVisible();
    await expect(element(by.testID("user-email"))).toBeVisible();
    await expect(element(by.text("John Doe"))).toBeVisible();
  });
});

Form Testing

// e2e/forms.e2e.js
describe("Form Interactions", () => {
  beforeAll(async () => {
    await device.launchApp();
  });

  beforeEach(async () => {
    await device.reloadReactNative();
    // Navigate to form screen
    await element(by.testID("forms-tab")).tap();
  });

  it("should submit contact form successfully", async () => {
    // Fill out the form
    await element(by.testID("name-input")).typeText("John Doe");
    await element(by.testID("email-input")).typeText("john@example.com");
    await element(by.testID("message-input")).typeText(
      "This is a test message"
    );

    // Dismiss keyboard
    await element(by.testID("message-input")).tapReturnKey();

    // Submit form
    await element(by.testID("submit-button")).tap();

    // Check success message
    await expect(element(by.testID("success-message"))).toBeVisible();
    await expect(
      element(by.text("Form submitted successfully!"))
    ).toBeVisible();
  });

  it("should show validation errors for empty fields", async () => {
    // Try to submit empty form
    await element(by.testID("submit-button")).tap();

    // Check validation errors
    await expect(element(by.testID("name-error"))).toBeVisible();
    await expect(element(by.testID("email-error"))).toBeVisible();
    await expect(element(by.text("Name is required"))).toBeVisible();
    await expect(element(by.text("Email is required"))).toBeVisible();
  });

  it("should validate email format", async () => {
    await element(by.testID("name-input")).typeText("John Doe");
    await element(by.testID("email-input")).typeText("invalid-email");
    await element(by.testID("submit-button")).tap();

    await expect(element(by.text("Please enter a valid email"))).toBeVisible();
  });
});

List and Scroll Testing

// e2e/lists.e2e.js
describe("List Interactions", () => {
  beforeAll(async () => {
    await device.launchApp();
  });

  beforeEach(async () => {
    await device.reloadReactNative();
    await element(by.testID("posts-tab")).tap();
  });

  it("should load and display posts", async () => {
    // Wait for posts to load
    await waitFor(element(by.testID("post-list")))
      .toBeVisible()
      .withTimeout(5000);

    // Check first few posts are visible
    await expect(element(by.testID("post-1"))).toBeVisible();
    await expect(element(by.testID("post-2"))).toBeVisible();
  });

  it("should scroll through posts list", async () => {
    await waitFor(element(by.testID("post-list")))
      .toBeVisible()
      .withTimeout(5000);

    // Scroll down to load more posts
    await element(by.testID("posts-flatlist")).scroll(200, "down");

    // Check that more posts are visible
    await expect(element(by.testID("post-5"))).toBeVisible();

    // Scroll back up
    await element(by.testID("posts-flatlist")).scroll(200, "up");
    await expect(element(by.testID("post-1"))).toBeVisible();
  });

  it("should pull to refresh posts", async () => {
    await waitFor(element(by.testID("post-list")))
      .toBeVisible()
      .withTimeout(5000);

    // Pull to refresh
    await element(by.testID("posts-flatlist")).scroll(100, "down", 0.1, 0.1);

    // Wait for refresh to complete
    await waitFor(element(by.testID("loading-indicator")))
      .not.toBeVisible()
      .withTimeout(5000);
  });

  it("should open post details when tapped", async () => {
    await waitFor(element(by.testID("post-list")))
      .toBeVisible()
      .withTimeout(5000);

    // Tap on first post
    await element(by.testID("post-1")).tap();

    // Check post detail screen is visible
    await expect(element(by.testID("post-detail-screen"))).toBeVisible();
    await expect(element(by.testID("post-title"))).toBeVisible();
    await expect(element(by.testID("post-content"))).toBeVisible();

    // Go back
    await element(by.testID("back-button")).tap();
    await expect(element(by.testID("post-list"))).toBeVisible();
  });
});

Testing User Workflows

// e2e/userFlow.e2e.js
describe("Complete User Workflows", () => {
  beforeAll(async () => {
    await device.launchApp();
  });

  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it("should complete full post creation workflow", async () => {
    // Navigate to create post screen
    await element(by.testID("create-post-fab")).tap();
    await expect(element(by.testID("create-post-screen"))).toBeVisible();

    // Fill out post form
    await element(by.testID("post-title-input")).typeText("My Test Post");
    await element(by.testID("post-content-input")).typeText(
      "This is the content of my test post. It contains some interesting information."
    );

    // Select category
    await element(by.testID("category-picker")).tap();
    await element(by.text("Technology")).tap();

    // Add tags
    await element(by.testID("tags-input")).typeText("react-native, testing");

    // Save as draft first
    await element(by.testID("save-draft-button")).tap();
    await expect(element(by.text("Draft saved"))).toBeVisible();

    // Publish post
    await element(by.testID("publish-button")).tap();

    // Confirm publication
    await element(by.text("Publish")).tap();

    // Check success and navigation back to list
    await expect(element(by.text("Post published successfully"))).toBeVisible();
    await expect(element(by.testID("post-list"))).toBeVisible();

    // Verify new post appears in list
    await expect(element(by.text("My Test Post"))).toBeVisible();
  });

  it("should handle offline post creation", async () => {
    // Simulate offline mode
    await device.setURLBlacklist(["*"]);

    // Create post while offline
    await element(by.testID("create-post-fab")).tap();
    await element(by.testID("post-title-input")).typeText("Offline Post");
    await element(by.testID("post-content-input")).typeText(
      "Created while offline"
    );
    await element(by.testID("publish-button")).tap();

    // Check offline indicator
    await expect(element(by.testID("offline-indicator"))).toBeVisible();
    await expect(element(by.text("Post will sync when online"))).toBeVisible();

    // Go back to list and check post is marked as pending
    await element(by.testID("back-button")).tap();
    await expect(element(by.testID("sync-pending-indicator"))).toBeVisible();

    // Restore connectivity
    await device.setURLBlacklist([]);

    // Wait for sync
    await waitFor(element(by.testID("sync-pending-indicator")))
      .not.toBeVisible()
      .withTimeout(10000);
  });
});

Testing Best Practices

Test Organization

Grouping Tests

// Organize tests by feature
describe("User Authentication", () => {
  describe("Login", () => {
    it("should login with valid credentials", () => {});
    it("should show error for invalid credentials", () => {});
  });

  describe("Registration", () => {
    it("should register new user", () => {});
    it("should validate required fields", () => {});
  });

  describe("Password Reset", () => {
    it("should send reset email", () => {});
    it("should validate email format", () => {});
  });
});

Test Data Management

// src/test-utils/fixtures.js
export const mockUser = {
  id: 1,
  name: "John Doe",
  email: "john@example.com",
  avatar: "https://example.com/avatar.jpg",
};

export const mockPosts = [
  {
    id: 1,
    title: "First Post",
    content: "This is the first post content",
    author: mockUser,
    createdAt: "2023-01-01T00:00:00Z",
  },
  {
    id: 2,
    title: "Second Post",
    content: "This is the second post content",
    author: mockUser,
    createdAt: "2023-01-02T00:00:00Z",
  },
];

export const createMockUser = (overrides = {}) => ({
  ...mockUser,
  ...overrides,
});

Custom Testing Utilities

// src/test-utils/render.js
import React from "react";
import { render } from "@testing-library/react-native";
import { Provider } from "react-redux";
import { NavigationContainer } from "@react-navigation/native";
import { createMockStore } from "./store";

export const renderWithProviders = (
  ui,
  {
    preloadedState = {},
    store = createMockStore(preloadedState),
    ...renderOptions
  } = {}
) => {
  const Wrapper = ({ children }) => (
    <Provider store={store}>
      <NavigationContainer>{children}</NavigationContainer>
    </Provider>
  );

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

export * from "@testing-library/react-native";
export { renderWithProviders as render };

Performance Testing

Testing Component Performance

// src/components/__tests__/PerformanceTest.js
import React from "react";
import { render } from "@testing-library/react-native";
import { act } from "react-test-renderer";
import LargeList from "../LargeList";

describe("LargeList Performance", () => {
  it("should render large list efficiently", () => {
    const largeDataSet = Array.from({ length: 1000 }, (_, i) => ({
      id: i,
      title: `Item ${i}`,
    }));

    const startTime = performance.now();

    render(<LargeList data={largeDataSet} />);

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

    // Should render within reasonable time (adjust threshold as needed)
    expect(renderTime).toBeLessThan(100); // 100ms
  });

  it("should handle updates efficiently", () => {
    const { rerender } = render(<LargeList data={[]} />);

    const updates = Array.from({ length: 10 }, (_, i) =>
      Array.from({ length: 100 }, (_, j) => ({
        id: i * 100 + j,
        title: `Item ${i * 100 + j}`,
      }))
    );

    const startTime = performance.now();

    updates.forEach((data) => {
      act(() => {
        rerender(<LargeList data={data} />);
      });
    });

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

    expect(updateTime).toBeLessThan(500); // 500ms for all updates
  });
});

Accessibility Testing

// src/components/__tests__/AccessibilityTest.js
import React from "react";
import { render } from "@testing-library/react-native";
import Button from "../Button";

describe("Button Accessibility", () => {
  it("should have proper accessibility labels", () => {
    const { getByTestId } = render(
      <Button
        title="Submit Form"
        accessibilityLabel="Submit the contact form"
        accessibilityHint="Submits the form and sends your message"
      />
    );

    const button = getByTestId("custom-button");

    expect(button.props.accessibilityLabel).toBe("Submit the contact form");
    expect(button.props.accessibilityHint).toBe(
      "Submits the form and sends your message"
    );
    expect(button.props.accessibilityRole).toBe("button");
  });

  it("should be accessible to screen readers", () => {
    const { getByTestId } = render(
      <Button title="Delete Item" variant="danger" />
    );

    const button = getByTestId("custom-button");

    expect(button.props.accessible).toBe(true);
    expect(button.props.accessibilityLabel).toBeTruthy();
  });
});

Debugging and Troubleshooting

Common Issues and Solutions

1. Tests Timing Out

// Increase timeout for async operations
it("should load data", async () => {
  const promise = loadData();

  await waitFor(
    () => {
      expect(getByTestId("data-loaded")).toBeTruthy();
    },
    { timeout: 5000 }
  ); // Increase timeout
});

2. Mock Issues

// Clear mocks between tests
beforeEach(() => {
  jest.clearAllMocks();
  // or
  mockFunction.mockClear();
});

// Reset mock implementations
afterEach(() => {
  jest.resetAllMocks();
});

3. Async State Updates

// Wrap state updates in act()
import { act } from "react-test-renderer";

it("should update state", async () => {
  const { getByTestId } = render(<Component />);

  await act(async () => {
    fireEvent.press(getByTestId("button"));
    await waitFor(() => {
      expect(getByTestId("updated-content")).toBeTruthy();
    });
  });
});

4. Navigation Testing Issues

// Mock navigation properly
const mockNavigate = jest.fn();
jest.mock("@react-navigation/native", () => ({
  useNavigation: () => ({
    navigate: mockNavigate,
  }),
}));

// Test navigation calls
expect(mockNavigate).toHaveBeenCalledWith("ProfileScreen", { userId: 1 });

Test Debugging Tips

1. Debug Test Output

// Use debug to see current component tree
import { render, screen } from "@testing-library/react-native";

it("should render component", () => {
  const { debug } = render(<MyComponent />);
  debug(); // Prints component tree to console
});

2. Query Debugging

// Use screen.getByRole with debugging
import { screen } from "@testing-library/react-native";

// This will show available roles if element not found
expect(screen.getByRole("button", { name: "Submit" })).toBeTruthy();

3. Test Environment Debugging

// Add debugging information
console.log("Test environment:", process.env.NODE_ENV);
console.log("Platform:", Platform.OS);
console.log("Mock state:", store.getState());

CI/CD Integration

GitHub Actions Configuration

# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: "18"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npm test -- --coverage --watchAll=false

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/lcov.info
          fail_ci_if_error: true

  e2e-tests:
    runs-on: macos-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: "18"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Setup iOS Simulator
        run: |
          sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
          xcrun simctl create "iPhone 14" "iPhone 14" "iOS16.0"

      - name: Build iOS app
        run: detox build --configuration ios.sim.debug

      - name: Run E2E tests
        run: detox test --configuration ios.sim.debug --cleanup

Conclusion

Testing is essential for building reliable React Native applications. A comprehensive testing strategy that includes unit, integration, and E2E tests helps ensure your app works correctly across different scenarios and devices.

Key Takeaways:

โœ… Test Pyramid: Focus on unit tests (70%), some integration tests (20%), few E2E tests (10%)
โœ… Test Early and Often: Write tests as you develop features
โœ… Mock Appropriately: Mock external dependencies and heavy operations
โœ… Test User Workflows: E2E tests should cover critical user journeys
โœ… Maintain Test Quality: Keep tests simple, focused, and maintainable

Best Practices Summary:

  • Write descriptive test names that explain what is being tested
  • Keep tests isolated and independent of each other
  • Use proper setup and teardown to maintain clean test environment
  • Test behavior, not implementation details
  • Include accessibility testing in your test suite
  • Monitor test performance and optimize slow tests
  • Integrate testing into CI/CD pipeline for automated quality checks

Remember that testing is an investment in your app's quality and your team's confidence. Start with the most critical features and gradually expand your test coverage. The goal is not 100% coverage, but rather covering the most important user flows and business logic.

Happy testing! ๐Ÿงช๐Ÿ“ฑ