根據Flutter官方文件,Testing可以分成3個類別:
- A unit test tests a single function, method, or class.
- A widget test (in other UI frameworks referred to as component test) tests a single widget.
- An integration test tests a complete app or a large part of an app.
由於時間和目前專案大小的考量,這次的內容重心只會在撰寫Unit Test和簡單的Widget Test上。
今天就先以官方提供的Unit Test和Widget Test的範例體會一下何謂「Test」。還會介紹到「Mockito」這個能夠模擬呼叫web Service或是資料庫運作的Package。
Unit Test中文稱作單元測試,用來驗證單一功能是否有如預期的運作,單元可以是一個Function、Method或是一個Class。
對比其他類型的測試,單元測試相對好撰寫,因為單一功能會遇到的情形並不多,比較好列舉出來做測試。
單元測試也像另類的使用文件,測試寫得好其他人可以依照你的測試程式碼了解該如何使用某功能或是針對可能產生的例外先行處理
寫單元測試前要先引入 test package。
接下來就用官方提供的例子來介紹吧。
假如有一個名為Counter
的Class長的像這樣,裡頭的method相當簡單只有加減value
這個變數而已。
class Counter {
int value = 0;
void increment() => value ++;
void decrement() => value --;
}
寫單元測試時要列舉使用這個功能可能有的結果,基本會有以下三種情形:
increment()
,變數value的值變為1decrement()
,變數value的值變為-1寫出來的單元測試就會像這樣:
import 'package:test/test.dart';
class Counter{
int value = 0;
increment() => value ++;
decrement() => value --;
}
void main() {
group('Counter', () {
test('value should start at 0', () {
expect(Counter().value, 0);
});
test('value should be incremented', () {
final counter = Counter();
counter.increment();
expect(counter.value, 1);
});
test('value should be decremented', () {
final counter = Counter();
counter.decrement();
expect(counter.value, -1);
});
});
}
因為進行測試同樣是在執行一個程式,所以一樣會需要main()
作為程式的入口。
group
是「test package」提供的function,能夠把多個test組合成一個群組,一起做測試。
test
是單元測試的最小單位,它的第一個參數
String
用來說明這個測試的目的,後面的匿名函式就是你要測試的程式碼。
expect
用來檢查第一個參數和第二個參數的值是否相同,expect可以結合「matcher package」提供的函式,能夠判斷更複雜的值。
在上面的範例中,每一個測試都需要初始化一個Counter的物件,如果有這種重複性的行為,可以使用setUp()
,在執行各測試前會先執行setUp()
內的程式碼;另外還有對應的tearDown()
它是會在測試執行完後才被呼叫。
使用setUp()
改寫成以下:
import 'package:test/test.dart';
class Counter{
int value = 0;
increment() => value ++;
decrement() => value --;
}
void main() {
Counter counter;
group('Counter', () {
setUp((){
counter = Counter();
});
test('value should start at 0', () {
expect(counter.value, 0);
});
test('value should be incremented', () {
counter.increment();
expect(counter.value, 1);
});
test('value should be decremented', () {
print(counter.value);
counter.decrement();
expect(counter.value, -1);
});
});
}
通過測試的畫面:
故意把初始值改為1,產生測試失敗的畫面:
相較於單元測試是在測試單一功能的運作結果,Widget Test是要測試介面有按照預期的呈現在畫面上。就像在模擬人實際操作App,要考慮到使用者可能的各種行為,使用自動化的方式讓程式來幫我們完成這一系列的操作並檢查呈現的結果是否如預期。
因為要測試的是介面的呈現,所以需要模擬出介面,並用互動的方式做測試。好在Flutter提供的「flutter_test」內有能建立測試環境的WidgetTester
以及能夠搜尋Widget的Finder
讓我們使用。
接下來就用Flutter的計步器作為範例,這個測試在建立專案的時候會自動產生,所以你可以開一個新專案來練習
你可以在test資料夾下找到widget_test.dart,這個檔案在測試計步器有沒有正確運作。
程式碼並不長,看起來就像用文字來描述操作Widget的步驟。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:test_flutter/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}
名詞介紹:
testWidgets():和前面單元測試中的test()相同,不過testWidget會產生WidgetTester
WidgetTester:建立能夠和widget互動的測試環境
Finder:使用關鍵字或是特別的性質(例子中的icon)找到特定widget
Matcher:前面提到的matcher是for值的,這邊則是用來比對widget的狀態
剛開始要使用tester.pumpWidget()
來初始化要進行測試的widget,接著就能依照需求做測試。
範例中使用tester.tap()
來做點擊Button的模擬,而後面的tester.pump()
則用來重製畫面,假如沒有這行畫面就不會更新。
有時要進行測試的功能是要從Web Services或是資料庫取得資料(例如昨天寫的Movie API),假如要使用真實的過程取得資料來做測試會有幾個問題:
為了預防以上幾個問題,若要測試這類的功能,有「Fake」、「Stub」以及「Mock」幾種方式可以解決。
對於這三種詳細的說明以及圖片可以看這篇medium文章
在這個專案中,我們要測試的是Bloc和Movie API是否有按照預期的運作所以採用Mock來進行模擬。
而Mockito就是個提供許多方便函式便於實作Mock的Package,接著就來看一個使用Mockito的範例吧。
開始測試前要先引入 test 和 Mockito。
範例中要針對fetchPost()
進行測試,長得跟昨天呼叫Movie API的Function很像對吧,都是使用http.get發出request然後對回傳值的statusCode判斷Request是否有成功。
import 'package:http/http.dart';
import 'dart:convert';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
class Post {
int userId;
int id;
String title;
String body;
Post.fromJson(Map<String, dynamic> parsedJson){
userId = parsedJson['userId'];
id = parsedJson['id'];
title = parsedJson['title'];
body = parsedJson['body'];
}
}
Future<Post> fetchPost(Client client) async {
final response =
await client.get('https://jsonplaceholder.typicode.com/posts/1');
if (response.statusCode == 200) {
// If the call to the server was successful, parse the JSON.
return Post.fromJson(json.decode(response.body));
} else {
// If that call was not successful, throw an error.
throw Exception('Failed to load post');
}
}
class MockClient extends Mock implements Client {}
main() {
group('fetchPost', () {
test('returns a Post if the http call completes successfully', () async {
final client = MockClient();
// Use Mockito to return a successful response when it calls the
// provided http.Client.
when(client.get('https://jsonplaceholder.typicode.com/posts/1'))
.thenAnswer((_) async => Response('{"title": "Test"}', 200));
expect(await fetchPost(client), isA<Post>());
});
test('throws an exception if the http call completes with an error', () {
final client = MockClient();
// Use Mockito to return an unsuccessful response when it calls the
// provided http.Client.
when(client.get('https://jsonplaceholder.typicode.com/posts/1'))
.thenAnswer((_) async => Response('Not Found', 404));
expect(fetchPost(client), throwsException);
});
});
}
class MockClient extends Mock implements Client {}
就是在定義Client()的Mock類別,如此一來在呼叫fetchPost時就能把MockClient實體當作參數傳進去模擬原本的Client。
when(...).thenAnswer((_) async => Response(...);
設定當呼叫到when裡面的程式碼要給什麼樣的回傳值,以這個例子來說會回傳一個Response的物件回去讓程式碼繼續往下執行。
最後一樣用expect
來判斷結果是否符合預期。
今天介紹Flutter的單元測試和Widget Test的概念並用簡單的例子讓各位體驗一下test的程式碼大概是長什麼樣子以及是如何運作的。
明天和後天會使用Mockito套件來完成對Bloc和Movie API的測試,若對怎麼撰寫測試有興趣的朋友,就明天見啦。