Рассмотрим тест для проверки простого класса User
с фабричной функцией и переопределенным оператором равенства (equals).
class User {
final int id;
final String firstName;
final String? lastName;
const User({
required this.id,
required this.firstName,
required this.lastName
});
factory User.fromJson(Map<String, dynamic> json) {
int id = json['id'];
String firstName = json['first_name'];
String? lastName = json['last_name'];
return User(id: id, firstName: firstName, lastName: lastName);
}
@override
bool operator ==(Object other) =>
other is User &&
other.runtimeType == runtimeType &&
other.id == id &&
other.firstName == firstName &&
other.lastName == lastName;
}
Далее следуют возможные тестовые случаи для фабрики fromJson()
, которые могут разрешиться или не разрешиться в действительный объект User
.
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Testing fromJson', () {
//содержит все допустимые поля - будет проведен парсинг
String input1 = '{"id": 0, "first_name": "Chahat", "last_name": "Gupta"}';
User expected1 = User(id: 0, firstName: 'Chahat', lastName: 'Gupta');
// содержит все допустимые поля - будет проведен парсинг
String input2 = '{"id": 0, "first_name": "Chahat", "last_name": null}';
User expected2 = User(id: 0, firstName: 'Chahat', lastName: null);
// отсутствует [last_name], который является nullable - будет проведен парсинг
String input3 = '{"id": 0, "first_name": "Chahat"}';
User expected3 = User(id: 0, firstName: 'Chahat', lastName: null);
// неверный тип данных [id] - не будет проведен парсинг
String input4 = '{"id": "0", "first_name": "Chahat", "last_name": "Gupta"}';
//недопустимая строка JSON - не будет проведен парсинг
String input5 = '{"id": 0, "first_name": "Chahat", "last_name": Gupta}';
test('Test case 1: All fields present',
() => expect(User.fromJson(jsonDecode(input1)), expected1));
test('Test case 2: last_name field null',
() => expect(User.fromJson(jsonDecode(input2)), expected2));
test('Test case 3: last_name field missing',
() => expect(User.fromJson(jsonDecode(input3)), expected3));
test(
'Test case 4: id field has wrong datatype',
() => expect(() => User.fromJson(jsonDecode(input4)),
throwsA(isA<TypeError>())));
test(
'Test case 5: invalid JSON string',
() => expect(() => User.fromJson(jsonDecode(input5)),
throwsA(isA<FormatException>())));
});
}
Теперь тест нормально выполняет свою работу. Но в этом подходе мне не нравится повторение оператора test()
. Работа с командой профессионалов выработала у меня привычку — писать лаконичный код, сохраняя при этом производительность.
В программировании написание оператора дважды — неоправданная практика в большинстве случаев.
Я начал думать о том, как избавиться от повторяющихся операторов test()
и при этом добиться тех же результатов. Испробовав множество подходов и потерпев неудачу в большинстве из них, пришел к такому решению:
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Testing fromJson', () {
Map<String, User> inputs = {
// содержит все допустимые поля - будет проведен парсинг
'{"id": 0, "first_name": "Chahat", "last_name": "Gupta"}':
User(id: 0, firstName: 'Chahat', lastName: 'Gupta'),
// содержит все допустимые поля - будет проведен парсинг
'{"id": 0, "first_name": "Chahat", "last_name": null}':
User(id: 0, firstName: 'Chahat', lastName: null),
//отсутствует [last_name], который является nullable - будет проведен парсинг
'{"id": 0, "first_name": "Chahat"}':
User(id: 0, firstName: 'Chahat', lastName: null),
};
inputs.forEach((String input, User expected) {
test(input, () => expect(User.fromJson(jsonDecode(input)), expected));
});
}
Основными особенностями этого подхода являются включение Map
и использование цикла для итерации и выполнения тестов. Можно объявить вводы/выводы как члены, но это необязательно. Для меня это выглядело воплощением совершенства, пока я не задумался о негативных тестовых случаях.
Подход простого сопоставления предполагает, что на выходе всегда будет объект User
, что в значительной степени мешает достижению цели написания модульных тестов для этой фабричной функции. Так каким же может быть решение?
Основным препятствием для сохранения текущего map & loop было то, что в результате негативных тестов ожидалась функция Matcher
, а не User
или любой другой объект.
Спустя множество попыток я остановился на следующей технике:
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Testing fromJson', () {
Map<String, dynamic> inputs = {
// содержит все допустимые поля - будет проведен парсинг
'{"id": 0, "first_name": "Chahat", "last_name": "Gupta"}':
User(id: 0, firstName: 'Chahat', lastName: 'Gupta'),
// содержит все допустимые поля - будет проведен парсинг
'{"id": 0, "first_name": "Chahat", "last_name": null}':
User(id: 0, firstName: 'Chahat', lastName: null),
// отсутствует [last_name], который является nullable - будет проведен парсинг
'{"id": 0, "first_name": "Chahat"}':
User(id: 0, firstName: 'Chahat', lastName: null),
// неверный тип данных [id] - не будет проведен парсинг
'{"id": "0", "first_name": "Chahat", "last_name": "Gupta"}':
throwsA(isA<TypeError>()),
// недопустимая строка JSON - не будет проведен парсинг
'{"id": 0, "first_name": "Chahat", "last_name": Gupta}':
throwsA(isA<FormatException>())
};
inputs.forEach((String input, dynamic expected) {
test(
input,
() => expect(
expected is User
? User.fromJson(jsonDecode(input))
: () => User.fromJson(jsonDecode(input)),
expected));
});
}
В этой версии предполагается, что переменная expected
может быть либо User
(в случае успешного парсинга), либо function
(в случае неудачи парсинга и выброса исключения).
В альтернативной версии можно сделать input
в виде Map<dynamic, dynamic>
и, вместо проверки типа переменной expected
внутри expect()
, обернуть негативные вводы в собственную функцию.
Данный подход работает и может быть адаптирован практически для любого языка или платформы.
Ограничения
Этот способ следует одному из ключевых принципов программирования D.R.Y. (“Do Not Repeat Yourself” — “Не повторяйся”), но в его ванильной версии есть некоторые ограничения.
Во-первых, описание каждого теста внутри цикла задается как входная строка. Не вижу в этом ничего страшного, учитывая, что тесты в основном запускаются в конвейерах и практически не рассматриваются по отдельности, пока что-то не сломается или не выйдет из строя.
Во-вторых, мы не используем другие поля, такие как reason
, skip
, timeout
и т. д.
Обе эти проблемы можно решить с помощью общей модели обертки для тестовых входов.
Читайте также:
- Flutter и SonarQube для статического анализа кода
- Даешь меньше ошибок в проектах ПО!
- Что такое Flutter и зачем его изучать?
Читайте нас в Telegram, VK и Дзен
Перевод статьи Chahat Gupta: Unit Testing in Flutter — Writing crisp & concise test code