Ваш проект стал совсем взрослым и для поддержания уровня надёжности его работы вы решаете писать юнит-тесты.
Давайте обсудим на примере, как можно их писать так, чтобы и удовольствие получить, и принести проекту пользу.
Мотивация — для чего нам нужны тесты?
Представьте, что:
-
Вы написали код. Как теперь защитить логику вашего кода от тех изменений, которые вы же сами можете внести через полгода?
А через год другие разработчики внесут в него правки. А через другой ещё, и так далее. Код часто меняется, и есть противоречивые требования к правкам. Как защитить его от потери первоначального смысла, который в него закладывался?
-
Вы написали код, на code review к нему возникли вопросы. Код получился не самым понятным. Такое случается, когда сама предметная область не самая простая и очевидная.
Как защитить это сакральное знание в случае, если ваш коллега может неверно понять логику класса и внести в него правки?
Решение — защитить код, покрыв его юнит-тестами.
-
Это способ проверить работу нового функционала, который вы пишете. И убедиться в том, что он не ломает уже существующую функциональность.
-
Стоимость таких проверок может быть гораздо ниже, чем их проверка вручную или автотестами. Особенно, если речь про функционал без интерфейса — работа с сетью, конвертация данных, вычисления, бизнес-правила и прочее.
-
В процессе развития программного продукта код периодически нужно рефакторить. В этом случае юнит-тесты позволяют проводить рефакторинг без опасений, что существующая функциональность будет сломана. Кроме того, юнит-тесты позволяют облегчить обнаружение ошибок и быстрее локализовать их поиск.
-
Другое полезное свойство юнит-тестов в том, что с их помощью можно понять, как работают определенные части системы и как их нужно использовать. Это документация, которая живет и меняется вместе с кодом.
Получается, что хороший тест — это документация к коду. А если учесть, что хороший код в комментариях не нуждается, зачастую юнит-тесты — это единственный способ понять, как он работает.
Что такое юнит-тест?
Если вы совсем не знакомы с понятием юнит-тестов, то можете начать с официальной документации:
Если коротко, то под юнитом подразумевают минимальную часть кода, обладающую своей логикой. И хотя для Dart это функция или метод, юнитом будет именно класс, так как, скорее всего, вы придерживаетесь принципов ООП.
Юнит-тест — это набор правил, которым должна соответствовать функция, метод или класс. Запуская тесты, мы сравниваем наш код с этим эталоном и оцениваем результат.
Структура тестов как часть документации
В самом простейшем случае для того, чтобы написать юнит тест, вам достаточно:
-
Подключить пакет
flutter_test
в разделеdev dependencies
файлаpubspec.yaml
. -
Создать файл с точкой входа (по умолчанию это функция
main()
). -
Вызвать функцию
test()
:
test(‘Описание вашего теста‘, () {
// логика вашего теста
});
В общем случае, когда вы пишете тесты, их структура может выглядеть так:
void main() {
setUpAll(() {
print('Данный метод будет выполнен только однажды перед запуском всех последующих тестов');
});
setUp(() {
print('Данный метод будет выполняться каждый раз перед новым тестом');
});
group('В некотором классе', () {
test('метод должен выполнить что-то', () {});
test('некоторый метод должен выполнить что-то', () {});
test('другой метод должен сделать то-то', () {});
group('группа тестов', (){
// ...
});
});
tearDown(() {
print('Данный метод будет выполнен только однажды после работы каждого теста');
});
tearDownAll(() {
print('Данный метод будет выполняться каждый раз после выполнения всех тестов');
});
}
Функция group()
используется для логической группировки конкретных тестов.
Также одна группа может включать в себя другие группы — если у вас много тестов в пределах одного dart-файла. Это хороший маркер — он подсказывает, что класс стоит декомпозировать.
С помощью group()
при запуске команда flutter test
будет формировать текстовый вывод, соединяя описание группы и вложенных тестов. И всё вместе даст представление о логике юнита.
Например, для сниппета выше вы получите следующий вывод:
**В некотором классе** метод должен выполнить что-то
**В некотором классе** некоторый метод должен выполнить что-то
**В некотором классе** другой метод должен сделать то-то
Если придерживаться правил и структуры (мы расскажем о них дальше), то тесты можно рассматривать как часть технической документации. Из неё можно будет понять, за что отвечает публичный контракт вашего класса.
Где разместить файлы с тестами внутри проекта
Автор теста сам решает, где и как удобнее разместить файлы с тестами/моками/стабами, но можно придерживаться следующих рекомендаций:
-
Файл с тестами на юнит (
*_test.dart
) стоит разместить в папкеtest
на том же уровне вложенности, что и юнит-класс.Например, тестируемый класс находится по пути
lib/src/some_feature/some_service.dart
, то и сам тест стоит разместить по такому же пути, но только внутри папкиtest
:test/some_feature/some_service_test.dart
.Важно указать суффикс
_test
в имени файла — только в таком случае запуск командыflutter test
без указания конкретного имени файла поймёт, что это dart-файл с тестом, и отправит его на выполнение. -
Вспомогательные mock-классы (классы, которые симулируют поведение других классов — об этом ниже) размещаются рядом, если они нужны только для конкретного теста. Если mock-классы используются разными тестами, то разместите mock-класс на том же уровне вложенности, что и класс, на который сделан mock.
Задайте вопросы к вашему классу
Один из способов написать хороший тест — это посмотреть на публичный контракт вашего класса и задать вопросы, что делает / за что отвечает тот или иной член класса.
Посмотрите на такой класс:
abstract class UserRepository {
UserModel getUserById(int id);
void removeUserById(int id);
UserModel createUser(String name);
UserModel updateUserName(int id, String name);
}
Для этого класса и первых двух методов можно задать следующие утверждения:
-
Метод
getUserById
классаUserRepository
должен вернуть корректную модельUser
, если указанid
существующего пользователя. -
Метод
removeUserById
классаUserRepository
должен успешно отработать, если указанid
существующего пользователя.
Задавая утверждения, можно придерживаться следующей нотации:
-
«Юнит должен / не должен выполнять что-то при заданных условиях».
-
Или «При заданных условиях должно / не должно быть ожидаемое состояние».
Важно определить какой-то общий подход и следовать ему.
Представьте, что есть следующая конкретная реализация данного класса с методами getUserById
и removeUserById
, которые могли бы выглядеть так:
@override
UserModel getUserById(int id) {
if (_ifExists(id)) {
return UserModel(id: id, name: 'John Smith');
}
throw Exception('User does not exist');
}
@override
void removeUserById(int id) {
if (_ifExists(id)) {
return;
}
throw Exception('User does not exist');
}
Вы заметите, что при определённых условиях эти методы бросают исключения. В таком случае вы можете задать следующие негативные или исключительные утверждения:
-
Метод
getUserById
классаUserRepository
должен бросить исключение, если указанid
несуществующего пользователя. -
Метод
removeUserById
классаUserRepository
должен бросить исключение, если указанid
несуществующего пользователя.
Рассмотрим метод createUser
:
@override
UserModel createUser(String name) {
if (name.isEmpty) {
throw Exception('Name should be specified');
}
return UserModel(id: newId(), name: name);
}
О нём можно сделать такой вывод:
- Метод
createUser
классаUserRepository
должен корректно создать пользователя и вернуть модель пользователя с заданным именем иid > 0
.
Также есть исключительный сценарий, если не указать имя пользователя:
- Метод
createUser
классаUserRepository
должен бросить исключение, если имя пользователя пустое.
Рассмотрим метод updateUserName
:
@override
UserModel updateUserName(int id, String name) {
if (_ifExists(id) && name.isNotEmpty) {
return UserModel(id: id, name: name);
}
Для него также есть два сценария — положительный и негативный/исключительный:
-
Метод
updateUserName
классаUserRepository
должен вернуть обновлённую модель пользователя. -
Метод
updateUserName
классаUserRepository
должен бросить исключение, если указанid
несуществующего пользователя.
Полный список утверждений вы можете записать, сгруппировав их, например, по имени класса/метода:
-
Метод
getUserById
:-
должен вернуть корректную модель
User
, если указанid
существующего пользователя; -
должен бросить исключение, если указан
id
несуществующего пользователя.
-
-
Метод
removeUserById
:-
должен успешно отработать, если указан
id
существующего пользователя; -
должен бросить исключение, если указан
id
несуществующего пользователя.
-
-
Метод
createUser
:-
должен корректно создать пользователя и вернуть модель пользователя с заданным именем и
id > 0
; -
должен бросить исключение, если имя пользователя пустое.
-
-
Метод
updateUserName
:-
должен вернуть обновлённую модель пользователя;
-
должен бросить исключение, если указан
id
несуществующего пользователя.
-
Обратите внимание, что появилась группа
«В классе UserRepository
», и несколько вложенных групп («Метод getUserById
», «Метод removeUserById
» и так далее) — каждая со своими утверждениями.
Все эти утверждения можно перенести в код, используя функции group()
. Группы можно вкладывать друг в друга. А функции test()
будут содержать сами утверждения («должен», «может», «не должен», «не может» и так далее).
В результате вы получите следующее:
group('В классе UserRepository', () {
group('Метод getUserById', () {
test('должен вернуть корректную модель User, если указан id существующего пользователя', () {});
test('должен бросить исключение, если указан id несуществующего пользователя', () {});
});
group('Метод removeUserById', () {
test('должен успешно отработать, если указан id существующего пользователя', () {});
test('должен бросить исключение, если указан id несуществующего пользователя', () {});
});
group('Метод createUser', () {
test('должен корректно создать пользователя и вернуть модель пользователя с заданным именем и id > 0', () {});
test('должен бросить исключение, если имя пользователя пустое', () {});
});
group('Метод updateUserName', () {
test('должен вернуть обновлённую модель пользователя', () {});
test('должен бросить исключение, если указан id несуществующего пользователя', () {});
});
});
Вы можете использовать язык, который вам удобнее, придав выразительности текстам утверждений ваших тестов.
Например, всё то же самое можно написать, используя английский. Используйте тот, который принят в вашей компании и который лучше отражает смысл теста.
group('UserRepository', () {
group('getUserById method', () {
test('should return correct UserModel for existing user specified by Id', () {});
test('should throw exception if user does not exist', () {});
});
group('removeUserById method', () {
test('should remove correctly an existing user specified by Id', () {});
test('should throw exception if user does not exist', () {});
});
group('createUser method', () {
test('should create new User if all conditions are correct', () {});
test('should throw exception if user name is empty', () {});
});
group('updateUserName method', () {
test('should return updated User model', () {});
test('should throw exception if user does not exist', () {});
});
});
Запустив такой тест (не написав ещё ни строчки логики) на выполнение fvm flutter test <путь к вашему файлу с тестом>.dart
, получим следующий вывод:
UserRepository getUserById method should return correct UserModel for existing user specified by Id
UserRepository getUserById method should throw exception if user does not exist
UserRepository removeUserById method should remove correctly an existing user specified by Id
UserRepository removeUserById method should throw exception if user does not exist
UserRepository createUser method should create new User if all conditions are correct
UserRepository createUser method should throw exception if user name is empty
UserRepository updateUserName method should return updated User model
UserRepository updateUserName method should throw exception if user does not exist
All tests passed!
Просто запустив тесты, оформленные в таком виде, вы уже можете судить о логике работы класса, на который были написаны тесты.
Чем больше таких утверждений будет, тем больше представления о том, как работает класс, будет у вас и ваших коллег. А если вы столкнётесь с проблемой и получите в логах CI/CD такой вывод, вам проще будет судить о том, что было затронуто, — класс/метод/функция.
Совет: лучше делать тесты как можно более атомарными на каждый из аспектов поведения.
Дальше давайте напишем логику такого теста.
Логика самого теста и проверка утверждений
Давайте напишем тест на положительный сценарий для метода getUserById
.
Такой тест может выглядеть следующим образом:
test('должен вернуть корректную модель User, если указан id существующего пользователя',
() {
const expectedId = 1;
final actualUser = userRepository.getUserById(expectedId);
expect(actualUser.id, expectedId);
expect(actualUser.name, 'John Smith');
});
Тут вы вызываете метод getUserById
со значением expectedId
, которое дальше сможете проверить в выражении expect()
.
Выражение expect()
используется для проверки утверждений. И его сигнатура выглядит так:
void expect(
dynamic actual,
dynamic matcher,
)
Здесь вы сравниваете текущее/актуальное значение (actual
) с ожидаемым значением (matcher
).
Можно улучшить читаемость кода и применять следующий naming convention
— давать префиксы actual
(значение, которое хотим проверить) и expected
(ожидаемое) для переменных, значения которых получаем, и тех, с которыми будем выполнять сравнение.
Как выше в примере для проверки полученного из репозитория пользователя:
test('должен вернуть корректную модель User, если указан id существующего пользователя',
() {
const expectedId = 1;
final actualUser = userRepository.getUserById(expectedId);
expect(actualUser.id, expectedId);
Matcher
в общем случае может быть любым значением, с которым вы сравниваете actual
значение. Если утверждение верное, то тест будет успешным.
Асинхронная проверка утверждений
Выражение expect
используется для синхронной проверки утверждений.
В сценариях, когда у вас асинхронный код, для проверки таких утверждений логику теста вы можете отметить как async
. Например, для следующего метода вы можете написать такое утверждение:
group('метод findByName', () {
test('должен вернуть корректные результаты поиска', () async {
const expectedName = 'Leo';
final actualResults = await userRepository.findByName(expectedName);
expect(actualResults, isNotEmpty);
expect(actualResults.first.name, expectedName);
});
В пакете flutter_test
есть еще одно выражение — expectLater()
.
expectLater
можно использовать для проверки некоторых асинхронных операций, но только с использованием тех матчеров, которые наследуют AsyncMatcher
.
Примеры таких матчеров:
-
throwsA
— проверка на выброс исключения; -
completion
— проверка на успешное завершение Future; -
doesNotComplete
— проверка на то, что Future не вернул результат; -
stream matchers
— проверка последовательности событий вStream
.
Матчеры
Возьмём класс пользователя:
class UserModel {
final int id;
final String name;
final bool hasLicence;
}
Аналогично проверке утверждений выше для полей id и name вы могли бы проверить утверждение, что пользователь имеет лицензию, — hasLicence == true
.
И могли бы написать вот так:
expect(user.hasLicence, true);
Такой код будет корректным с точки зрения исполнения теста. Но гораздо лучше использовать те матчеры, которые есть в пакете flutter_test
.
Например:
expect(user.hasLicence, isTrue);
Это субъективно, но матчеры повышают читаемость:
expect(actualValue, true) ->
expect(actualValue, isTrue)
Или:
expect(actualCollection, []) ->
expect(actualCollection, isEmpty)
Пакет flutter_test
дает обширный набор матчеров, которые помогают покрыть разные сценарии.
Кроме того, в случае упавшего теста вы сможете получить более читаемый текст ошибки, который вернёт соответствующий матчер.
Например, для теста на создание пользователя и проверку утверждения, что id
должен быть больше 0
, вы могли бы написать такой тест:
test('должен создать и вернуть модель пользователя при соблюдении всех условий', () {
const expectedName = 'Leo';
final actualUser = userRepository.createUser(expectedName);
expect(actualUser.id > 0, isTrue);
});
И в случае ошибки вы получите следующий текст в логах:
Expected: true
Actual: <false>
Но если вы примените более подходящий матчер для проверки утверждения:
expect(actualUser.id, greaterThan(0));
То вывод будет гораздо более читаемый и понятный, а это должно быстрее помочь в понимании и решении возникшей проблемы:
Expected: a value greater than <0>
Actual: <-1>
Which: is not a value greater than <0>
Кастомные матчеры
В тех случаях, когда не хватает встроенных возможностей из SDK, вы можете написать свой матчер.
Например, можно написать матчер для проверки наличия в Map<,>
некоторых ключей и их значений:
Matcher containsMap(Map<String, dynamic>? value) => _ContainsMap(value);
class _ContainsMap extends Matcher {
final Map<String, dynamic>? _value;
const _ContainsMap(this._value);
@override
Description describe(Description description) {
return description
.add('Словарь должен содержать все пары значений из проверяемого словаря')
.addDescriptionOf(_value);
}
@override
bool matches(Object? item, Map matchState) {
if (_value == null || item == null) {
return false;
}
return _value!.entries.every(
(entry) => (item as Map<String, dynamic>)[entry.key] == entry.value,
);
}
}
// Использование в коде:
expect(
actualResponse as Json,
containsMap({
'pageName': expectedPrevPageValue,
'reqId': expectedRecId.id,
}),
);
В заключение про матчеры есть отличный гайд со списком и примерами в этой статье.
Замечание насчёт логики теста
При формировании тела юнит-теста в общем случае стоит придерживаться подхода AAA (Arrange, Act, Assert — «Настройка, действие, проверка»).
На примере теста класса, который создаёт пользователя:
-
Сначала вы настраиваете моки и сам юнит (класс/функцию), который нужно покрыть тестами.
-
Вызываете действия, получаете результат, который хотите проверить.
-
Проверяете утверждение, которое указали в
description
теста.
group('В классе UserRepository', () {
group('метод getUserById', () {
test('должен вернуть корректную модель User, если указан id существующего пользователя',
() {
// Arrange — настройка. Часто такие вещи выносят в функцию setUp()
const expectedId = 1;
userApiMock = MockUserApi();
when(userApiMock.removeUser(any)).thenAnswer((_) => Future(() {})); // про when рассмотрим дальше
userRepository = UserRepositoryImpl(userApiMock);
// Act — действие, которое хотите проверить
final actualUser = userRepository.getUserById(expectedId);
// Assert — проверяете утверждение
expect(actualUser.id, expectedId);
expect(actualUser.name, 'John Smith');
expect(actualUser.hasLicence, isFalse);
});
});
});
Такая форма записи субъективно легче читается, чем
expect(userRepository.getUserById(expectedId).name, 'John Smith');
Для большинства сценариев настройку мок-классов, а также создание инстанса тестируемого юнита можно вынести в функцию setUp()
, которая будет вызываться перед каждым вызовом юнит-тестов.
Тестирование состояния и поведения
Во всех рассмотренных выше случаях вы получали какой-то актуальный результат, который могли проверить, сравнив с эталонным значением. Это то, что можно назвать тестированием состояния.
Но есть сценарии, когда вы не получаете никакого результата.
Вернитесь к примеру и ещё раз посмотрите на метод removeUserById()
. Он ничего не возвращает, и вам нужно убедиться, что он работает правильно.
Представьте, что удаление пользователя происходит через вызов API. В таком случае метод removeUserById
мог бы выглядеть вот так:
class UserRepositoryImpl implements UserRepository {
final UserApi _api;
@override
Future<void> removeUserById(int id) async {
if (_ifExists(id)) {
await _api.removeUser(id);
return;
}
throw Exception('User does not exist');
}
Метод removeUserById()
для удаления вызывает removeUser()
из класса UserApi
. В таком случае для проверки правильности работы метода removeUserById()
достаточно было бы убедиться, что был вызван api.removeUser()
после проверки всех условий.
Это — тестирование поведения.
Чтобы протестировать поведение, нам нужно воспользоваться мок-классами.
Мок-классы
Это специальные классы, чьи инстансы подменяют собой инстансы реальных классов. Название происходит от английского глагола to mock — подражать.
Для работы с моками можно использовать пакеты mockito
и mocktail
.
В работе с mockito
есть следующие минусы:
-
Необходимость кодогенерации и её длительность.
-
AnyMatcher
/CaptureAny
(всё расскажем дальше) трудно использовать начиная с версии Dart 2.0 и перехода на null safety.
Дальше в примерах мы будем пользоваться библиотекой mocktail
.
В этом конкретном случае (необходимо написать тест на removeUserById
) моки позволят вам не создавать реальный инстанс UserApi
и не использовать настоящие сетевые вызовы — что усложнило бы настройку окружения для юнит-теста.
Моки только имитируют поведение UserApi
. И для проверки корректности работы метода removeUserById
вам потребуется убедиться только в том, что соотвествующий API был вызван.
Сделать это можно при помощи методов:
-
verify
— чтобы проверить, что был вызван конкретный метод. -
verifyInOrder
— когда требуется проверить, что были вызовы соответствующих вызовов в определённой последовательности. -
verifyNever
— чтобы убедиться, что метод НЕ был вызван.
Дальше мы сделаем мок для класса UserApi
и напишем два теста:
-
что метод
api.removeUser()
был вызван с корректнымid
; -
что
api.removeUser
не был вызван, еслиid
был указан некорректно (id < 0
).
Для начала создадим сам мок-класс. Сделать это можно, наследовав класс вашего мока (MockUserApi
) от класса Mock
и реализовав интерфейс/публичный контракт класса UserApi
class MockUserApi extends Mock implements UserApi {}
Дальше в теле юнит-теста объявим необходимые для теста переменные:
void main() {
UserRepository userRepository;
MockUserApi userApiMock;
Теперь можно использовать MockUserApi
в логике тестов.
Сперва настроим сам мок и класс репозитория:
setUp(() {
userApiMock = MockUserApi();
when(()=> userApiMock.removeUser(any())).thenAnswer((_) => Future(() {}));
userRepository = UserRepositoryImpl(userApiMock);
});
В коде выше мок был настроен таким образом, что его вызов removeUser
будет возвращать пустой Future
.
Настройка мок-классов происходит через использование конструкции
when().thenReturn/thenAnswer/thenThrow.
В when
вы можете указать свойство или метод, к которому происходит обращение. При этом для методов вы можете указывать как конкретные значения, с которыми они ожидаемо будут вызваны, так и любой другой матчер:
-
any
для указания того, что аргумент принимает любое значение. -
Можно использовать
any
для указания именованного аргумента, который принимает любое значение (T any<T>({String? named)
). -
captureAny
иcaptureAny({String? named, Matcher? that})
аналогично, но с захватом аргументов для последующей проверки вverify
(пример будет дальше).
Для настройки значений геттеров и полей класса результат нужно указывать через thenReturn
, для методов и функций — через thenAnswer
.
thenThrow
указывается в том случае, если вам нужно сэмулировать выброс исключения.
Моки настроены, теперь можно написать код для проверки утверждения, что для вызова c id > 0
будет вызвав метод UserApi
. Для этого достаточно убедиться в том, что метод UserApi.removeUser
был вызван.
test('should remove correctly an existing user specified by Id', () async {
const expectedId = 1;
await userRepository.removeUserById(expectedId);
verify(() => userApiMock.removeUser(expectedId));
});
Аналогичным образом вы можете написать проверку негативного сценария на тот случай, когда удаления не происходит. Например, проверить выброс исключения из метода, а также то, что UserApi.removeUser
не был вызван (ни разу и никогда):
test('should throw exception if user does not exist', () async {
const expectedId = -1;
await expectLater(userRepository.removeUserById(expectedId), throwsA(isA<Exception>()));
verifyNever(() => userApiMock.removeUser(expectedId));
});
Миграция с Mockito на Mocktail
Если ваши тесты уже написаны с использованием mockito
, то в случае необходимости мигрировать их на mocktail
довольно просто.
Felix, автор популярных пакетов Bloc ( https://bloclibrary.dev/ ), поддержал контракты mockito практически без изменений (где-то потребуется обернуть вызовы методов/функций в лямбды — было when(functionCall()),
станет when(()=>functionCall())
и так далее).
Не всегда есть возможность использовать мок-классы.
Бывают сценарии, в которых эффективнее использовать другие фейковые сущности, типа in memory database. Например, создавать мок-классы для зависимостей очень трудоёмко.
Известный автор ряда популярных книг и статей по архитектуре ПО Мартин Фаулер выделяет несколько типов таких сущностей, называя их общим термином Test Doubles. Но в большинстве сценариев мок-классы с настроенным и ожидаемым поведением позволяют упростить как логику теста, так и его настройку.
Проверка аргументов в verify()
Для проверки удаления и настройки MockUserApi
вы использовали ранее следующее выражение:
userApiMock = MockUserApi();
when(() => userApiMock.removeUser(any())).thenAnswer((_) => Future(() {}));
Мок-класс был настроен таким образом, что метод removeUser
всегда возвращал пустой Future
независимо от того, какой id
в него передали. И сделано это было за счёт использования матчера any
в качестве аргумента. Но можно было бы использовать и конкретное значение.
Иногда при написании теста на проверку поведения требуется проверить само значение, с которым был вызван метод мок-класса. Проверить это можно следующим образом. Метод verify
()
возвращает объект VerificationResult
, у которого можно проверить список аргументов (captured
), с которым был вызван метод:
/// List of all arguments captured in real calls.
...
List<dynamic> get captured => _captured;
Список аргументов, которые попадут в captured
, формируется через указание captureAny matcher
, которые вы можете указать вместо конкретных значений.
Для теста на метод removeUserById
вы могли бы написать такой тест:
test('should remove correctly an existing user specified by Id', () async {
const expectedId = 1;
userRepository = UserRepositoryImpl(userApiMock);
await userRepository.removeUserById(expectedId);
final result = verify(() => userApiMock.removeUser(captureAny()));
expect(result.captured.first, expectedId);
});
Подробнее про использование моков, их настройку, валидацию verify
и прочие моменты можно узнать, посмотрев документацию к пакету mocktail
. А также на pub.dev.
Кроме того, в документации к mockito
есть хорошие примеры и рекомендации к использованию разных типов матчеров.
Работа с моделями
Юнит-классы могут работать с доменными моделями, инициализация которых может быть сложной. В таких случаях имеет смысл использовать мок-классы вместо реальных инстансов.
Не всегда можно сделать мок на модели
Например, когда в коде происходит маппинг одной модели в другую.
Если вы считаете это излишним, можете использовать классы моделей. Но стоит учесть, что в коде ваших тестов возникает сильная связанность (coupling) на контракт конструктора модели.
Рассмотрите лучше следующий вариант работы с моделями в тестах, на примере класса модели Foo
:
/// Модель со сложным конструктором
class Foo {
final int bar;
final String baz;
final String xyz;
const Foo({required this.bar, required this.baz, required this.xyz});
}
Создайте рядом с вашим тестом фабричные методы/классы-фабрики для создания моделей. Такие фабричные методы объявляют дефолтные значения для модели:
Foo buildFoo({int bar = 0, String baz = 'baz', String xyz = 'xyz'}) =>
Foo(bar: bar, baz: baz, xyz: xyz);
Дальше в коде теста их можно использовать так:
test('bar value should be more than zero', (){
final model = buildFoo(bar: 1);
...
expect(actual, model.bar);
});
Такой подход решает следующее:
-
Уменьшает количество правок вследствие рефакторинга модели, когда нужно указать новые поля, которые не используются в тестах и приносят только шум.
-
Увеличивает акцент на важных полях в контексте юнит-теста.
На примере — попробуйте написать тест на проверку поля bar
. Вы будете вынуждены указать все остальные поля из модели Foo
, делая такой тест «шумным». К нему возникают вопросы: поля baz
и xyz
и их значения имеют отношение к тесту или нет?
test('bar value should be more than zero', (){
final model = Foo(
bar: 1,
baz: 'baz',
xyz: 'xyz',
);
...
expect(actual, model.bar);
});
Не стоит создавать специальные конструкторы для моделей или писать другой продакшен-код, который нужен только в контексте тестов, как в следующем примере:
class Foo {
final int bar;
final String baz;
final String xyz;
const Foo({required this.bar, required this.baz, required this.xyz});
/// Специальный конструктор для использования только в тестах — так делать не нужно
Foo.test({this.bar = 0, this.baz = 'baz', this.xyz = 'xyz'});
}
DateTime.now() и использование Clock
Посмотрите на следующий пример, где есть класс-провайдер, который возвращает баланс пользователя на заданное время, используя метод getAccountBalance()
. Он позволяет запросить баланс как на текущий день, так и на предыдущий:
abstract class UserProfileProvider {
double getAccountBalance();
}
Конкретная реализация такого провайдера могла бы выглядеть следующим образом:
class UserProfileProviderImpl implements UserProfileProvider {
final BankDetailsDatabase _detailsDatabase;
@override
double getAccountBalance() {
final now = DateTime.now();
return _detailsDatabase.getUserBalance(now);
}
...
Метод getAccountBalance()
для получения баланса пользователя обращается к DateTime.now()
. Дальше запрос уходит в класс BankDetailsDatabase
, который отвечает за реализацию запроса.
Вы можете написать тест, имея следующие утверждения:
group('UserProfileProvider', () {
group('getAccountBalance method', () {
test('should return balance for current day correctly', () {});
test('should return balance for the next day depending savings account percents', () {});
});
});
-
getAccountBalance()
должен корректно возвращать баланс на текущий день. -
getAccountBalance()
должен уметь показывать баланс на следующий день с учетом процентов на накопительном счету.
Написав логику теста, вы можете столкнуться с проблемой, когда тест будет возвращать некорректные данные в зависимости от того, в какое время суток он работал.
Все дело в том, что DateTime.now()
на вашем CI может иметь совсем другую тайм-зону, чем сервер базы данных. Вы получите тест, результат которого не является идемпотентным с точки зрения выполнения. Особенно непредсказуемыми будут результаты в зависимости от запуска в полночь в момент перехода предыдущего и следующего дня.
DateTime.now()
— это зависимость, которая возвращает результат. Вы не можете контролировать значение результата в логике теста или использовать его для проверки в качестве эталонных значений.
И, как с любой внешней зависимостью, стоит или замокать её (скажем, используя свой написанный класс DateTimeNowProvider
), или использовать пакет Clock
, который умеет фиксировать конкретное время в качестве результата now()
. Сделать это можно следующим образом:
class UserProfileProviderImpl implements UserProfileProvider {
final BankDetailsDatabase _detailsDatabase;
final Clock _clock;
UserProfileProviderImpl(
this._detailsDatabase, {
Clock clock = const Clock(),
}) : _clock = clock;
@override
double getAccountBalance() {
final now = _clock.now();
return _detailsDatabase.getUserBalance(now);
}
Здесь в класс UserProfileProviderImpl
передаётся зависимость на инстанс класса Clock
с использованием значения по умолчанию const Clock()
. Дальше оно используется для получения времени now()
. В продакшен-окружении этот код будет работать, как и задумывалось.
Но при написании тестов вы уже сможете указывать конкретное эталонное значение через Clock.fixed(expected)
и получать ожидаемый результат без сайд-эффектов в зависимости от времени запуска.
С настроенными моками такой тест мог бы выглядеть следующим образом:
final expectedCurrentDayDate = DateTime(2023, 12, 31, 23, 59);
const expectedCurrentDayBalance = 1.0;
final expectedNextDayDate = DateTime(2024, 01, 01, 00, 01);
const expectedNextDayBalance = 2.0;
setUp(() {
mockBankDetailsDatabase = MockBankDetailsDatabase();
when(mockBankDetailsDatabase.getUserBalance(expectedCurrentDayDate))
.thenAnswer((_) => expectedCurrentDayBalance);
when(mockBankDetailsDatabase.getUserBalance(expectedNextDayDate))
.thenAnswer((_) => expectedNextDayBalance);
});
...
test(
'should return balance for the next day depending savings account percents',() {
userProfileProvider = UserProfileProviderImpl(
mockBankDetailsDatabase,
clock: Clock.fixed(expectedNextDayDate),
);
final actualBalance = userProfileProvider.getAccountBalance();
expect(actualBalance, expectedNextDayBalance);
});
Замечания насчет использования Timer и Future.delayed
Представьте, что контракт класса UserProfileProvider
изменился так, что в нем появилась возможность подписаться на изменение баланса пользователя.
abstract class UserProfileProvider {
Stream<double> get onBalanceChanged;
double getAccountBalance();
}
Реализация этого провайдера может выглядеть следующим образом:
class UserProfileProviderImpl implements UserProfileProvider {
@override
Stream<double> get onBalanceChanged => _balanceStreamController.stream;
final StreamController<double> _balanceStreamController = StreamController<double>.broadcast();
Timer? _timer;
UserProfileProviderImpl(
this._detailsDatabase, {
Clock clock = const Clock(),
}) : _clock = clock {
Timer(const Duration(hours: 1), () => _balanceStreamController.sink.add(getAccountBalance()));
}
У вас появился таймер, который с определённой периодичностью (для примера — раз в час) эмитит в onBalanceChanged()
стрим-значение, получаемое из getAccountBalance()
, которое вы покрыли юнит-тестом.
Для этого стрима onBalanceChanged
вы можете указать аналогичные утверждения, как ранее для метода getAccountBalance
:
-
onBalanceChanged()
должен корректно возвращать баланс на текущий день. -
onBalanceChanged()
должен корректно возвращать баланс на следующий день с учётом процентов на накопительном счету.
Напишем такие утверждения:
group('onBalanceChanged stream', () {
test('should emit balance for current day correctly', () {});
test('should emit balance for the next day depending savings account percents', () {});
});
И попробуем написать тест на проверку получения текущего баланса, например, через час после создания инстанса:
test('should emit balance for current day correctly', () async {
userProfileProvider = UserProfileProviderImpl(
mockBankDetailsDatabase,
clock: Clock.fixed(expectedCurrentDayDate),
);
await Future.delayed(const Duration(hours: 1));
await expectLater(userProfileProvider.onBalanceChanged, emits(expectedCurrentDayBalance));
});
Как вы видите, вам придется добавить некоторое ожидание await Future.delayed(const Duration(hours: 1));
Тут возникает сразу несколько проблем:
-
Агент на CI/CD будет впустую тратить процессорное время, и ваши DevOps-инженеры будут расстроены.
-
Тест упадёт по тайм-ауту, если вы его не настроили предварительно и не увеличили тайм-аут через использование аннотации @Timeout(duration).
-
Но даже если и настроили тайм-аут, время выполнения тестов увеличилось. А это, скорее всего, совсем не тот результат, который вы ожидаете от юнит-тестов.
В качестве решения проблемы длительного ожидания можно и нужно использовать пакет fake_async
.
Он запускает все асинхронные операции и таймеры в dart zone и переопределяет их поведение таким образом, что можно сэмулировать:
-
Работу таймера.
-
Резолвинг для
Future.delayed()
. -
Резолвинг микротасок, как будто
dart event loop
взял их на выполнение.
Попробуйте переписать тест следующим образом:
test('should emit balance for current day correctly', () async {
fakeAsync((fa) {
userProfileProvider = UserProfileProviderImpl(
mockBankDetailsDatabase,
clock: Clock.fixed(expectedCurrentDayDate),
);
expectLater(userProfileProvider.onBalanceChanged, emits(expectedCurrentDayBalance));
fa.elapse(const Duration(hours: 1));
});
});
При помощи вызова fa.elapse(const Duration(hours: 1));
вы сможете заставить таймер, который был запущен в зоне fake async
, перейти на час дальше, тем самым позволив зависимому коду эмитить данные в стриме onBalanceChanged
.
Если вы часто встречаетесь с аналогичной проблемой, можете добавить вот такой helper-метод для работы с тестами, которые требуют для своей работы fake_async
typedef TestAsyncCallback = void Function(FakeAsync fa);
void testAsync(String description, TestAsyncCallback body) {
test(description, () => fakeAsync((fa) => body(fa)));
}
// пример использования
testAsync('should emit balance for current day correctly', (fa) {
userProfileProvider = UserProfileProviderImpl(
mockBankDetailsDatabase,
clock: Clock.fixed(expectedCurrentDayDate),
);
expectLater(userProfileProvider.onBalanceChanged, emits(expectedCurrentDayBalance));
fa.elapse(const Duration(hours: 1));
});
Тесты-интроверты и тесты-экстраверты
Вы уже увидели выше, как можно использовать мок-классы.
В некоторых случаях разработчики предпочитают использовать другие сущности (например, in memory database
для баз данных sqllite
/hive
), классы-заглушки stubs
и прочие подходы, которые призваны решить единственную задачу — подменить/имитировать зависимости класса или функции, которые вы покрываете юнит-тестом.
В общем случае ваш класс всегда будет иметь какие то зависимости. В логике dart-классов вы можете использовать constructor injection
, что означает передачу зависимостей через конструктор создаваемого класса. Резолвинг зависимостей лежит в зоне ответственности той системы DI, которая используется у вас в проекте.
Часто возникает соблазн не использовать моки, а использовать реальные зависимости.
Например, вы могли уже встречать подходы, когда:
-
Создаются инстансы API или баз данных, которые работают с тестовым окружением. В таком случае такие тесты превращаются в интеграционные. И начинают зависеть от среды, в которой происходит их запуск.
-
Передаются в тестируемые классы репозиториев/сервисов/менеджеров инстансы API, которые настроены для работы с заглушками ответов от бэкенда, и отдающие конкретные DTO (Data Transfer Objects, например JSON).
-
И прочие подходы, когда при выполнении логики тестов на юнит-класс/функцию выполняется не только код тестируемой сущности, но и код его зависимостей вместо использования моков.
Мартин Фаулер в своей классификации тестов вводит следующую классификацию:
-
Solitary-тесты, поток выполнения которых не затрагивает код зависимостей юнита класса/функции.
-
Sociable-тесты, поток выполнения которых напрямую затрагивает код зависимостей.
Можно привести другую аналогию: тесты-интроверты и тесты-экстраверты.
-
Тесты-интроверты предпочитают работать только со своими моками, избегая «общения» с реальными инстансами.
-
В то время, как тесты-экстраверты предпочитают втянуть в свою «игру» все зависимости, с которыми сталкиваются.
Мы предлагаем следовать практике написания solitary- (или интроверт-) тестов. И вот почему — представьте следующую структуру некоторого приложения в архитектуре Clean:
-
API лежит в data-слое. API умеют работать с сетевым слоем, отвечают за сериализацию/десериализацию json-ответов со стороны бэкенда в DTO.
-
Repositories находятся в domain-слое. Repositories работают со слоем Data: умеют строить доменные модели Domain Models из DTO, конвертировать их в DTO, получаемые со стороны API.
-
Менеджеры/сервисы/провайдеры находятся в domain-слое: умеют работать с Repositories и доменными моделями. Отвечают за бизнес-логику.
-
Виджеты в presentation-слое в общем случае работают со своими менеджерами/провайдерами. Умеют работать с доменными моделями или со своим состоянием (в зависимости от используемых подходов к построению UI).
В такой архитектуре каждый слой ничего не знает про слой, лежащий сверху. API не знает про доменные модели, доменный слой не знает про презентационный.
Кроме того, нет кросс-пересечений, когда один вышестоящий слой обращается к нижележащему больше чем через один (Presentation layer не обращается к Data layer).
Представьте в такой архитектуре некоторый класс репозитория. Например, из ранее рассмотренного примера:
class UserRepositoryImpl implements UserRepository {
final UserApi _api;
@override
Future<void> removeUserById(int id) async {
...
Для создания тестов на UserRepository
вы могли бы передавать реальный инстанс UserApi, настроенный для работы с некоторыми JSON-заглушками. Например, таким образом:
setUp(() {
when(dio.removeUser).thenAnswer((_) => Future.value('{key: value json structure}'));
final api = UserApiImpl(dio);
userRepository = UserRepositoryImpl(UserApi());
});
В таком случае поток выполнения тестов на UserRepository
затрагивал бы дополнительно код UserApi
, его логику сериализации/десериализации и получения объектов DTO. Скорее всего, будет затрагиваться код, отвечающий за отправку метрик, за логгинг и прочие вспомогательные действия.
Как итог, ваш тест проверяет не только логику юнит-класса/функции (для removeUserById
проверку значения id на допустимость), но и код его зависимостей. Что может помешать выполнению теста и сделать сам тест более атомарным и независимым от кода зависимостей.
Если представить, что класс UserApi
был бы покрыт своими тестами, которые защищают логику сериализации ответов от бэкенда, создания DTO, то такой подход с формированием тестов-экстравертов кажется необоснованным и даже лишним. Так как проверяет не столько логику покрываемого класса, сколько логику его зависимостей.
Более правильный на наш взгляд подход — написание solitary тестов, или тестов-интровертов.
Для сценария выше для класса UserRepository
вы могли бы в качестве зависимостей передавать настроенные для каждого тестового сценария мок-объекты.
Например, как вы это уже делали выше:
setUp(() {
userApiMock = MockUserApi();
when(userApiMock.removeUser(any)).thenAnswer((_) => Future(() {}));
userRepository = UserRepositoryImpl(userApiMock);
});
group('removeUserById method', () {
test('should remove correctly an existing user specified by Id',() async {
const expectedId = 1;
userRepository = UserRepositoryImpl(userApiMock);
await userRepository.removeUserById(expectedId);
final result = verify(userApiMock.removeUser(captureAny));
expect(result.captured.first, expectedId);
});
Кроме того, когда закрыли UserRepository
тестами, класс UserApi
тоже нужно покрыть тестами.
С таким подходом каждый слой в архитектуре будет закрыт своими тестами, предназначенными для защиты логики конкретных классов.
Для API — логика сериализации, для репозиториев — создание доменных моделей, и так далее выше по иерархии.
И подход с использованием тестов-экстравертов теряет смысл, так как нижележащая логика будет покрыта тестами. В таком случае более оправдан подход с использованием мок-классов.
Но выбор всегда за вами, если вы видите другие преимущества в использовании solitary-тестов.
Рефакторинг
На практике именно при написании тестов становятся очевидными логические конфликты или ошибки, которые были допущены при написании класса.
Например, использование такого паттерна (кто-то его назовет антипаттерном), как Service Locator, усложняет настройку тестов. Возникает необходимость переопределять глобально доступный Service Locator. Кроме того, субъективно понижается читаемость кода.
Аналогичные проблемы возникают с использованием статических методов — по той причине, что их не получится обернуть в мок-классы и передать как зависимость в тестируемый класс.
Исходя из этих соображений, стоит придерживаться следующего подхода при написании любого кода:
-
Пишите код, держа в уме необходимость написать на него тесты.
-
Задавайте себе вопрос, легко ли будет написать тестовый сценарий на такой код.
Так делать нехорошо
Случается так, что тесты падают. И это даже хорошо, что они падают. Это становится индикатором того, что вы или кто-то внесли правки в код, в логику, которая поменялась или ошибочно, или согласно новым требованиям к классу.
В таком случае возникает большой соблазн внести правку в упавший тест, причем так, чтобы он стал работающим, но не работающим корректно с точки зрения бизнес-логики. Так делать не нужно, поскольку:
-
теряется смысл такого юнит-теста, он превращается в бесполезный код;
-
такой тест может ввести в заблуждение другого разработчика, который попытается понять, что он делает.
Если изменились требования к классу и соответствующий тест упал, стоит
-
или вообще удалить такой тест;
-
или переписать логику и поправить тест согласно изменениям;
-
или добавить новые тесты согласно новым утверждениям.
Подведём итоги
Вот несколько рекомендаций, как писать тесты:
-
Не относитесь к своим тестам как к второсортному коду. Ошибочно полагать, что DRY, KISS и все остальные принципы и культура разработки — это для продакшена, а в тестах допустимо всё.
Это не верно. Тесты — такой же код.
Разница только в том, что у тестов другая цель — обеспечить качество вашего приложения. Все принципы, применямые в разработке продакшен-кода, могут и должны применяться при написании тестов.
-
Относитесь к тестам как к спецификации юнит-класса/функции, как к его документации. Задавайте описания (description) тестов как семантические определения, несущие логический смысл с точки зрения бизнес-логики, которой отвечает класс.
При написании ваших тестов можно придерживаться такого принципа:
group (<кто> должен) test (<что> <когда>)
<когда/если> <то>
-
Если вы придерживаетесь solitary-подхода — покрывайте только код самого класса, на который пишете юнит. Поток выполнения кода не должен протекать в код зависимостей (исключение — структуры данных).
Снизу вверх:
-
API (мокаете JSON) — проверяете маппинги, логику сериализации/десериализации.
-
Repository (мокаете API) — проверяете логику только методов репозитория.
-
Менеджеры/сервисы/вью-модели — мокаете репозитории. В таком случае вы на уровень API не спускаетесь. Репозитории отдают ответ в виде доменных моделей, которые вы будете ожидать в теле теста на позитивные/негативные сценарии.
-
Widget-тесты — мокаете вью-модели/сервисы/провайдеры, те зависимости, которые протекают в виджеты в качестве их зависимостей.
-
-
Бездумное написание тестов не только не помогает, но и вредит проекту. Если раньше у вас был один некачественный код, то, написав тесты, не разобравшись в этой теме, вы получите два. А также удвоенное время на сопровождение и поддержку.
-
Стоит придерживаться следующих основных правил.
Ваши тесты должны:
-
Быть достоверными.
-
Не зависеть от окружения, на котором они выполняются.
-
Легко поддерживаться.
-
Легко читаться и быть простыми для понимания.
-
Использовать единые правила именования в своей команде.
-
Запускаться регулярно на CI/CD.
-
-
Вот несколько принципов, которые помогают писать тестируемый код:
-
Мыслите интерфейсами, а не классами, тогда вы всегда сможете легко подменять конкретные реализации моками в тестовом коде.
-
Избегайте прямого инстанцирования объектов внутри методов с логикой. Используйте фабрики или DI.
-
Избегайте прямого вызова статических методов (статика — зло).
-
Избегайте конструкторов, которые содержат логику, — их сложнее тестировать.
-
Пишите утверждения в тестах на одну конкретную вещь / один аспект поведения.
-
-
-
Если юнит-класс слишком сложен (например, какая-то крупная фича), стоит разделить его на несколько частей и тестировать отдельно каждый аспект поведения.
Если вы не будете придерживаться этого правила, ваши тесты потеряют читаемость, и очень скоро их станет сложно поддерживать.
-
Использование внешних зависимостей в юнит-тестах, таких как база-данных, сетевые запросы, файловая система, запрещено. Все они должны быть подменены, независимо от размеров «юнита». В противном случае вы пишете интеграционный тест.
-
В тестах все тайм-ауты и
Future
резолвятся с помощьюFakeAsync
. Не допускайте реальных временных задержек. Экономьте ваши CPU. -
Юнит-тест не может вызывать неинтерфейсный (непубличный/private/protected) метод напрямую. Цель юнит-теста — защитить публичный контракт класса и таким образом — его потребителей. Неинтерфейсные члены класса в той или иной степени могут быть проверены через тестирование публичных членов класса.
Надеемся, теперь написание юнит-тестов будет вас радовать так же, как и написание любого другого кода, и приносить пользу вашему проекту.