Testing Strategies
Comprehensive testing is essential for React Native applications. This guide covers unit testing, component testing, integration testing, and end-to-end testing strategies.
Testing Setup
Jest Configuration
// jest.config.js
module.exports = {
preset: "react-native",
setupFilesAfterEnv: ["<rootDir>/__tests__/setup/jest-setup.ts"],
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/android/", "<rootDir>/ios/"],
transformIgnorePatterns: [
"node_modules/(?!(react-native|@react-native|expo|@expo|@unimodules|unimodules|sentry-expo|native-base|react-clone-referenced-element|@react-native-community|expo-router|@expo/.*|react-native-.*)/)",
],
collectCoverageFrom: ["src/**/*.{ts,tsx}", "!src/**/*.d.ts", "!src/**/*.stories.{ts,tsx}", "!src/**/__tests__/**"],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
}
Test Setup File
// __tests__/setup/jest-setup.ts
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 modules
jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper")
// Mock Expo modules
jest.mock("expo-font")
jest.mock("expo-asset")
jest.mock("expo-constants", () => ({
manifest: {},
}))
// Mock react-navigation
jest.mock("@react-navigation/native", () => ({
useNavigation: () => ({
navigate: jest.fn(),
goBack: jest.fn(),
}),
useRoute: () => ({
params: {},
}),
useFocusEffect: jest.fn(),
}))
// Silence console warnings during tests
global.console = {
...console,
warn: jest.fn(),
error: jest.fn(),
}
// Mock Dimensions
const mockDimensions = {
get: jest.fn(() => ({ width: 375, height: 667 })),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}
jest.mock("react-native/Libraries/Utilities/Dimensions", () => mockDimensions)
Unit Testing
Testing Utility Functions
// utils/format.ts
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount)
}
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text
return text.substring(0, maxLength - 3) + "..."
}
// __tests__/utils/format.test.ts
import { formatCurrency, validateEmail, truncateText } from "@/utils/format"
describe("formatCurrency", () => {
it("should format USD currency correctly", () => {
expect(formatCurrency(1234.56)).toBe("$1,234.56")
expect(formatCurrency(0)).toBe("$0.00")
expect(formatCurrency(-100)).toBe("-$100.00")
})
it("should format different currencies", () => {
expect(formatCurrency(1000, "EUR")).toBe("€1,000.00")
expect(formatCurrency(1000, "GBP")).toBe("£1,000.00")
})
})
describe("validateEmail", () => {
it("should validate correct email addresses", () => {
expect(validateEmail("test@example.com")).toBe(true)
expect(validateEmail("user.name+tag@domain.co.uk")).toBe(true)
})
it("should reject invalid email addresses", () => {
expect(validateEmail("invalid-email")).toBe(false)
expect(validateEmail("@example.com")).toBe(false)
expect(validateEmail("test@")).toBe(false)
expect(validateEmail("")).toBe(false)
})
})
describe("truncateText", () => {
it("should truncate long text", () => {
const longText = "This is a very long text that should be truncated"
expect(truncateText(longText, 20)).toBe("This is a very lo...")
})
it("should not truncate short text", () => {
const shortText = "Short text"
expect(truncateText(shortText, 20)).toBe("Short text")
})
it("should handle edge cases", () => {
expect(truncateText("", 10)).toBe("")
expect(truncateText("abc", 3)).toBe("abc")
expect(truncateText("abcd", 3)).toBe("...")
})
})
Testing Custom Hooks
// hooks/useCounter.ts
import { useState, useCallback } from "react"
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => {
setCount((prev) => prev + 1)
}, [])
const decrement = useCallback(() => {
setCount((prev) => prev - 1)
}, [])
const reset = useCallback(() => {
setCount(initialValue)
}, [initialValue])
const setValue = useCallback((value: number) => {
setCount(value)
}, [])
return {
count,
increment,
decrement,
reset,
setValue,
}
}
// __tests__/hooks/useCounter.test.ts
import { renderHook, act } from "@testing-library/react-native"
import { useCounter } from "@/hooks/useCounter"
describe("useCounter", () => {
it("should initialize with default value", () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it("should initialize with custom value", () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it("should increment count", () => {
const { result } = renderHook(() => useCounter(0))
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it("should decrement count", () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(9)
})
it("should reset to initial value", () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.increment()
result.current.increment()
})
expect(result.current.count).toBe(7)
act(() => {
result.current.reset()
})
expect(result.current.count).toBe(5)
})
it("should set specific value", () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.setValue(42)
})
expect(result.current.count).toBe(42)
})
})
Component Testing
Testing UI Components
// components/Button.tsx
import React from "react"
import { Pressable, Text, StyleSheet, ViewStyle, TextStyle } from "react-native"
interface ButtonProps {
title: string
onPress: () => void
variant?: "primary" | "secondary" | "danger"
disabled?: boolean
loading?: boolean
testID?: string
}
export function Button({
title,
onPress,
variant = "primary",
disabled = false,
loading = false,
testID,
}: ButtonProps) {
const handlePress = () => {
if (!disabled && !loading) {
onPress()
}
}
return (
<Pressable
style={[styles.button, styles[variant], (disabled || loading) && styles.disabled]}
onPress={handlePress}
testID={testID}
accessibilityRole="button"
accessibilityState={{ disabled: disabled || loading }}
>
<Text style={[styles.text, styles[`${variant}Text`]]}>{loading ? "Loading..." : title}</Text>
</Pressable>
)
}
// __tests__/components/Button.test.tsx
import React from "react"
import { render, fireEvent } from "@testing-library/react-native"
import { Button } from "@/components/Button"
describe("Button", () => {
const mockOnPress = jest.fn()
beforeEach(() => {
mockOnPress.mockClear()
})
it("should render correctly", () => {
const { getByText, getByRole } = render(<Button title="Test Button" onPress={mockOnPress} />)
expect(getByText("Test Button")).toBeTruthy()
expect(getByRole("button")).toBeTruthy()
})
it("should call onPress when pressed", () => {
const { getByRole } = render(<Button title="Test Button" onPress={mockOnPress} />)
fireEvent.press(getByRole("button"))
expect(mockOnPress).toHaveBeenCalledTimes(1)
})
it("should not call onPress when disabled", () => {
const { getByRole } = render(<Button title="Test Button" onPress={mockOnPress} disabled />)
fireEvent.press(getByRole("button"))
expect(mockOnPress).not.toHaveBeenCalled()
})
it("should show loading state", () => {
const { getByText, getByRole } = render(<Button title="Test Button" onPress={mockOnPress} loading />)
expect(getByText("Loading...")).toBeTruthy()
fireEvent.press(getByRole("button"))
expect(mockOnPress).not.toHaveBeenCalled()
})
it("should render different variants", () => {
const { rerender, getByRole } = render(<Button title="Primary" onPress={mockOnPress} variant="primary" />)
let button = getByRole("button")
expect(button).toHaveStyle({ backgroundColor: "#007AFF" })
rerender(<Button title="Secondary" onPress={mockOnPress} variant="secondary" />)
button = getByRole("button")
expect(button).toHaveStyle({ backgroundColor: "#6C757D" })
})
it("should have proper accessibility properties", () => {
const { getByRole } = render(<Button title="Test Button" onPress={mockOnPress} disabled />)
const button = getByRole("button")
expect(button).toHaveAccessibilityState({ disabled: true })
})
})
Integration Testing
Testing with Context Providers
// __tests__/integration/AuthFlow.test.tsx
import React from "react"
import { render, fireEvent, waitFor } from "@testing-library/react-native"
import { AuthProvider } from "@/contexts/AuthContext"
import { LoginScreen } from "@/screens/LoginScreen"
// Mock API calls
jest.mock("@/services/api", () => ({
loginAPI: jest.fn(),
}))
const renderWithAuth = (component: React.ReactElement) => {
return render(<AuthProvider>{component}</AuthProvider>)
}
describe("Authentication Flow", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("should handle successful login", async () => {
const mockLoginAPI = require("@/services/api").loginAPI
mockLoginAPI.mockResolvedValue({
user: { id: "1", name: "John Doe", email: "john@example.com" },
token: "fake-token",
})
const { getByTestId } = renderWithAuth(<LoginScreen />)
fireEvent.changeText(getByTestId("email-input"), "john@example.com")
fireEvent.changeText(getByTestId("password-input"), "password123")
fireEvent.press(getByTestId("login-button"))
await waitFor(() => {
expect(mockLoginAPI).toHaveBeenCalledWith("john@example.com", "password123")
})
// Verify user is logged in
await waitFor(() => {
expect(getByTestId("user-profile")).toBeTruthy()
})
})
it("should handle login error", async () => {
const mockLoginAPI = require("@/services/api").loginAPI
mockLoginAPI.mockRejectedValue(new Error("Invalid credentials"))
const { getByTestId } = renderWithAuth(<LoginScreen />)
fireEvent.changeText(getByTestId("email-input"), "john@example.com")
fireEvent.changeText(getByTestId("password-input"), "wrongpassword")
fireEvent.press(getByTestId("login-button"))
await waitFor(() => {
expect(getByTestId("error-message")).toHaveTextContent("Invalid credentials")
})
})
})
End-to-End Testing
Detox Setup
// detox.config.js
module.exports = {
testRunner: "jest",
runnerConfig: "e2e/config.json",
apps: {
"ios.debug": {
type: "ios.app",
binaryPath: "ios/build/Build/Products/Debug-iphonesimulator/MyApp.app",
build:
"xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build",
},
"android.debug": {
type: "android.apk",
binaryPath: "android/app/build/outputs/apk/debug/app-debug.apk",
build: "cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug",
reversePorts: [8081],
},
},
devices: {
simulator: {
type: "ios.simulator",
device: {
type: "iPhone 13",
},
},
emulator: {
type: "android.emulator",
device: {
avdName: "Pixel_4_API_30",
},
},
},
configurations: {
"ios.sim.debug": {
device: "simulator",
app: "ios.debug",
},
"android.emu.debug": {
device: "emulator",
app: "android.debug",
},
},
}
E2E Test Examples
// e2e/login.e2e.ts
import { device, expect, element, by } from "detox"
describe("Login Flow", () => {
beforeAll(async () => {
await device.launchApp()
})
beforeEach(async () => {
await device.reloadReactNative()
})
it("should show login screen", async () => {
await expect(element(by.id("login-screen"))).toBeVisible()
await expect(element(by.id("email-input"))).toBeVisible()
await expect(element(by.id("password-input"))).toBeVisible()
await expect(element(by.id("login-button"))).toBeVisible()
})
it("should login with valid credentials", async () => {
await element(by.id("email-input")).typeText("test@example.com")
await element(by.id("password-input")).typeText("password123")
await element(by.id("login-button")).tap()
await expect(element(by.id("home-screen"))).toBeVisible()
})
it("should show error for invalid credentials", async () => {
await element(by.id("email-input")).typeText("invalid@example.com")
await element(by.id("password-input")).typeText("wrongpassword")
await element(by.id("login-button")).tap()
await expect(element(by.id("error-message"))).toBeVisible()
})
})
Testing Best Practices
Test Organization
// Organize tests by feature/component
describe("UserProfile", () => {
describe("rendering", () => {
it("should render user information", () => {})
it("should show loading state", () => {})
it("should show error state", () => {})
})
describe("interactions", () => {
it("should handle profile update", () => {})
it("should handle logout", () => {})
})
describe("edge cases", () => {
it("should handle missing user data", () => {})
it("should handle network errors", () => {})
})
})
Test Data Management
// __tests__/fixtures/userData.ts
export const mockUser = {
id: "1",
name: "John Doe",
email: "john@example.com",
avatar: "https://example.com/avatar.jpg",
}
export const createMockUser = (overrides: Partial<User> = {}) => ({
...mockUser,
...overrides,
})
// __tests__/helpers/render.tsx
import React from "react"
import { render } from "@testing-library/react-native"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { AuthProvider } from "@/contexts/AuthContext"
export function renderWithProviders(
ui: React.ReactElement,
options: {
queryClient?: QueryClient
user?: User | null
} = {}
) {
const queryClient =
options.queryClient ||
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider initialUser={options.user}>{children}</AuthProvider>
</QueryClientProvider>
)
}
return render(ui, { wrapper: Wrapper })
}
CI/CD Testing
GitHub Actions Example
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
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 type check
run: npm run type-check
- name: Run linter
run: npm run lint
- name: Run tests
run: npm run test -- --coverage --watchAll=false
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
e2e:
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: Build iOS app
run: npx detox build --configuration ios.sim.debug
- name: Run E2E tests
run: npx detox test --configuration ios.sim.debug
Next Steps
Continue your React Native journey with:
- Performance Optimization - Advanced performance techniques
- Deployment & Distribution - Publishing to app stores
- Advanced Topics - Custom native modules and advanced patterns