一、前言
移動應用的測試往往比較複雜且工作量很大,為了驗證使用者的真實使用體驗往往需要跨越多個平台以及不同的實體裝置手動測試。随着産品功能不斷疊代累積,測試的複雜度随之大幅增長,手動測試會變得更加困難。是以,編寫自動化測試用例對我們的項目更新、疊代有着非常重要的作用。
二、單元測試
單元測試是指對軟體中的最小可測試單元進行驗證的方式,使用單元測試可以驗證單個函數、方法或類的行為。我們來看看 Flutter 項目的工程目錄:

如上圖所示,lib 是 Flutter 應用源檔案目錄,test 是測試檔案目錄。接下來我們看看編寫單元測試用例的步驟。
2.1 相關步驟
2.1.1 添加依賴
Flutter 工程預設添加了 flutter_test package,如果是 dart package 沒有依賴 Flutter,可以導入 test package,示例代碼如下:
dev_dependencies: flutter_test: sdk: flutter //or test:
複制
2.1.2 聲明一個用來測試的類
在 lib 目錄下建立一個 dart 檔案,聲明一個用來測試的類,示例代碼如下:
//unit.dart class Counter { int value = 0; void increment() => value++; void decrement() => value--;}
複制
2.1.3 編寫測試用例
在 test 目錄下建立一個 dart 檔案(檔案名建議已 _test 結尾),編寫測試用例。測試用例通常包含含定義、執行和驗證步驟,示例如下:
//unit_test.dart import 'package:flutter_unit_test/unit.dart';import 'package:flutter_test/flutter_test.dart'; void main() { //第一個用例,判斷Counter對象調用increase方法後是否等于1 test('Increase a counter value should be 1', () { final counter = Counter(); counter.increase(); expect(counter.count, 1); }); //第二個用例,判斷1+1是否等于2 test('1+1 should be 2', () { expect(1 + 1, 2); });}
複制
可以看到驗證需要使用 expect 函數,将最小可測單元的執行結果與預期進行比較。另外,測試用例需要包裝在 test() 内部,test 是 flutter 提供的測試用例封裝類。
2.1.4 啟動測試用例
選擇 unittest.dart 檔案,在右鍵彈出的菜單中選擇 “Run ‘tests in widgettest’”,就可以啟動測試用例了。運作結果如下:
接下來我們修改下測試用例代碼,如下:
void main() { //第一個用例,判斷Counter對象調用increase方法後是否等于1 test('Increase a counter value should be 1', () { final counter = Counter(); counter.increase(); expect(counter.count, 2); }); //第二個用例,判斷1+1是否等于2 test('1+1 should be 2', () { expect(1 + 1, 2); });}
複制
可以看到,我們将第一個用例的 1 修改成 2 來制造一個錯誤,現在來看看測試用例執行不通過的提示:
2.1.5 組合測試用例
如果有多個測試用例,它們之間存在關聯關系,可以在外層使用 group 函數将它們組合在一起,示例代碼如下:
void main() { //組合測試用例,判斷Counter對象調用increase方法後是否等于1, // 并且判斷Counter對象調用decrease方法後是否等 group('Counter', () { test('Increase a counter value should be 1', () { final counter = Counter(); counter.increase(); expect(counter.count, 1); }); test('Decrease a counter value should be -1', () { final counter = Counter(); counter.decrease(); expect(counter.count, -1); }); });}
複制
另外,除了上述啟動方式外,還可以使用終端指令來啟動測試用例,示例如下:
//flutter test 檔案路徑flutter test test/unit_test.dart//使用 flutter run 檔案路徑 的方式來運作到真機或模拟器上測試也是可以的
複制
2.2 使用 mockito 模拟外部依賴
進行單元測試時我們可能還需要從外部依賴(比如web服務)擷取需要測試的資料,我們先來看一個示例,在 lib 中建立一個要測試的類:
//mock.dart import 'dart:convert';import 'package:http/http.dart' as http; class Todo { final String title; Todo({this.title}); //工廠類構造方法,将JSON轉換為對象 factory Todo.fromJson(Map<String, dynamic> json) { return Todo( title: json['title'], ); }} Future<Todo> fetchTodo(http.Client client) async { //擷取網絡資料 final response = await client.get('https://xxx.com/todos/1'); if (response.statusCode == 200) { //請求成功,解析JSON return Todo.fromJson(json.decode(response.body)); } else { //請求失敗,抛出異常 throw Exception('Failed to load post'); }}
複制
可以看到與 web 服務的資料互動是我們程式不能夠控制的,很難覆寫所有可能成功或失敗的用例,是以更好的辦法是在測試用例中模拟這些”外部依賴“,讓其可以傳回特定内容。接下來我們來看看使用 mockito 模拟外部依賴的步驟:
2.2.1 添加依賴
在 pubspec.yaml 檔案的 dev_dependencies 中添加 mockito 包:
dependencies: http: ^0.12.2 dev_dependencies: flutter_test: sdk: flutter mockito:
複制
2.2.2 建立模拟類
建立一個模拟類,示例如下:
//mock_test.dart import 'package:mockito/mockito.dart';import 'package:http/http.dart' as http; class MockClient extends Mock implements http.Client {}
複制
可以看到我們定義了一個模拟類 MockClient,這個類以接口聲明的方式擷取到了 http.Client 的外部接口。
2.2.3 編寫測試用例
現在我們可以使用 when 語句,在其調用 Web 服務時注入 MockClient 并傳回相應的資料,代碼如下:
//mock_test.dart import 'package:flutter_test/flutter_test.dart';import 'package:flutter_unit_test/mock.dart';import 'package:mockito/mockito.dart';import 'package:http/http.dart' as http; class MockClient extends Mock implements http.Client {} void main() { group('fetchTodo', () { test('returns a Todo if successful', () async { final client = MockClient(); //使用Mockito注入請求成功的JSON字段 when(client.get('https://xxx.com/todos/1')) .thenAnswer((_) async => http.Response('{"title": "Test"}', 200)); //驗證請求結果是否為Todo實例 expect(await fetchTodo(client), isInstanceOf<Todo>()); }); test('throws an exception if error', () { final client = MockClient(); //使用Mockito注入請求失敗的Error when(client.get('https://xxx.com/todos/1')) .thenAnswer((_) async => http.Response('Forbidden', 403)); //驗證請求結果是否抛出異常 expect(fetchTodo(client), throwsException); }); });}
複制
可以看到在第一個用例中我們為其注入了 json 結果,而在第二個用例中我們注入了一個 403 異常。我們來看看運作結果:
可以看到,在沒有調用真實 Web 服務的情況下我們的程式成功地模拟出了正常和異常兩種情況。
關于 Flutter 的單元測試部分先說到這裡,細心的同學可能發現整個 Flutter 單元測試的模式跟 Android 是非常類似的。
三、UI 自動化測試
3.1 簡單示例
為了測試 widget 類,我們需要使用 flutter _test package。拿一個 Flutter 預設的計時器應用模闆為例:
它的 UI 測試用例可以這麼來寫:
//widget_test.dart import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:flutter_unit_test/widget.dart'; void main() { testWidgets('Counter increments UI test', (WidgetTester tester) async { //聲明所需要驗證的Widget對象(即MyApp),并觸發其渲染 await tester.pumpWidget(MyApp()); //查找字元串文本為'0'的Widget,驗證查找成功 expect(find.text('0'), findsOneWidget); //查找字元串文本為'1'的Widget,驗證查找失敗 expect(find.text('1'), findsNothing); //查找'+'按鈕,施加點選行為 await tester.tap(find.byIcon(Icons.add)); //觸發其渲染 await tester.pump(); //查找字元串文本為'0'的Widget,驗證查找失敗 expect(find.text('0'), findsNothing); //查找字元串文本為'1'的Widget,驗證查找成功 expect(find.text('1'), findsOneWidget); });}
複制
右鍵點選該檔案,選擇 Run 'tests in widget_test.dart' 選項執行測試,測試結果如下:
3.2 相關步驟以及API詳解
flutter_test package 提供了以下工具用于 widget 的測試:
- testWidgets() :此函數會自動為每個測試建立一個 WidgetTester,用來代替普通的 test 函數。
- WidgetTester:使用該類可在測試環境下建立 widget 并與其互動。
- Finder :該類可以友善我們在測試環境下查找 widgets。
- Mathcer 常量:該常量在測試環境下幫助我們驗證 Finder 是否定位到一個或多個 widgets。
接下來我們來看看編寫測試用例的相關步驟:
3.2.1 添加 flutter_test 依賴
在 pubspec.yaml 檔案的 devdependencies 裡添加 fluttertest 依賴,代碼如下:
dev_dependencies: flutter_test: sdk: flutter
複制
3.2.2 建立用于測試的 widget
還是拿 Flutter 預設的計時器應用模闆為例,代碼如下:
import 'package:flutter/material.dart'; void main() { runApp(MyApp());} class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, visualDensity: VisualDensity.adaptivePlatformDensity, ), home: MyHomePage(title: 'Flutter Demo Home Page'), ); }} class MyHomePage extends StatefulWidget { MyHomePage({Key key, this.title}) : super(key: key); final String title; @override _MyHomePageState createState() => _MyHomePageState();} class _MyHomePageState extends State<MyHomePage> { int _counter = 0; void _incrementCounter() { setState(() { _counter++; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text( 'You have pushed the button this many times:', ), Text( '$_counter', style: Theme.of(context).textTheme.headline4, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: Icon(Icons.add), ), // This trailing comma makes auto-formatting nicer for build methods. ); }}
複制
3.2.3 建立一個 testWidgets 測試方法
用 flutter_test package 提供的 testWidgets() 函數定義一個測試。testWidgets 函數可以定義一個 widget 測試并建立一個可以使用的 WidgetTester。
import 'package:flutter_test/flutter_test.dart'; void main() { testWidgets('Counter increments UI test', (WidgetTester tester) async { });}
複制
3.2.4 使用 WidgetTester 建立并渲染 widget
在上一步中我們建立了一個 WidgetTester,使用 WidgetTester 可以在測試環境下建立、渲染 widget 并可以與其進行互動。接下來我們來介紹下 WidgetTester 中常見的 api。
建立/渲染類API
- pumpWidget(Widget widget) :建立并渲染我們提供的 widget。
- pump(Duration duration):觸發 widget 重建。與 pumpWidget 不同之處在于即使 widget 與先前的調用相同, pumpWidget 也會強制完全重建樹,而 pump 将僅重建已更改的 widget。例如我們點選調用 setState() 的按鈕,可以使用 pump 方法來讓 flutter 再一次建立我們的 widget。
- pumpAndSettle():在給定期間内不斷重複調用 pump() 直到完成所有繪制幀,一般需要等到所有動畫全部完成。
互動類API
- enterText():模拟輸入文本。
- tap():模拟點選按鈕。
- drag():模拟滑動。
- longPress():模拟長按。
其他方法這裡不再贅述,如果想深入了解這些内容,可以參考 WidgetTester 進行學習。
3.2.5 使用 Finder 定位(查找) widget
在測試環境下,為了定位 widget,我們需要用到 Finder 類。
text(String text):查找含有特定文本的 widget,例如 find.text('0')。
widgetWithText():限定 widget 的類型,并且該類型 widget 包含給定的文本,例如 find.widgetWithText(Button, '0')。
byKey(Key key):使用具體 key 查找 widget。例如 find.byKey(Key('H'))。
byType(Type type):根據 type 來尋找對應的 widget,type 參數必須是 Widget 的子類,例如 find.byType(IconButton)。
byWidget(Widget widget):根據 widget 執行個體來尋找對應的 widget。示例如下:
Widget myButton = new Button( child: new Text('Update')); find.byWidget(myButton);
複制
- byWidgetPredicate():根據 widget 的屬性比對 widget,示例如下:
如果想深入了解這些内容,可以參考 CommonFinders 進行學習。
3.2.6、使用 Matcher 常量進行驗證
flutter_test 提供了以下 matchers:
- findsOneWidget:找到一個 widget
- findsWidgets:找到一個或多個 widget
- findsNothing:沒有找到 widget
- findsNWidgets:找到指定數量的 widget
例如:
//查找字元串文本為'0'的Widget,驗證查找失敗expect(find.text('0'), findsNothing);
複制
通過以上步驟,我們對 widget 測試有了一定的了解了,現在我們再來看看上面寫的那個 widget 測試用例可以有更深刻的認識了:
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:flutter_unit_test/widget.dart'; void main() { testWidgets('Counter increments UI test', (WidgetTester tester) async { //聲明所需要驗證的Widget對象(即MyApp),并觸發其渲染 await tester.pumpWidget(MyApp()); //查找字元串文本為'0'的Widget,驗證查找成功 expect(find.text('0'), findsOneWidget); //查找字元串文本為'1'的Widget,驗證查找失敗 expect(find.text('1'), findsNothing); //查找'+'按鈕,施加點選行為 await tester.tap(find.byIcon(Icons.add)); //觸發其渲染 await tester.pump(); //查找字元串文本為'0'的Widget,驗證查找失敗 expect(find.text('0'), findsNothing); //查找字元串文本為'1'的Widget,驗證查找成功 expect(find.text('1'), findsOneWidget); });}
複制
盡管 widget 測試擴大了應用的測試範圍,可以找到單元測試無法找到的問題,不過相比于單元測試來說,widget 測試用例的開發和維護成本非常高,是以建議在項目達到一定的規模,并且業務特征具有一定的延續規律後,再考慮 widget 測試的必要性。
更多精彩請關注騰訊VTeam技術團隊公衆号