-->

Полный гайд по тестированию на Flutter. Часть 4: продвинутое модульное тестирование

Полный гайд по тестированию на Flutter. Часть 4: продвинутое модульное тестирование

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

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

import 'package:shared_preferences/shared_preferences.dart';


class LoginViewModel {

  final SharedPreferences sharedPreferences;


  LoginViewModel({

    required this.sharedPreferences,

  });


  final Map<String, String?> _cache = {};


  bool login(String email, String password) {

    if (_cache.containsKey(email)) {

      return password == _cache[email];

    }


    final storedPassword = sharedPreferences.getString(email);

    _cache[email] = storedPassword;


    return password == storedPassword;

  }

}

В коде выше содержится ошибка: переменная _cache является приватной, поэтому ее нельзя подменить. Из-за этого остается только один тестовый сценарий – когда _cache пуст. Как можно добавить больше значений к _cache для проверки разных сценариев, сохраняя переменную приватной? Для этого понадобится аннотация @visibleForTesting.

(Аннотация @visibleForTesting)

final Map<String, String?> _cache = {};


// Expose this method for testing purposes to set values in _cache

@visibleForTesting

void putToCache(String email, String? password) {

  _cache[email] = password;

}

Когда функция помечена аннотацией, подразумевается, что ее следует использовать только в файлах с тестами и внутри файла, содержащего эту функцию. Именно таким образом она остается приватной.

Теперь, напишем тесты для 2 следующих тестовых сценариев:

group('login', () {

  test('login should return true when the cache contains the password input even when the password is incorrect', () {

    // Arrange

    final mockSharedPreferences = MockSharedPreferences();

    final loginViewModel = LoginViewModel(

      sharedPreferences: mockSharedPreferences,

    );

    String email = 'ntminh@gmail.com';

    String password = 'abc';


    loginViewModel.putToCache(email, 'abc'); // NEW


    // Stubbing

    when(() => mockSharedPreferences.getString(email))

        .thenReturn('123456');


    // Act

    final result = loginViewModel.login(email, password);


    // Assert

    expect(result, true);

  });


  test('login should return false when the cache does not contain the password input and the password is incorrect', () {

    // Arrange

    final mockSharedPreferences = MockSharedPreferences();

    final loginViewModel = LoginViewModel(

      sharedPreferences: mockSharedPreferences,

    );

    String email = 'ntminh@gmail.com';

    String password = 'abc';


    // Stubbing

    when(() => mockSharedPreferences.getString(email))

        .thenReturn('123456');


    // Act

    final result = loginViewModel.login(email, password);


    // Assert

    expect(result, false);

  });

});

Тест кейсы, связанные с переменной _cache, проверены. Далее попробуем отрефакторить код, чтобы избежать дублирования. Для этого вынесем инициализацию mockSharedPreferences и loginViewModel за пределы тестовых функций.

Когда прогоним тест снова, второй тест упадет с ошибкой.

Почему актуальный результат получился true вместо false?

Когда шарится объект loginViewModel, также шарится и переменная _cache. В первом тест кейсе значение кладется в _cache через loginViewModel.putToCache(email, 'abc');, поэтому когда переходим ко второму тестовому кейсу, _cache уже содержит значение 'abc'. Таким образом, _cache содержит входные данные пароля и возвращает true.

Чтобы исправить баг, нужно удостовериться, что каждый раз, когда прогоняется новый тест, создается новый объект loginViewModel. Это можно сделать, используя функцию setUp.

void main() {

  late MockSharedPreferences mockSharedPreferences;

  late LoginViewModel loginViewModel;


  setUp(() {

    mockSharedPreferences = MockSharedPreferences();

    loginViewModel = LoginViewModel(

      sharedPreferences: mockSharedPreferences,

    );

  });


  ...

}

Полный исходный код

Прогнав тест еще раз, видно, что все тесты прошли.

(Функции setUp, tearDown, setUpAll, tearDownAll)
  • setUp

Функция setUp вызывается перед запуском каждого теста, поэтому она обычно используется для инициализации объектов для теста и конфигурации начальных значений. В примере выше порядок вызова функций выглядит так:

setUp (initialization) -> test case 1 -> setUp (re-initialization) -> test case 2

Таким образом, loginViewModel перед запуском второго тест кейса инициализируется заново, поэтому баг был пофикшен.

  • tearDown

Функция tearDown вызывается после завершения каждого теста. Обычно ее используют для задач по очистке, таких как освобождение памяти, закрыть какие-либо ресурсы или закрыть соединение с базой данных.

  • setUpAll

Функция setUpAll вызывается всего один раз перед прогоном абсолютно всех тестов. Ее часто используют для открытия соединения с базой данных и дальнейшего использования одной базы данных для всех тестов.

setUpAll(() async {

  await Isar.initializeIsarCore(download: true);

  isar = await Isar.open([JobSchema], directory: '');

});


  • tearDownAll

Функция tearDownAll тоже выполняется всего один раз после завершения всех тестов, поэтому часто используется для закрытия доступа к базе данных.

tearDownAll(() async {

  await isar.close();

});

Далее пройдемся по нескольким примерам, чтобы понять, как применять эти 4 функции. Кроме этого, рассмотрим, как проверять Stream’ы.

(Тестирование Stream’ов)

Представим, что приложение использует базу данных Isar, и в ней есть таблица JobData.

import 'package:isar/isar.dart';

part 'job_data.g.dart';


@collection

class JobData {

  Id id = Isar.autoIncrement;

  late String title;

}

Также есть класс HomeBloc. В этом классе будем слушать данные, которые возвращает Isar.

class HomeBloc {

  HomeBloc({required this.isar}) {

    _streamSubscription = isar.jobDatas

      .where()

      .watch(fireImmediately: true)

      .listen((event) {

        _streamController.add(event);

      });

  }


  final Isar isar;

  final _streamController = 

        StreamController<List<JobData>>.broadcast();

  StreamSubscription? _streamSubscription;

  Stream<List<JobData>> get data => _streamController.stream;


  void close() {

    _streamSubscription?.cancel();

    _streamController.close();

    _streamSubscription = null;

  }

}

Теперь создадим файл для теста, чтобы проверить геттер data из класса HomeBloc.

После этого инициализируем HomeBloc в функции setUp и базу данных Isar в функции setUpAll. Обычно, если инициализируем объект в функции setUp, то он будет очищен в функции tearDown. И наоборот, если мы инициализируем объект в функции setUpAll, то он очистится только в функции tearDownAll.

void main() {

  late Isar isar;

  late HomeBloc homeBloc;


  setUp(() async {

    await isar.writeTxn(() async => isar.clear());

    homeBloc = HomeBloc(isar: isar);

  });


  tearDown(() {

    homeBloc.close();

  });


  setUpAll(() async {

    await Isar.initializeIsarCore(download: true);

    isar = await Isar.open(

      [JobDataSchema],

      directory: '',

    );

  });


  tearDownAll(() {

    isar.close();

  });

}

Наконец, напишем тесты для геттера data.

test('data should emit what Isar.watchData emits', () async {

  expectLater(

    homeBloc.data,

    emitsInOrder([

      [],

      [JobData()..title = 'IT'],

      [JobData()..title = 'IT', JobData()..title = 'Teacher'],

  ]));


  // put data to Isar

  await isar.writeTxn(() async {

    isar.jobDatas.put(JobData()..title = 'IT');

  });

  await isar.writeTxn(() async {

    isar.jobDatas.put(JobData()..title = 'Teacher');

  });

});

Здесь появляется 2 новых вещи:

  • Функция expectLater: Она отличается от функции expect, которая используется для проверки синхронных значений, а expectLater — для тестирования асинхронных значений, таких как Stream.
    Когда тестируем поток данных, нужно разместить выражение expectLater до того, как данные попадут в Stream. Таким образом можно следить за значениями, как только они попадают в поток. И не нужно использовать await перед функцией expectLater(), так как тест провалится.

  • Функция emitsInOrder - Matcher, который используется для проверки, что данные в Stream попадают в верном порядке. Если нужно проверить все события, но вне зависимости от их порядка, можно использовать Matcher emitsInAnyOrder.

(Функция resetMocktailState)

Используется для сброса Mock-объектов. Для ошибки выше можно вызвать ее в методах setUp или tearDown для исправления бага вместо инициализации Mock-объекта заново в функции setUp.

tearDown(() {

  resetMocktailState();

});

(Заключение)

Надеемся, что перевод этой статьи был для вас полезен  и вы научились писать продвинутые тесты на различные сценарии. В следующей части продолжим изучать библиотеку mocktail для написания тестов для более комплексных кейсов.

Чтобы не пропустить новый  выпуск, подписывайтесь на наш телеграм-канал Flutter. Много. Там вы найдете еще больше интересного и полезного о кроссплатформенной разработке. Присоединяйтесь!

Хотите связаться с владельцами компании напрямую?
Константин Франгуриди
Константин Франгуриди
Account director

НАПИСАТЬ

Дмитрий Тарасов
Дмитрий Тарасов
СЕО

НАПИСАТЬ