當 App 中的功能越來越多的時候,我們想要去手動測試一個功能的時候,會變的非常麻煩,這個時候就需要單元測試來幫助我們測試想要測的功能。
Flutter 中提供了三種測試:
- unit test : 單元測試
- widget test : Widget 測試
- integration test : 整合測試
這裡記錄下前兩種。
當建立一個新的 Flutter 工程之後,工程目錄下就會有一個 test 目錄,該目錄用來存放測試檔案:
單元測試
單元測試用來驗證程式碼中的某一個方法或者某一塊邏輯是否正確。寫單元測試的步驟如下:
- 新增 test 或者 flutter_test 依賴到工程中
- 在 test 目錄下建立一個測試檔案,如:
counter_test.dart
- 建立一個待測試的檔案,如:
counter.dart
- 在
counter_test.dart
檔案中編寫test
- 如果有多個測試的需要在一起測試的情況下,可以使用
group
- 執行測試類
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 開發的話,那麼直接點選側邊的執行按鈕來執行或者除錯:
如果使用的是 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,有興趣的可以下載體驗下。