In our last article, we looked at the Flutter Test framework along with the mock framework Mockito. We can’t wait to start writing Test cases. But when you’re ready to write your test case, you don’t know where to start. As we said at the end of our last article, not all code can easily write test cases, and we need to gradually modify our code to make it easier to test.
Round 1
stringToInt(context, '123');
print(number);
int number;
void stringToInt(BuildContext context, String text) {
try {
number = int.parse(text);
} on FormatException {
Scaffold.of(context).showSnackBar(SnackBar(
content: Text("translate failed"))); }}Copy the code
This code logic is very simple, input a string, convert it to int, encountered error will pop up error box.
The fatal problem with this code, however, is that it contains UI logic, meaning that it is not a purely logical function. We can easily construct a String argument when writing Unit test, but it’s hard to initialize a BuildContext, and the logic of the popbox doesn’t have a clear result, not a UI test after all.
So the first thing we need to do is strip the UI out of this method and let the caller handle the UI-related logic.
if(! stringToInt('123')) {
Scaffold.of(context).showSnackBar(SnackBar(
content: Text("translate failed"))); }else {
print(number);
}
int number;
bool stringToInt(String text) {
try {
number = int.parse(text);
return true;
} on FormatException {
return false; }}Copy the code
So the stringToInt method is just pure logic, and we just need to focus on the original need to convert the type.
But this function can be further optimized. We find that the function stores the converted result in a variable outside the function. Therefore, we need to introduce this variable in the process of testing. We should try to make the object or method we test less dependent on the outside world. We can try to modify it like this.
final number = stringToInt('123');
if (number == null) {
Scaffold.of(context).showSnackBar(SnackBar(
content: Text("translate failed"))); }else {
print(number);
}
int stringToInt(String text) {
try {
final number = int.parse(text);
return number;
} on FormatException {
return null; }}Copy the code
We can simply return both the converted value and the failure case through the return value, so that we no longer need to rely on an additional variable. Writing test cases for such a testable function is a delight.
expect(stringToInt('123'), 123);
expect(stringToInt('123bda'), null);
Copy the code
Round 2
class PageProvider {
int _page = 0;
List<Message> messages = [];
refresh() {
_page = 0;
messages = [];
_getData();
}
loadMore() {
_getData();
}
void _getData() async {
final response = await http.Client().get('https://jsonplaceholder.typicode.com/posts/1?page=${_page}');
if (response.statusCode == 200) {
messages.addAll(Message.formJson(convert.jsonDecode(response.body)));
} else {
throw Exception('Failed to load post'); }}}Copy the code
This example is a ViewModel class that requests page data, which is also more in line with our daily experience. It contains network request, page turning, error handling and other logic.
The first thing we notice is that the _getData core method contains a network request. According to the principles mentioned in our last article, network requests as external impact factors should be mock.
We need the mock Client class and return an artificial Response(‘{“title”: “Test”}’, 200).
class MockClient extends Mock implements http.Client {}
final client = MockClient();
when(client.get(any))
.thenAnswer((_) async => http.Response('{"title": "Test"}'.200));
Copy the code
So in Unit Test we first need to mock a fake client to artificially control the value returned by the network library. We have initialized the mock client and set the content to return. But how do we get _getData in our project to use the MockClient we created? Here comes a design pattern in software development: dependency injection.
To make it easier to replace the Client object during testing, we need to be able to replace the Client object in some way during _getData execution. There are several ways to do this. The simplest way is to pass the Client object as an argument to _getData so that the caller can control what Client object _getData uses.
_getData(http.Client client) async {
final response = await client.get('https://jsonplaceholder.typicode.com/posts/1?page=${_page}');
if (response.statusCode == 200) {
messages.addAll(Message.formJson(convert.jsonDecode(response.body)));
} else {
throw Exception('Failed to load post'); }}Copy the code
This way, when writing test cases, we can easily inject our mock Client into _getData instead of the original implementation. However, this design is obviously not very elegant, and all calls to the _getData method must pass this parameter. We can also optimize our code in other ways or by using tripartite libraries.
class HTTP {
factory HTTP() => _instance;
static HTTP _instance = HTTP._private();
HTTP._private();
http.Client getclient => _client; http.Client _client = http.Client(); injection(http.Client client) { _client = client; }}Copy the code
That’s one way to do it. The network library is normally partitioned into singletons. We can leave an injection method for Unit Test to modify _client in the singletons. This allows you to replace the client instance globally.
We don’t need the client entry anymore. All places that use client can use HTTP().client instead.
Now, we can try to write our first test case.
final provider = PageProvider();
provider.loadMore();
expect(provider.messages.length, 1);
Copy the code
Wait, that’s not true. Why is that?
Note that _getData is an asynchronous method (async later), after all, the network request is called internally. But we are not calling with the await keyword, which means we are not calling the function and waiting for the function to complete, but just going to the next method. That is, the caller does not know when the asynchronous function ends.
We could have done this by adding a delay of a few hundred milliseconds, but this was obviously not rigorous enough. We can use the await keyword before these functions to make it clear that the following methods need to wait until the current method has finished executing. To make it easier for the user to understand, it is also best to change the return values of several functions so that they explicitly return the Future to let the caller know that they are asynchronous methods.
class PageProvider {
int _page = 0;
List<Message> messages = [];
Future<void> refresh() {
_page = 0;
messages = [];
return _getData();
}
Future<void> loadMore() {
return _getData();
}
Future<void> _getData() async {
final response = await HTTP().client.get('https://jsonplaceholder.typicode.com/posts/1?page=${_page}');
if (response.statusCode == 200) {
messages.addAll(Message.formJson(convert.jsonDecode(response.body)));
} else {
throw Exception('Failed to load post'); }}}Copy the code
After all the functions have added Future return values, we add await in our test case to know when the method has completed and to check the results.
test('load more message', () async {
final provider = PageProvider();
await provider.loadMore();
expect(provider.messages.length, 1);
});
Copy the code
At this point, with some modifications, our code is happy to write unit Test. This is the complete functional code section.
class PageProvider {
int _page = 0;
List<Message> messages = [];
Future<void> refresh() {
_page = 0;
messages = [];
return _getData();
}
Future<void> loadMore() {
return _getData();
}
Future<void> _getData() async {
final response = await HTTP().client.get('https://jsonplaceholder.typicode.com/posts/1?page=${_page}');
if (response.statusCode == 200) {
messages.addAll(Message.formJson(convert.jsonDecode(response.body)));
} else {
throw Exception('Failed to load post'); }}}class Message {
static List<Message> formJson(dynamic) {}}class HTTP {
factory HTTP() => _instance;
static HTTP _instance = HTTP._private();
HTTP._private();
http.Client getclient => _client; http.Client _client = http.Client(); injection(http.Client client) { _client = client; }}Copy the code
Let’s add a few more cases. This is the complete test case section.
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mockito/mockito.dart';
class MockClient extends Mock implements http.Client {}
void main() {
final provider = PageProvider();
final client = MockClient();
HTTP().injection(client);
group('page provider', () {
test('load messages', () async {
when(client.get(any)).thenAnswer((invocation) async => http.Response('{"title": "Test"}'.200));
await provider.loadMore();
expect(provider.messages.length, 1);
await provider.refresh();
expect(provider.messages.length, 0);
});
test('error', () async {
when(client.get(any)).thenAnswer((_) async => http.Response(' '.500));
expect(provider.loadMore(), throwsException);
});
});
}
Copy the code
This completes a simple unit test. There are plenty of other optimizations that can be made. For example, the Mock invocation invocation now returns dead data. The Mock Invocation invocation gets the page parameter of the network request and returns the corresponding data. Or we could test the error handling of the jsonDecode function by creating a case that returns an HTTP code of 200 but an empty body string. I will not list them here.
From the above two examples, we can have a rough understanding of unit test. But everyone works on different projects, and different code faces different problems. I’ve compiled a few guidelines that can help you write more testable code:
- Single responsibility: The function or class should be as simple as possible, the logic of the function should be as simple as possible, and a function should solve only one problem.
- Low coupling: Functions or classes should rely as little on external resources as possible.
- Dependency injection: When designing the API, you need to ensure that the code has an entry point for injecting dependencies.
- Pure logic: Avoid introducing UI or other non-logical code into your test methods.
- Be careful with global state: Authority variables and static attributes are often scattered and difficult to control. Dependency injection can be used to pass external parameters to methods. (Also consider using get_it)
- Be careful with static methods: For the same reason, but note that Mockito cannot yet mock static methods.
Although it may seem complicated, I recommend that you try writing a Unit test or two. On the one hand, it forces you to write testable code with a better decoupling structure and elegant API design. On the other hand, Unit Test can really help you find small bugs in advance and improve the robustness of your application. I myself found a bug in a real project after writing my first Unit test 😂.