Flutter 单元测试

官方文档

当 App 中的功能越来越多的时候,我们想要去手动测试一个功能的时候,会变的非常麻烦,这个时候就需要单元测试来帮助我们测试想要测的功能。

Flutter 中提供了三种测试:

  • unit test : 单元测试
  • widget test : Widget 测试
  • integration test : 集成测试

这里记录下前两种。

当创建一个新的 Flutter 工程之后,工程目录下就会有一个 test 目录,该目录用来存放测试文件:

image

单元测试

单元测试用来验证代码中的某一个方法或者某一块逻辑是否正确。写单元测试的步骤如下:

  1. 添加 test 或者 flutter_test 依赖到工程中
  2. test 目录下创建一个测试文件,如: counter_test.dart
  3. 创建一个待测试的文件,如: counter.dart
  4. counter_test.dart 文件中编写 test
  5. 如果有多个测试的需要在一起测试的情况下,可以使用 group
  6. 运行测试类

1. 添加依赖

在工程的 pubspec.yaml 中添加 flutter_test 的依赖:

dev_dependencies:
  flutter_test:
    sdk: flutter

2. 创建测试文件

这里,需要创建两个文件,一个是测试类文件 counter_test.dart 还有一个是被测试文件counter.dart。当这两个文件创建完之后,目录结构如下:

.
├── lib
│   ├── counter.dart
├── test
│   ├── counter_test.dart

3. 编写被测试类

Counter 类中的方法如下:

class Counter {
  int value = 0;

  void increment() => value++;

  void decrement() => value--;
}

4.编写测试类

counter_test.dart 文件中编写单元测试,里面会使用到一些 flutter_test 包提供的顶层方法,如 test(...) 方法是用来定义一个单元测试的,还有就是 expect(...) 方法用来验证结果的。

test(...) 方法里面有两个必需的参数,第一个参数表示这个单元测试的描述信息,第二个是一个 Function,用来编写测试内容的。

expect(...) 方法中也有两个必需的参数,第一个是需要验证的变量,第二个是与该变量匹配的值。

counter_test.dart 中的代码如下:

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_testing/counter.dart';

/// 也可以使用命令来运行 flutter test test/counter_test.dart

void main() {
  // 单一的测试
  test("测试 value 递增", () {
    final counter = Counter();
    counter.increment();
    
    // 验证 counter.value 的是是否为 1
    expect(counter.value, 1);
  });

5.使用 group 来执行多个测试

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_testing/counter.dart';

void main() {
  // 使用 group 合并多个测试。用来测试多个有关联的测试
  group("Counter", () {
    test("value should start at 0", () {
      expect(Counter().value, 0);
    });

    test("value should be increment", () {
      final counter = Counter();

      counter.increment();

      expect(counter.value, 1);
    });

    test("value should be decremented", () {
      final counter = Counter();

      counter.decrement();

      expect(counter.value, -1);
    });
  });
}

6.执行单元测试

如果使用的是 Android Studio 或者 Idea 开发的话,那么直接点击侧边的运行按钮来执行或者调试:

image

如果使用的是 VSCode ,则可以使用命令来执行测试:

flutter test test/counter_test.dart

网络接口测试

同样的,在 test 目录下新建一个文件,如:http_test.dart,在这个文件中去请求一个接口,然后验证返回的结果:

import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;

void main() {
  test("测试网络请求", () async {
    // 假如这个请求需要一个 token
    final token = "54321";
    final response = await http.get(
      "https://api.myjson.com/bins/18mjgh",
      headers: {"token": token},
    );
    if (response.statusCode == 200) {
      // 验证请求 header 中的 token
      expect(response.request.headers['token'], token);
      print(response.request.headers['token']);
      print(response.body);
      // 解析返回的 json
      Person person = parsePersonJson(response.body);
      // 验证 person 对象不为空
      expect(person, isNotNull);
      // 检测 person 对象中的属性值是否都正确
      expect(person.name, "Lili");
      expect(person.age, 20);
      expect(person.country, 'China');
    }
  });
}

使用 Mockito 来模拟对象依赖

首先,添加 mockito 的依赖到 pubspec.yaml 中:

dev_dependencies:
  mockito: 4.1.1

然后新建一个被测试的类:

class A {
  int calculate(B b) {
    int randomNum = b.getRandomNum();
    return randomNum * 2;
  }
}

class B {
  int getRandomNum() {
    return Random().nextInt(100);
  }
}

上述代码中,类 A 的 calculate 方法是依赖类 B 的。这时测试 calculate 方法的时候可以使用 mockito 来模拟一个类 B

接着新建一个测试类:

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_testing/mock_d.dart';
import 'package:mockito/mockito.dart';

/// 使用 mockito 模拟一个类 B
class MockB extends Mock implements B {}

void main() {
  test("测试使用 mockito 来 mock 依赖", () {
    var b = MockB();
    var a = A();
    // 当调用 b.getRandomNum() 方法的时候返回 10
    when(b.getRandomNum()).thenReturn(10);
    expect(a.calculate(b), 20);

    // 检查 b.getRandomNum(); 是否调用过
    verify(b.getRandomNum());
  });
}

官方文档上还有一个这样的例子,是使用 mockito 来模拟接口返回的数据,要测试的方法如下:

Future<Post> fetchPost(http.Client client) async {
  final response =
      await client.get("https://jsonplaceholder.typicode.com/posts/1");
  if (response.statusCode == 200) {
    return Post.fromJson(json.decode(response.body));
  } else {
    throw Exception('Failed to load post');
  }
}

上述方法中就是请求一个接口,请求成功则解析返回,否则抛出异常。测试该方法的代码如下:

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_testing/post_service.dart';
import 'package:http/http.dart' as http;
import 'package:mockito/mockito.dart';

/// 使用 mock 模拟一个 http.Client 对象
class MockClient extends Mock implements http.Client {}

void main() {
  group("fetchPost", () {
    test("接口返回数据正确", () async {
      final client = MockClient();

      // 当调用指定的接口的时候返回指定的数据
      when(client.get("https://jsonplaceholder.typicode.com/posts/1"))
          .thenAnswer((_) async {
        return http.Response(
            '{"title": "test title", "body": "test body"}', 200);
      });
      var post = await fetchPost(client);
      expect(post.title, "test title");
    });

    test("接口返回数据错误,抛出异常", () {
      final client = MockClient();

      // 当调用这个接口的时候返回 Not Found
      when(client.get("https://jsonplaceholder.typicode.com/posts/1"))
          .thenAnswer((_) async {
        return http.Response('Not Found', 404);
      });
      expect(fetchPost(client), throwsException);
    });
  });
}

Widget 测试

Widget 测试和单元测试一个很明显的区别就是 Widget 测试使用的顶层函数是 testWidgets,该函数的写法如下:

testWidgets('这是一个 Widget 测试', (WidgetTester tester){

});

我们可以使用 WidgetTester 来 build 需要测试的 widget,或者执行重绘(相当于调用了 setState(...) 方法。

还有就是可以使用另外一个顶层函数 find 来定位到需要操作的 widget,如:

find.text('title'); // 通过 text 来定位 widget
find.byIcon(Icons.add); // 通过 Icon 来定位 widget
find.byWidget(myWidget); // 通过 widget 的引用来定位 widget
find.byKey(Key('value')); // 通过 key 来定位 widget

测试页面中是否包含某一个 widget

待测试的页面 MyWidget

class MyWidget extends StatelessWidget {
  final String title;
  final String message;

  const MyWidget({Key key, @required this.title, @required this.message})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(
          title: Text(title),
        ),
        body: Center(
          child: Text(message),
        ),
      ),
    );
  }
}

上述页面中,有两个 Text 分别为 text(title) 和 text(message),下面编写测试类来验证页面中是否包含着两个 Text:

  testWidgets("MyWidget has a title and message", (WidgetTester tester) async {
    // 加载 MyWidget
    await tester.pumpWidget(MyWidget(
      title: "T",
      message: "M",
    ));

    final titleFinder = find.text('T');
    final messageFinder = find.text('M');
    
    // 验证页面中是否含有上述的两个 Text
    expect(titleFinder, findsOneWidget);
    expect(messageFinder, findsOneWidget);
  });

注意:待测试的 widget 需要用 MaterialApp() 包裹;

上述代码中的 findsOneWidget 表示在页面中发现了一个与 titleFinder 对应的 Widget,与之对应的还有 findsNothing 表示页面中没有要寻找的 Widget

测试页面中和用户交互的部分

上一个实例中,我们使用 WidgetTester 来找页面中的 widget,WidgetTester 还能帮助我们模拟输入,点击,滑动操作,下面,还是官方的例子:

待测试的页面如下:

import 'package:flutter/material.dart';

/// Date: 2019-09-29 14:44
/// Author: Liusilong
/// Description:
//

class TodoList extends StatefulWidget {
  @override
  _TodoListState createState() => _TodoListState();
}

class _TodoListState extends State<TodoList> {
  static const _appTitle = 'Todo List';
  final todos = <String>[];
  final controller = TextEditingController();
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _appTitle,
      home: Scaffold(
        appBar: AppBar(
          title: Text(_appTitle),
        ),
        body: Column(
          children: <Widget>[
            TextField(
              controller: controller,
            ),
            Expanded(
              child: ListView.builder(
                  itemCount: todos.length,
                  itemBuilder: (BuildContext context, int index) {
                    final todo = todos[index];
                    return Dismissible(
                      key: Key('$todo$index'),
                      onDismissed: (direction) => todos.removeAt(index),
                      child: ListTile(title: Text(todo)),
                      background: Container(color: Colors.red),
                    );
                  }),
            ),
          ],
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            setState(() {
              if (controller.text.isNotEmpty) {
                todos.add(controller.text);
                controller.clear();
              }
            });
          },
          child: Icon(Icons.add),
        ),
      ),
    );
  }
}

该页面的运行效果如下:

image

测试类如下:

  testWidgets('Add and remove a todo', (WidgetTester tester) async {
    // Build the widget
    await tester.pumpWidget(TodoList());
    // 往输入框中输入 hi
    await tester.enterText(find.byType(TextField), 'hi');
    // 点击 button 来触发事件
    await tester.tap(find.byType(FloatingActionButton));
    // 让 widget 重绘
    await tester.pump();
    // 检测 text 是否添加到 List 中
    expect(find.text('hi'), findsOneWidget);

    // 测试滑动
    await tester.drag(find.byType(Dismissible), Offset(500.0, 0.0));

    // 页面会一直刷新,直到最后一帧绘制完成
    await tester.pumpAndSettle();

    // 验证页面中是否还有 hi 这个 item
    expect(find.text('hi'), findsNothing);

  });

其实我感觉只要业务逻辑和 UI 分离开来,单元测试写起来还是比较方便的。

最近项目开始逐步转向使用 Provider 来进行状态的管理。建议看看 Flutter Architecture - My Provider Implementation Guide 这个系列的文章,讲的很好。

大致结构如下:


image

最后,看了 My Provider Implementation Guide 系列的文章之后,写了一个 APP,有兴趣的可以下载体验下。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容