Flutter 單元測試

劉斯龍發表於2019-09-29

官方文件

當 App 中的功能越來越多的時候,我們想要去手動測試一個功能的時候,會變的非常麻煩,這個時候就需要單元測試來幫助我們測試想要測的功能。

Flutter 中提供了三種測試:

  • unit test : 單元測試
  • widget test : Widget 測試
  • integration test : 整合測試

這裡記錄下前兩種。

當建立一個新的 Flutter 工程之後,工程目錄下就會有一個 test 目錄,該目錄用來存放測試檔案:

Flutter 單元測試

單元測試

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

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

Flutter 單元測試

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

複製程式碼

該頁面的執行效果如下:

Flutter 單元測試

測試類如下:

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

大致結構如下:

Flutter 單元測試

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

相關文章