Введение в Тестирование React Native

React Native Testing Library (RNTL) — это индустриальный стандарт для тестирования приложений React Native. Построенная на принципах тестирования поведения пользователя, а не деталей реализации, RNTL поощряет написание поддерживаемых тестов, которые дают вам уверенность в функциональности вашего приложения.

Это руководство охватывает комплексные стратегии тестирования, от базовых тестов компонентов до сложных интеграционных сценариев, моки нативных модулей и лучшие практики тестирования производительности.

Почему React Native Testing Library?

RNTL следует руководящему принципу: “Чем больше ваши тесты напоминают то, как используется ваше программное обеспечение, тем больше уверенности они могут вам дать.”

Ключевые Преимущества

  • User-Centric Testing: Тестируйте компоненты так, как пользователи взаимодействуют с ними
  • Независимость от Реализации: Рефакторьте компоненты без нарушения тестов
  • Async Утилиты: Встроенные помощники для асинхронных операций
  • Фокус на Доступности: Поощряет дизайн доступных компонентов
  • Интеграция с Jest: Бесшовная интеграция с экосистемой Jest

Сравнение с Другими Подходами к Тестированию

ПодходФокусОбслуживаниеДоверие Пользователя
Enzyme (устарел)Детали реализацииВысокоеНизкое
React Native Testing LibraryПоведение пользователяНизкоеВысокое
Detox (E2E)Полные процессы appСреднееОчень Высокое

Настройка Окружения

Установка

npm install --save-dev @testing-library/react-native
npm install --save-dev @testing-library/jest-native
npm install --save-dev jest @types/jest

Конфигурация Jest

Настройте jest.config.js:

module.exports = {
  preset: 'react-native' (как обсуждается в [Detox: Grey-Box Testing for React Native Applications](/blog/detox-react-native-grey-box)),
  setupFilesAfterEnv: [
    '@testing-library/jest-native/extend-expect',
    '<rootDir>/jest.setup.js'
  ],
  transformIgnorePatterns: [
    'node_modules/(?!(react-native|@react-native|@react-navigation|react-native-.*)/)'
  ],
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.test.{js,jsx,ts,tsx}',
    '!src/**/__tests__/**'
  ],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  }
};

Создайте jest.setup.js:

import 'react-native-gesture-handler/jestSetup';

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

global.fetch = jest.fn();

beforeEach(() => {
  jest (как обсуждается в [API Testing Architecture: From Monoliths to Microservices](/blog/api-testing-architecture-microservices)).clearAllMocks();
});

Основы Тестирования Компонентов

Базовый Тест Компонента

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

interface ButtonProps {
  onPress: () => void;
  title: string;
  disabled?: boolean;
}

export const Button: React.FC<ButtonProps> = ({
  onPress,
  title,
  disabled = false
}) => (
  <TouchableOpacity
    testID="custom-button"
    onPress={onPress}
    disabled={disabled}
    style={[styles.button, disabled && styles.disabled]}
  >
    <Text style={styles.text}>{title}</Text>
  </TouchableOpacity>
);

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#007AFF',
    padding: 12,
    borderRadius: 8,
  },
  disabled: {
    backgroundColor: '#CCC',
  },
  text: {
    color: 'white',
    fontSize: 16,
  },
});

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

describe('Компонент Button', () => {
  it('корректно рендерится с заголовком', () => {
    const { getByText } = render(
      <Button onPress={() => {}} title="Нажми Меня" />
    );

    expect(getByText('Нажми Меня')).toBeTruthy();
  });

  it('вызывает onPress при нажатии', () => {
    const onPressMock = jest (как обсуждается в [GraphQL Testing: Complete Guide with Examples](/blog/graphql-testing-guide)).fn();
    const { getByTestId } = render(
      <Button onPress={onPressMock} title="Нажать" />
    );

    fireEvent.press(getByTestId('custom-button'));
    expect(onPressMock).toHaveBeenCalledTimes(1);
  });

  it('не вызывает onPress когда отключена', () => {
    const onPressMock = jest.fn();
    const { getByTestId } = render(
      <Button onPress={onPressMock} title="Нажать" disabled />
    );

    fireEvent.press(getByTestId('custom-button'));
    expect(onPressMock).not.toHaveBeenCalled();
  });
});

Тестирование Форм и Ввода

// src/components/LoginForm.tsx
import React, { useState } from 'react';
import { View, TextInput, Text } from 'react-native';
import { Button } from './Button';

interface LoginFormProps {
  onSubmit: (email: string, password: string) => void;
}

export const LoginForm: React.FC<LoginFormProps> = ({ onSubmit }) => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = () => {
    if (!email || !password) {
      setError('Email и пароль обязательны');
      return;
    }

    if (!email.includes('@')) {
      setError('Неверный формат email');
      return;
    }

    setError('');
    onSubmit(email, password);
  };

  return (
    <View testID="login-form">
      <TextInput
        testID="email-input"
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />
      <TextInput
        testID="password-input"
        placeholder="Пароль"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      {error ? <Text testID="error-message">{error}</Text> : null}
      <Button onPress={handleSubmit} title="Войти" />
    </View>
  );
};

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

describe('LoginForm', () => {
  it('отправляет форму с валидными учетными данными', () => {
    const onSubmitMock = jest.fn();
    const { getByTestId } = render(
      <LoginForm onSubmit={onSubmitMock} />
    );

    fireEvent.changeText(
      getByTestId('email-input'),
      'user@example.com'
    );
    fireEvent.changeText(
      getByTestId('password-input'),
      'password123'
    );
    fireEvent.press(getByTestId('custom-button'));

    expect(onSubmitMock).toHaveBeenCalledWith(
      'user@example.com',
      'password123'
    );
  });

  it('показывает ошибку для пустых полей', () => {
    const { getByTestId, getByText } = render(
      <LoginForm onSubmit={() => {}} />
    );

    fireEvent.press(getByTestId('custom-button'));

    expect(
      getByText('Email и пароль обязательны')
    ).toBeTruthy();
  });

  it('показывает ошибку для неверного формата email', () => {
    const { getByTestId, getByText } = render(
      <LoginForm onSubmit={() => {}} />
    );

    fireEvent.changeText(getByTestId('email-input'), 'неверный');
    fireEvent.changeText(getByTestId('password-input'), 'pass');
    fireEvent.press(getByTestId('custom-button'));

    expect(getByText('Неверный формат email')).toBeTruthy();
  });
});

Асинхронное Тестирование

Тестирование API Вызовов

// src/services/api.ts
export const fetchUserProfile = async (userId: string) => {
  const response = await fetch(
    `https://api.example.com/users/${userId}`
  );
  if (!response.ok) {
    throw new Error('Ошибка получения пользователя');
  }
  return response.json();
};

// src/components/UserProfile.tsx
import React, { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
import { fetchUserProfile } from '../services/api';

interface User {
  id: string;
  name: string;
  email: string;
}

export const UserProfile: React.FC<{ userId: string }> = ({
  userId
}) => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');

  useEffect(() => {
    const loadUser = async () => {
      try {
        const data = await fetchUserProfile(userId);
        setUser(data);
      } catch (err) {
        setError('Ошибка загрузки пользователя');
      } finally {
        setLoading(false);
      }
    };

    loadUser();
  }, [userId]);

  if (loading) {
    return <ActivityIndicator testID="loading" />;
  }

  if (error) {
    return <Text testID="error">{error}</Text>;
  }

  return (
    <View testID="user-profile">
      <Text testID="user-name">{user?.name}</Text>
      <Text testID="user-email">{user?.email}</Text>
    </View>
  );
};

// src/components/__tests__/UserProfile.test.tsx
import React from 'react';
import {
  render,
  waitFor,
  screen
} from '@testing-library/react-native';
import { UserProfile } from '../UserProfile';
import * as api from '../../services/api';

jest.mock('../../services/api');

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

  it('показывает состояние загрузки изначально', () => {
    (api.fetchUserProfile as jest.Mock).mockImplementation(
      () => new Promise(() => {}) // Никогда не разрешается
    );

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

    expect(getByTestId('loading')).toBeTruthy();
  });

  it('показывает данные пользователя после успешного fetch', async () => {
    const mockUser = {
      id: '123',
      name: 'Иван Иванов',
      email: 'ivan@example.com'
    };

    (api.fetchUserProfile as jest.Mock).mockResolvedValue(
      mockUser
    );

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

    await waitFor(() => {
      expect(getByTestId('user-name')).toHaveTextContent(
        'Иван Иванов'
      );
    });

    expect(getByTestId('user-email')).toHaveTextContent(
      'ivan@example.com'
    );
  });

  it('показывает сообщение об ошибке при сбое fetch', async () => {
    (api.fetchUserProfile as jest.Mock).mockRejectedValue(
      new Error('Ошибка сети')
    );

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

    await waitFor(() => {
      expect(getByTestId('error')).toHaveTextContent(
        'Ошибка загрузки пользователя'
      );
    });
  });
});

Моки Нативных Модулей

Моки API React Native

// jest.setup.js дополнения
jest.mock('react-native/Libraries/Alert/Alert', () => ({
  alert: jest.fn(),
}));

jest.mock('@react-native-async-storage/async-storage', () => ({
  setItem: jest.fn(() => Promise.resolve()),
  getItem: jest.fn(() => Promise.resolve(null)),
  removeItem: jest.fn(() => Promise.resolve()),
  clear: jest.fn(() => Promise.resolve()),
}));

// Пример теста использующего мок AsyncStorage
import AsyncStorage from '@react-native-async-storage/async-storage';
import { saveUserToken } from '../storage';

it('сохраняет токен в AsyncStorage', async () => {
  await saveUserToken('abc123');

  expect(AsyncStorage.setItem).toHaveBeenCalledWith(
    'userToken',
    'abc123'
  );
});

Лучшие Практики

Приоритет Запросов

Следуйте этому приоритету при выборе элементов:

  1. getByRole / getByLabelText: Доступно для всех пользователей
  2. getByPlaceholderText: Для элементов формы
  3. getByText: Для неинтерактивных элементов
  4. getByTestId: Последнее средство для деталей реализации

Цели Покрытия

МетрикаЦельПриоритет
Statements80%+Высокий
Branches75%+Высокий
Functions80%+Средний
Lines80%+Средний

Непрерывная Интеграция

Пример GitHub Actions

name: React Native Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Настроить Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Установить зависимости
        run: npm ci

      - name: Запустить тесты
        run: npm test -- --coverage --watchAll=false

      - name: Загрузить покрытие в Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

Заключение

React Native Testing Library предоставляет надежную основу для тестирования мобильных приложений с user-centric подходом. Фокусируясь на том, как пользователи взаимодействуют с вашим приложением, а не на деталях реализации, вы строите набор тестов, устойчивый к рефакторингу и обеспечивающий подлинную уверенность в поведении вашего приложения.

Ключевые Выводы:

  • Приоритизируйте запросы на основе доступности (role, label) над testID
  • Используйте waitFor для async операций и обновлений состояния
  • Мокайте нативные модули и внешние зависимости соответствующим образом
  • Поддерживайте высокое покрытие тестами избегая чрезмерного тестирования
  • Интегрируйте тестирование в CI/CD пайплайны для непрерывного обеспечения качества

Начните с тестирования критических пользовательских процессов и постепенно расширяйте покрытие для достижения комплексной защиты тестами.