К содержимому

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

Любое приложение среднего размера необходимо тестировать.

Чтобы успешно протестировать наше приложение, необходимо следовать следующим правилам:

  • Ни одно состояние не должно быть изменено между test/testWidgets. Либо не должно быть глобального состояния приложения, либо все глобальные состояния должны сбрасываться после каждого теста.

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

Давайте рассмотрим, как Riverpod может помочь нам в этом.

Ни одно состояние не должно быть изменено между test/testWidgets.

То что провайдеры обычно объявляются как глобальные переменные может вас беспокоить. В конце концов, глобальное состояние усложняет тестирование, т. к. тогда требуется setUp/tearDown.

Но в реальности, несмотря на то, что провайдеры объявляются как глобальные переменные, состояние провайдера не является глобальным.

Состояние хранится в объекте ProviderContainer, который вы возможно уже видели, когда смотрели примеры кода только для dart. Если же вы не встречались с этим объектом ранее, запомните, что ProviderScope создает ProviderContainer.

Т. е. два testWidgets, использующих одни и те же провайдеры, не разделяют общее состояние. Поэтому нет никакой нужды в использовании setUp/tearDown.

Лучше показать это на примере:


// Счетчик, реализованный и протестированный с использованием Flutter

// Объявляем провайдер глобально.
// Используем этот провайдер в двух тестах, чтобы проверить, правильно ли
// состояние сбрасывается до нуля между тестами.

final counterProvider = StateProvider((ref) => 0);

// Отрисовывает текущее состояние и кнопку для увеличения счетчика
class MyApp extends StatelessWidget {

Widget build(BuildContext context) {
return MaterialApp(
home: Consumer(builder: (context, ref, _) {
final counter = ref.watch(counterProvider);
return ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).state++,
child: Text('$counter'),
);
}),
);
}
}

void main() {
testWidgets('update the UI when incrementing the state', (tester) async {
await tester.pumpWidget(ProviderScope(child: MyApp()));

// `0` - значение по умолчанию, как это было объявлено в провайдере
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);

// Увеличение счетчика и переотрисовка
await tester.tap(find.byType(ElevatedButton));
await tester.pump();

// Состояние счетчика действительно увеличилось
expect(find.text('1'), findsOneWidget);
expect(find.text('0'), findsNothing);
});

testWidgets('the counter state is not shared between tests', (tester) async {
await tester.pumpWidget(ProviderScope(child: MyApp()));

// Состояние счетчика снова стало равно `0`
// без использования tearDown/setUp
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
});
}

Как вы можете наблюдать, counterProvider объявлен в виде глобальной переменной, но ни одно состояние не разделяется между тестами. Таким образом, нам не нужно беспокоиться о том, что наши тесты будут вести себя по-разному в зависимости от того, в каком порядке мы их расположим. Каждый тест изолирован.

Переопределение поведения провайдера при выполнении теста.

Обычное приложение может иметь следующие объекты:

  • Класс Repository, который предоставляет простое API для осуществления HTTP запросов.

  • Объект, который управляет состояние приложения и может использовать Repository для выполнения HTTP запросов, основываясь на различных факторах. Это может быть ChangeNotifier, Bloc или же провайдер.

С Riverpod это может быть реализовано следующим образом:


class Repository {
Future<List<Todo>> fetchTodos() async => [];
}

// Храним экземпляр Repository в провайдере
final repositoryProvider = Provider((ref) => Repository());

/// Список задач. Мы просто получаем задачи с сервера,
/// используя [Repository].
final todoListProvider = FutureProvider((ref) async {
// Получение экземпляра Repository
final repository = ref.watch(repositoryProvider);

// Получение задач и передача их в UI.
return repository.fetchTodos();
});

При написании unit/widget тестов нам необходимо заменить наш Repository на тестировочного дублера, который будет возвращать предопределенный ответ вместо осуществления реального HTTP запроса.

Тогда нам нужно, чтобы todoListProvider или его эквивалент использовал мок реализацию Repository.

Для достижения нашей цели можно воспользоваться overrides параметром ProviderScope/ProviderContainer, чтобы переопределить поведение repositoryProvider:


testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// Переопределяем поведение repositoryProvider, чтобы он
// возвращал FakeRepository вместо Repository.
repositoryProvider.overrideWithValue(FakeRepository())
// Нам не нужно переопределять `todoListProvider`.
// Он автоматически будет использовать
// переопределенный repositoryProvider
],
child: MyApp(),
),
);
});

В выделенной строке вы можете увидеть, как ProviderScope/ProviderContainer позволяют переопределять поведение провайдера.

к сведению

Некоторые провайдеры предоставляют упрощенные способы переопределения поведения. Например FutureProvider позволяет изменять свое значение на AsyncValue:


final todoListProvider = FutureProvider((ref) async => <Todo>[]);
// ...
/* SKIP */
final foo =
/* SKIP END */
ProviderScope(
overrides: [
/// Можно переопределить FutureProvider, чтобы он возвращал
/// определенное значение
todoListProvider.overrideWithValue(
AsyncValue.data([Todo(id: '42', label: 'Hello', completed: true)]),
),
],
child: const MyApp(),
);

Note:В рамках версии 2.0.0 overrideWithValue методы временно исключены. Они будут возвращены в более поздних версиях.

к сведению

Синтаксис переопределения провайдера с модификатором family немного отличается.

Если вы используете провайдер таким образом:

final response = ref.watch(myProvider('12345'));

Вы можете переопределить провайдер так:

myProvider('12345').overrideWithValue(...));

Полный пример widget тестирования

В итоге мы получаем такой код:


import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';

class Repository {
Future<List<Todo>> fetchTodos() async => [];
}

class Todo {
Todo({
required this.id,
required this.label,
required this.completed,
});

final String id;
final String label;
final bool completed;
}

// Храним экземпляр Repository в провайдере
final repositoryProvider = Provider((ref) => Repository());

/// Список задач. Мы просто получаем задачи с сервера,
/// используя [Repository].
final todoListProvider = FutureProvider((ref) async {
// Получение экземпляра Repository
final repository = ref.read(repositoryProvider);

// Получение задач и передача их в UI.
return repository.fetchTodos();
});

/// Mock реализация Repository, которая возвращает предопределенный список задач
class FakeRepository implements Repository {

Future<List<Todo>> fetchTodos() async {
return [
Todo(id: '42', label: 'Hello world', completed: false),
];
}
}

class TodoItem extends StatelessWidget {
const TodoItem({super.key, required this.todo});
final Todo todo;

Widget build(BuildContext context) {
return Text(todo.label);
}
}

void main() {
testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
repositoryProvider.overrideWithValue(FakeRepository())
],
// Наше приложение, которые читает значение todoListProvider
// для отображение списка задач.
// Вы можете вынести это в отдельный MyApp виджет
child: MaterialApp(
home: Scaffold(
body: Consumer(builder: (context, ref, _) {
final todos = ref.watch(todoListProvider);
// Список задач загружается, либо случилась ошибка
if (todos.asData == null) {
return const CircularProgressIndicator();
}
return ListView(
children: [
for (final todo in todos.asData!.value) TodoItem(todo: todo)
],
);
}),
),
),
),
);

// Первый кадр - состояние загрузки
expect(find.byType(CircularProgressIndicator), findsOneWidget);

// Перерисовка. TodoListProvider уже должен получить задачи
await tester.pump();

// Загрузка закончилась
expect(find.byType(CircularProgressIndicator), findsNothing);

// Переотрисовка одного TodoItem с данными, пришедшими из FakeRepository
expect(tester.widgetList(find.byType(TodoItem)), [
isA<TodoItem>()
.having((s) => s.todo.id, 'todo.id', '42')
.having((s) => s.todo.label, 'todo.label', 'Hello world')
.having((s) => s.todo.completed, 'todo.completed', false),
]);
});
}