Полный гайд по тестированию на Flutter. Часть 3: Mocking и Stubbing

Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead агентства продуктовой разработки Amiga. В предыдущих статьях мы научились писать модульные тесты для статичных функций, верхнеуровневых функций и расширений. Сегодня перевод статьи посвящен Unit-тестам для методов класса.

Полный гайд по тестированию на Flutter. Часть 3: Mocking и Stubbing

Больше про кроссплатформенную разработку в телеграмм-канале Flutter.Много. Мы с командой мобильных разработчиков Amiga рассказываем о личном опыте, делимся полезными плагинами\библиотеками, переводами статей и кейсами. Присоединяйтесь!

Полный гайд по тестированию на Flutter. Часть 3: Mocking и Stubbing

Написание Unit-тестов для методов класса

Будем использовать пример из прошлых частей, но вместо функции создадим класс LoginViewModel.

import 'package:shared_preferences/shared_preferences.dart'; class LoginViewModel { bool login(String email, String password) { return Validator.validateEmail(email) && Validator.validatePassword(password); } }

Проверим всего 2 тест кейса, например:

group('login', () { test('login should return false when the email and password are invalid', () { final loginViewModel = LoginViewModel(); final result = loginViewModel.login('', ''); expect(result, false); }); test('login should return true when the email and password are valid', () { final loginViewModel = LoginViewModel(); final result = loginViewModel.login('ntminh@gmail.com', 'password123'); expect(result, true); }); });

В данный момент нет никаких отличий от прошлых частей. Теперь добавим объект SharedPreferences в LoginViewModel и обновим логику функции login.

import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; class LoginViewModel { final SharedPreferences sharedPreferences; LoginViewModel({ required this.sharedPreferences, }); bool login(String email, String password) { final storedPassword = sharedPreferences.getString(email); return password == storedPassword; } Future<bool> logout() async { bool success = false; try { success = await sharedPreferences.clear(); } catch (e) { success = false; } if (!success) { throw FlutterError('Logout failed'); } return success; } }

Как можно заметить, вывод функции login зависит от вывода функции sharedPreferences.getString(email). Поэтому в зависимости от возвращенного результата функции sharedPreferences.getString(email), будут следующие тест кейсы:

  • Функция sharedPreferences.getString(email) возвращает storedPassword, который отличается от password, переданного в функцию login.
  • Функция sharedPreferences.getString(email) возвращает storedPassword, который совпадает с password, переданным в функцию login.

Для контроля результата функции sharedPreferences.getString(email) необходимо использовать Mocking и Stubbing.

Mocking и Stubbing

Mocking — создание фейкового объекта, который заменяет реальный объект. Mock-объекты часто используются для подмены зависимостей объекта, который нужно протестировать.Кроме того, можно контролировать результат, который возвращают методы Mock-объекта. Эта техника называется Stubbing (заглушки). Например, подменим объект ApiClient и поставим заглушку на его методы get, post, put и delete, чтобы они возвращали фейковые данные вместо выполнения реальных запросов.

В нашем примере нужно подменить объект SharedPreferences, чтобы избежать вызова функций clear или getString в реальности. И что важно — это поможет симулировать результат выполнения функции getString. Таким образом, будет несколько тестовых сценариев для функции login.

Существует 2 популярные библиотеки, которые позволяют использовать техники Mocking и Stubbing: mocktail и mockito. В этой серии статей используется mocktail.

Для начала, добавим пакет mocktail в dev_dependencies.

dev_dependencies: mocktail: 1.0.3

Далее создадим класс с названием MockSharedPreferences, который расширяет класс Mock и реализует класс SharedPreferences.

class MockSharedPreferences extends Mock implements SharedPreferences {}

Теперь создадим Mock-объект внутри функции main.

final mockSharedPreferences = MockSharedPreferences();

После этого имитируем mockSharedPreferences, чтобы он возвращал фейковый пароль 123456, используя технику stubbing.

// Stubbing when(() => mockSharedPreferences.getString(email)).thenReturn('123456');

Наконец, протестируем случай, когда пользователь вводит неверный пароль, при помощи имитирования функции sharedPreferences.getString(email). Она возвращает storedPassword, который отличается от password, переданного в функцию login.

test('login should return false when the password are incorrect', () { // Arrange final mockSharedPreferences = MockSharedPreferences(); // create mock object final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences); String email = 'ntminh@gmail.com'; String password = 'abc'; // incorrect password // Stubbing when(() => mockSharedPreferences.getString(email)).thenReturn('123456'); // Act final result = loginViewModel.login(email, password); // Assert expect(result, false); });

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

test('login should return false when the password are correct', () { // Arrange final mockSharedPreferences = MockSharedPreferences(); // create mock object final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences); String email = 'ntminh@gmail.com'; String password = '123456'; // correct password // Stubbing when(() => mockSharedPreferences.getString(email)).thenReturn('123456'); // Act final result = loginViewModel.login(email, password); // Assert expect(result, true); });

Полный исходный код можно найти по ссылке.

Mocktail предлагает 3 способа выполнить stubbing:

– when(() => functionCall()).thenReturn(T expected) используется, когда functionCall — это не асинхронная функция, как в примере выше.

– when(() => functionCall()).thenAnswer(Answer<T> answer) используется, когда functionCall — это асинхронная функция. Например, для подмены функции clear, нужно сделать следующее:

when(() => mockSharedPreferences.clear()).thenAnswer((_) => Future.value(true));

– when(() => functionCall()).thenThrow(Object throwable) используется, когда нужно, чтобы functionCall бросило исключение. Например:

when(() => mockSharedPreferences.clear()).thenThrow(Exception('Clear failed'));

Теперь используем подменные методы для проверки функции logout в 3 тестовых сценариях.

group('logout', () { test('logout should return true when the clear method returns true', () async { // Arrange final mockSharedPreferences = MockSharedPreferences(); final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences); // Stubbing when(() => mockSharedPreferences.clear()).thenAnswer((_) => Future.value(true)); // Act final result = await loginViewModel.logout(); // Assert expect(result, true); }); test('logout should throw an exception when the clear method returns false', () async { // Arrange final mockSharedPreferences = MockSharedPreferences(); final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences); // Stubbing when(() => mockSharedPreferences.clear()).thenAnswer((_) => Future.value(false)); // Act final call = loginViewModel.logout; // Assert expect(call, throwsFlutterError); }); test('logout should throw an exception when the clear method throws an exception', () async { // Arrange final mockSharedPreferences = MockSharedPreferences(); final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences); // Stubbing when(() => mockSharedPreferences.clear()).thenThrow(Exception('Logout failed')); // Act final Future<bool> Function() call = loginViewModel.logout; // Assert expect( call, throwsA(isA<FlutterError>().having((e) => e.message, 'error message', 'Logout failed')), ); }); });

Небольшие изменения в коде, представленном выше:

– Когда ожидаем, что функция выкинет ошибку вместо результата, то не можем вызывать метод logout на шаге Act. Его вызов породит некоторые ошибки, которые перенесутся в функцию тестирования, и это вызовет провал теста. Можем только создать переменную с функцией:

final Future<bool> Function() call = loginViewModel.logout;

– Когда ожидаем, что функция выкинет ошибку вместо результата, можем использовать доступные для этого Matcher’ы: throwsArgumentError, throwsException и т.д. На примере выше ожидаем, что будет выброшена ошибка FlutterError, поэтому используем expect(call, throwsFlutterError).

Полный гайд по тестированию на Flutter. Часть 3: Mocking и Stubbing

– Когда нужно подтвердить более конкретно и подробно. Например, ожидания появления ошибки должно быть FlutterError и его message должен быть “Logout failed”. Тогда нужно использовать 2 Matcher’а: throwsA и isA.

expect( call, throwsA(isA<FlutterError>().having((e) => e.message, 'error message', 'Logout failed')), );

– Matcher throwsA() позволяет проверить выбрасывается ли какая-либо ошибка, включая кастомные классы исключений. На самом деле, throwsFlutterError — это эквивалент throwsA(isA FlutterError()).

– Matcher isA() позволяет проверить тип результата без привязки к определенному значению. Например, когда хотим, чтобы тест вернул либо true, либо false, так как это тип bool, можно использовать expect(result, isA()). Он часто используется с методом having для проведения более детальных проверок за пределами простого типа данных. Например, isA().having((e) => e.message, 'description: error message', 'Logout failed') — тоже самое, что требовать объект быть типа FlutterError и его свойства message равняться 'Logout failed'.

Заключение

В данной статье мы изучили техники Mocking и Stubbing вместе с несколькими часто встречающимися функциями: throwsA, isA и having. В следующей части мы еще больше усложним класс LoginViewModel при помощи создания переменной _cache для кеширования результата, полученного от SharedPreferences. При вызове функции login, мы ставим высший приоритет получению данных из кеша.

Пишите в комментариях, интересна ли вам данная тема? И подписывайтесь на Flutter. Много!

22
Начать дискуссию