Unit Testing in Flutter — Writing crisp & concise test code
Unit testing is an important part of the software development process — especially when your code is being served to tens of thousands of people.
Unit test writing should be considered as a mindful exercise to meditate over your code; thinking of its different use cases, all possible inputs, and places where it could break.
Let’s consider this simple User
class with a factory function and overridden equals operator.
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;
}
Followed by some possible test cases for the fromJson()
factory which may or may not resolve to a valid User
object.
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Testing fromJson', () {
// contains all valid fields - will get parsed
String input1 = '{"id": 0, "first_name": "Chahat", "last_name": "Gupta"}';
User expected1 = User(id: 0, firstName: 'Chahat', lastName: 'Gupta');
// contains all valid fields - will get parsed
String input2 = '{"id": 0, "first_name": "Chahat", "last_name": null}';
User expected2 = User(id: 0, firstName: 'Chahat', lastName: null);
// missing [last_name] which is nullable - will get parsed
String input3 = '{"id": 0, "first_name": "Chahat"}';
User expected3 = User(id: 0, firstName: 'Chahat', lastName: null);
// wrong [id] datatype - will not be parsed
String input4 = '{"id": "0", "first_name": "Chahat", "last_name": "Gupta"}';
// invalid JSON string - will not be parsed
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>())));
});
}
Now this is perfectly fine and does the job. What I dislike about this approach is that we are repeating the test()
statement. Working with a pro team I built a habit — more of an obsession — of writing concise code while keeping the performance intact.
In programming, more often than not, if you have to write a statement twice you are doing it wrong.
I started thinking of some ways to get rid of the repetitive test()
statements while achieving the same results. After trying out many approaches and failing in most of them, I came up with this solution.
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Testing fromJson', () {
Map<String, User> inputs = {
// contains all valid fields - will get parsed
'{"id": 0, "first_name": "Chahat", "last_name": "Gupta"}':
User(id: 0, firstName: 'Chahat', lastName: 'Gupta'),
// contains all valid fields - will get parsed
'{"id": 0, "first_name": "Chahat", "last_name": null}':
User(id: 0, firstName: 'Chahat', lastName: null),
// missing [last_name] which is nullable - will get parsed
'{"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));
});
}
The main highlights of this approach are the inclusion of an input Map
, and the use of a loop to iterate and run the tests. You may declare the inputs/outputs as members but that’s optional. This looked like utter perfection to me, until I thought..
Wait. What about the negative test cases?
The simple matching approach assumes that the output will always be a User
object. Which beats the purpose of writing unit tests for this factory function to a great extent. So what could be the solution? I asked.
The main obstacle with keeping the current map & loop was that the negative tests expected a Matcher
function, not a User
or any other object.
So after another session of googling and trying to protect my so-far progress in revolutionising my team’s test writing methods, I finally rested on this technique.
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Testing fromJson', () {
Map<String, dynamic> inputs = {
// contains all valid fields - will get parsed
'{"id": 0, "first_name": "Chahat", "last_name": "Gupta"}':
User(id: 0, firstName: 'Chahat', lastName: 'Gupta'),
// contains all valid fields - will get parsed
'{"id": 0, "first_name": "Chahat", "last_name": null}':
User(id: 0, firstName: 'Chahat', lastName: null),
// missing [last_name] which is nullable - will get parsed
'{"id": 0, "first_name": "Chahat"}':
User(id: 0, firstName: 'Chahat', lastName: null),
// wrong [id] datatype - will not be parsed
'{"id": "0", "first_name": "Chahat", "last_name": "Gupta"}':
throwsA(isA<TypeError>()),
// invalid JSON string - will not be parsed
'{"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));
});
}
In this amended version, we assume that the expected
variable can either be a User
(in case the parsing is successful) or a function
(in case the parsing fails and throws an Exception).
In an alternate version of it you can make the input
as a Map<dynamic, dynamic>
and instead of checking the type of expected
variable inside expect()
, you can wrap your negative inputs in a function of their own.
Cherry on the cake — this approach is valid and can be adapted for almost any language or platform.
Limitations
This is an excellent approach in the direction of D.R.Y. coding, but this vanilla version also comes with some limitations.
Firstly, the description of each test inside the loop is set to be the actual input string. I don’t see this as a dealbreaker considering tests are mostly run in pipelines and hardly looked upon individually until something breaks or fails.
Second, we are missing the use of other fields like reason
, skip
, timeout
, etc.
Both of these can be overcome by using a common wrapper model for our test inputs. I plan to make one soon and might as well write about it in near future.
Conclusion
Code is like handwriting, everybody has their own style. Same results can be achieved by different approaches and methods. At the end of the day, it all rolls down to what you and your team is most comfortable with. I look forward to sharing deeper insights on the developments I make in unit testing and other aspects of my daily coding routine.
Let’s meet in another blog! 👋🏼