Создание лаконичных модульных тестов во Flutter

Рассмотрим тест для проверки простого класса 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 и т. д.

Обе эти проблемы можно решить с помощью общей модели обертки для тестовых входов.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Chahat Gupta: Unit Testing in Flutter — Writing crisp & concise test code

Предыдущая статьяКак создать веб-приложение для преобразования речи в текст с Node.js
Следующая статьяАвтоматизация и масштабирование инфраструктуры приложений с Docker Swarm и AWS