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

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

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

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

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

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

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

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

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

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

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

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

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


// A Counter implemented and tested using Flutter

// We declared a provider globally, and we will use it in two tests, to see
// if the state correctly resets to `0` between tests.

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

// Renders the current state and a button that allows incrementing the state
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()));

// The default value is `0`, as declared in our provider
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);

// Increment the state and re-render
await tester.tap(find.byType(ElevatedButton));
await tester.pump();

// The state have properly incremented
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()));

// The state is `0` once again, with no tearDown/setUp needed
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 => [];
}

// We expose our instance of Repository in a provider
final repositoryProvider = Provider((ref) => Repository());

/// The list of todos. Here, we are simply fetching them from the server using
/// [Repository] and doing nothing else.
final todoListProvider = FutureProvider((ref) async {
// Obtains the Repository instance
final repository = ref.watch(repositoryProvider);

// Fetch the todos and expose them to the UI.
return repository.fetchTodos();
});

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

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

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


testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// Override the behavior of repositoryProvider to return
// FakeRepository instead of Repository.
repositoryProvider.overrideWithValue(FakeRepository())
// We do not have to override `todoListProvider`, it will automatically
// use the overridden repositoryProvider
],
child: MyApp(),
),
);
});

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

к сведению

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


final todoListProvider = FutureProvider((ref) async => <Todo>[]);
// ...
/* SKIP */
final foo =
/* SKIP END */
ProviderScope(
overrides: [
/// Allows overriding a FutureProvider to return a fixed value
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;
}

// We expose our instance of Repository in a provider
final repositoryProvider = Provider((ref) => Repository());

/// The list of todos. Here, we are simply fetching them from the server using
/// [Repository] and doing nothing else.
final todoListProvider = FutureProvider((ref) async {
// Obtains the Repository instance
final repository = ref.read(repositoryProvider);

// Fetch the todos and expose them to the UI.
return repository.fetchTodos();
});

/// A mocked implementation of Repository that returns a pre-defined list of todos
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())
],
// Our application, which will read from todoListProvider to display the todo-list.
// You may extract this into a MyApp widget
child: MaterialApp(
home: Scaffold(
body: Consumer(builder: (context, ref, _) {
final todos = ref.watch(todoListProvider);
// The list of todos is loading or in error
if (todos.asData == null) {
return const CircularProgressIndicator();
}
return ListView(
children: [
for (final todo in todos.asData!.value) TodoItem(todo: todo)
],
);
}),
),
),
),
);

// The first frame is a loading state.
expect(find.byType(CircularProgressIndicator), findsOneWidget);

// Re-render. TodoListProvider should have finished fetching the todos by now
await tester.pump();

// No longer loading
expect(find.byType(CircularProgressIndicator), findsNothing);

// Rendered one TodoItem with the data returned by 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),
]);
});
}