Flutter 單元測試

官方文檔json

當 App 中的功能愈來愈多的時候,咱們想要去手動測試一個功能的時候,會變的很是麻煩,這個時候就須要單元測試來幫助咱們測試想要測的功能。api

Flutter 中提供了三種測試:bash

  • unit test : 單元測試
  • widget test : Widget 測試
  • integration test : 集成測試

這裏記錄下前兩種。markdown

當建立一個新的 Flutter 工程以後,工程目錄下就會有一個 test 目錄,該目錄用來存放測試文件:網絡

單元測試

單元測試用來驗證代碼中的某一個方法或者某一塊邏輯是否正確。寫單元測試的步驟以下:app

  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 的依賴:less

dev_dependencies:
 flutter_test:
 sdk: flutter
複製代碼

2. 建立測試文件

這裏,須要建立兩個文件,一個是測試類文件 counter_test.dart 還有一個是被測試文件counter.dart。當這兩個文件建立完以後,目錄結構以下:dom

.
├── lib
│   ├── counter.dart
├── test
│   ├── counter_test.dart
複製代碼

3. 編寫被測試類

Counter 類中的方法以下:async

class Counter {
  int value = 0;

  void increment() => value++;

  void decrement() => value--;
}
複製代碼

4.編寫測試類

counter_test.dart 文件中編寫單元測試,裏面會使用到一些 flutter_test 包提供的頂層方法,如 test(...) 方法是用來定義一個單元測試的,還有就是 expect(...) 方法用來驗證結果的。ide

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 開發的話,那麼直接點擊側邊的運行按鈕來執行或者調試:

若是使用的是 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),
        ),
      ),
    );
  }
}

複製代碼

該頁面的運行效果以下:

測試類以下:

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 這個系列的文章,講的很好。

大體結構以下:

最後,看了 My Provider Implementation Guide 系列的文章以後,寫了一個 APP,有興趣的能夠下載體驗下。

相關文章
相關標籤/搜索