在上一篇文章中,我们把 Flutter Test 框架连带 mock 框架 Mockito 一起过了一遍,是不是迫不及待准备开始写 test case。然而当你磨刀霍霍准备开始写 test case 的时候,却发现不知从何处下手。正如我们上一篇的结尾所言,不是所有代码都可以方便的写出 test case,我们需要逐步改造我们的代码,使他们更容易测试。
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"),
));
}
}
这段代码逻辑很简单,输入一个字符串,将其转换成 int,遇到错误会弹出错误提示框。
但这段代码有一个致命问题,就是它包含了 UI 逻辑,即它不是一个纯逻辑函数。我们在写 Unit test 的时候可以很容易的构造一个 String 入参,但却很难初始化一个 BuildContext,而且弹框的逻辑也没有一个明确的结果,毕竟不是 UI Test。
因此,我们首先要做的,就是将 UI 剥离出该方法,让调用方处理 UI 相关的逻辑。
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;
}
}
如此,stringToInt 方法只剩下单纯的逻辑,我们只需要专注在转换类型这个原始需求上。
但这个函数还可以进一步优化一下。我们发现该函数将转换后的结果存储到了函数外部的一个变量中。因此我们在测试的过程中不免需要引入这个变量。我们应该尽可能让我们测试的对象或方法减少对外部的依赖。我们可以尝试这样修改。
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;
}
}
我们完全可以把转换后的值和失败的情况同时通过返回值返回,这样我们就不再需要依赖一个额外的变量。对于这样一个 testable 的函数,写 test case 将会是一种享受。
expect(stringToInt('123'), 123);
expect(stringToInt('123bda'), null);
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');
}
}
}
这个例子是一个请求页面数据的 ViewModel 类,也更符合我们日常接触的场景了。其中包含了网络请求、翻页、错误处理等逻辑。
我们首先注意到的是 _getData 这个核心方法中包含了一个网络请求。根据我们上一篇中提到的原则,网络请求这种外部影响因子应该使用 mock 的方式进行模拟。
我们需要 mock Client 这个类,然后人为返回了一个 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));
因此我们在 Unit Test 中首先需要 mock 一个假的 client,用于人为控制网络库返回的值。我们已经初始化了 mock 的 client,同时也设置好的返回的内容。但我们如何才能让项目中的 _getData 使用我们创建出来的 MockClient ?这里就涉及到软件开发中的一个设计模式:依赖注入。
为了方便在测试时替换 Client 对象,我们需要在 _getData 执行的过程中,可以通过某种方式替换 Client 对象。实现的方式有多种,最简单的方法就是,将 Client 对象作为 _getData 的参数传递进来,这样 _getData 到底使用什么 Client 对象由调用者来控制。
_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');
}
}
这样设计后,我们在写 test case 时,可以很方便将我们 mock 的 Client 对象注入 _getData 中替换原有的实现。不过这样的设计显然不够优雅,所有调用 _getData 方法的地方都得传递这个参数。我们也可以通过其他方式或者使用三方库来优化我们的代码。
class HTTP {
factory HTTP() => _instance;
static HTTP _instance = HTTP._private();
HTTP._private();
http.Client get client => _client;
http.Client _client = http.Client();
injection(http.Client client) {
_client = client;
}
}
这是其中一种方案。我们一般会将网络库分装成单例,我们可以为 Unit Test 专门留一个方法 injection ,用于修改单例中的 _client 。这样就可以做到全局替换 client 的实例。
我们现在不再需要 client 这个入参,所有使用 client 的地方都可以使用 HTTP().client 来替代。
现在,我们可以尝试写出第一个 test case。
final provider = PageProvider();
provider.loadMore();
expect(provider.messages.length, 1);
等等,结果并不正确,这是为什么?
我们注意到 _getData 是一个异步方法(后面的 async),毕竟内部调用了网络请求。但我们调用的时候没有使用 await 关键字,这意味着调用这个函数后会不会等待函数执行完成,而是直接走到下一个方法中去。即调用者无法得知该异步函数何时结束。
我们可以通过加入一个几百毫秒的 delay 来完成这个测试,但这明显不够严谨。我们可以在这几个函数之前使用 await 关键字,明确下面的方法需要等当前的方法执行完毕后再执行。为了便于使用者理解,我们也最好修改几个函数的返回值,让他们明确返回 Future 来让调用者知道他们是异步方法。
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');
}
}
}
当所有函数都添加了 Future 返回值后,在我们的 test case 中加入 await 来获知方法完成的时机并检查结果。
test('load more message', () async {
final provider = PageProvider();
await provider.loadMore();
expect(provider.messages.length, 1);
});
到此,经过一些改造,我们的代码已经可以愉快地写 unit test 了。这是完整的功能代码部分。
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 get client => _client;
http.Client _client = http.Client();
injection(http.Client client) {
_client = client;
}
}
让我们再补充一些 case。这是完整的 test case 部分。
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);
});
});
}
这样就完成了一份简单的单元测试。当然我们还有很多地方可以进行优化,比如现在 mock 的网络请求返回的数据是死值,我们可以根据 mock 回调的 invocation 参数来获取网络请求的参数 page,根据参数返回相应的数据。再比如我们可以创造一个 case,返回一个 http code 为 200 但 body 为空字符串的结果,测试 jsonDecode 函数的错误处理能力。这里就不一一列举。
通过上面的两个例子,我们大略能够对 unit test 有一个粗略的理解。但每个人接触的项目是不一样的,不同的代码面临的问题也不一致。我总结了一部分规范,能够帮助你写出更加 testable 的代码:
- 单一职责:函数或类的功能要尽可能单一,函数的逻辑应该尽可能简单,一个函数只解决一个问题。
- 低耦合:函数或类应该尽可能少的依赖外部资源。
- 依赖注入:在设计 API 的时候需要确保代码留有注入依赖的入口。
- 纯逻辑:避免在测试方法中引入 UI 或其他非逻辑相关的代码。
- 慎用全局状态:权局变量、静态属性往往都分散且难以控制,可以考虑使用依赖注入的方式通过外部参数传递给方法。(也可以考虑使用 get_it )
- 慎用静态方法:理由同上,而且需要注意 Mockito 还无法 mock 静态方法。
虽然看起来很复杂,但我还是建议你能够尝试写一两个 unit test。一方面,倒逼你写出的 testable 的代码往往有着更好的解耦结构和优雅的 API 设计。另一方面,unit test 真的能够帮助你提前发现一些细小的 bug ,提高程序的健壮性。我自己在写完第一个 unit test 后,就发现了一个真实项目中的 bug 😂。