Введение в Тестирование 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'
);
});
Лучшие Практики
Приоритет Запросов
Следуйте этому приоритету при выборе элементов:
- getByRole / getByLabelText: Доступно для всех пользователей
- getByPlaceholderText: Для элементов формы
- getByText: Для неинтерактивных элементов
- getByTestId: Последнее средство для деталей реализации
Цели Покрытия
Метрика | Цель | Приоритет |
---|---|---|
Statements | 80%+ | Высокий |
Branches | 75%+ | Высокий |
Functions | 80%+ | Средний |
Lines | 80%+ | Средний |
Непрерывная Интеграция
Пример 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 пайплайны для непрерывного обеспечения качества
Начните с тестирования критических пользовательских процессов и постепенно расширяйте покрытие для достижения комплексной защиты тестами.