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

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

Написание 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), будут следующие тест кейсы:

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

  2. Функция 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).

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

expect(
call,
throwsA(isA<FlutterError>().having((e) => e.message, 'error message', 'Logout failed')),
);
  • Matcher throwsA<T>() позволяет проверить выбрасывается ли какая-либо ошибка, включая кастомные классы исключений. На самом деле, throwsFlutterError — это эквивалент throwsA(isA FlutterError()).

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

Заключение

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

Пишите в комментариях, интересна ли вам данная тема?

Подписывайтесь на телеграмм-канале Flutter. Много, чтобы не пропустить следующую статью!

Комментарии (0)