官方文檔json
當 App 中的功能愈來愈多的時候,咱們想要去手動測試一個功能的時候,會變的很是麻煩,這個時候就須要單元測試來幫助咱們測試想要測的功能。api
Flutter 中提供了三種測試:bash
這裏記錄下前兩種。markdown
當建立一個新的 Flutter 工程以後,工程目錄下就會有一個 test 目錄,該目錄用來存放測試文件:網絡
單元測試用來驗證代碼中的某一個方法或者某一塊邏輯是否正確。寫單元測試的步驟以下:app
counter_test.dart
counter.dart
counter_test.dart
文件中編寫 test
group
在工程的 pubspec.yaml
中添加 flutter_test
的依賴:less
dev_dependencies:
flutter_test:
sdk: flutter
複製代碼
這裏,須要建立兩個文件,一個是測試類文件 counter_test.dart
還有一個是被測試文件counter.dart
。當這兩個文件建立完以後,目錄結構以下:dom
.
├── lib
│ ├── counter.dart
├── test
│ ├── counter_test.dart
複製代碼
Counter
類中的方法以下:async
class Counter {
int value = 0;
void increment() => value++;
void decrement() => value--;
}
複製代碼
在 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);
});
複製代碼
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);
});
});
}
複製代碼
若是使用的是 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 的依賴到 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 測試使用的頂層函數是 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
複製代碼
待測試的頁面 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,有興趣的能夠下載體驗下。