Flutter 如何寫測試案例

陳小嬰
13 min readJun 19, 2019

--

單元測試、元件測試、集成測試

單元測試(Unit Test)

用於測試單一函數、方法、類別。

首先是Flutter官網,一步一步教你如何寫單元測試。

基本判斷式:

// 判斷是否相等
expect(actual,matcher);
// 判斷是否不為空
expect(actual, isNotNull);
// 同expect,但不檢查尚未完成的異步API請求
expectSync();
// 同expect,但在符合時回傳一個Future
expectLater();

matcher:

expect("foo,bar,baz", allOf([
contains("foo"),
isNot(startsWith("bar")),
endsWith("baz")
]));

相關的測試放在一個group:

group('testSomeFunc', () {  test('test1', () {    expect(width, 50);  });  test('test2', () {    expect(height, 100);  });}

初始設定:

每次測試運行前的初始函數,在測試案例中只被調用一次。

可以異步運行,且返回Future。

void main() {  MovieList movieList;  setUpAll(() {    // 這個檔案中的所有測試運行前  });  setUp(() {   // 每個測試案例運行前    print('setup 1');    movieList = new MovieList();  });  tearDownAll(() {    // 這個檔案中的所有測試結束後
print('tearDown');
}); group("group test 1", () { setUp(() { // group 裡面的每個測試案例運行前

print('setup 2');
});

test('test 1', () {

});
});}

這樣一來每個case 都會進入setup 1、setup 2,適用於初始資料和狀態,使每個test case 都是新的開始,測試資料的乾淨度會影響測試結果。

terminal執行:

$flutter test test/name_test.dart

或 VSCode 選擇 Debug / Start Debugging

然後重點來了!測試的精髓在於你寫了什麼?

單元測試的核心概念很簡單,就是你的input和output是否符合期望值。

↑ 裡面有等價類劃分和邊界值分析

寫測試就等於給你的程式上一個石膏模,當你程式該有的形狀被改壞時,石膏模就會破掉,提醒你要重新檢查程式和測試。

— — — — — (以下進入小劇場模式) — — — — —

某天收到一個issue說要新增一個計算BMI的功能,然後很快就生出code。

double getBMI(double hieght, double weight) {  return weight / (hieght * hieght);}

嗯……寫是寫完了,但出來結果對不對咧?寫個測試案例try try看。

group('test getBMI', () {  test('check result', () {    TestBMI bmi = TestBMI();    expect(bmi.getBMI(155, 52), 21.6);  });});

然後就收到紅字條……

很明顯就是單位錯惹!立馬改!

double getBMI(double hieght, double weight) {
//身高單位轉為公尺
double hieghtUnitM = hieght / 100;
double result = weight / (hieghtUnitM * hieghtUnitM);
//四捨五入取小數點後一位,不足補0
return num.parse(result.toStringAsFixed(1));
}

再Try一次,看到綠色勾勾就安心了~

這是一般開發時都會做的事,測試input和output是否符合期待,但差別在於開發完成後不一定會留下紀錄。現在功能好像完成了,但還沒有足夠的信心說完全沒問題,那就多幾個測試吧!

發揮你內心的邪惡力量,想想如果要把這支程式搞壞,有哪些方法?

//TODO: 身高體重等於0
//TODO: 身高體重為負值
//TODO: 身高單位錯誤

然後經過邊界值分析和等價類劃分之後,就能產出測試案例。

test('正常的輸入', () {  TestBMI bmi = TestBMI();  expect(bmi.getBMI(155, 52), 21.6);  expect(bmi.getBMI(160, 60), 23.4);  expect(bmi.getBMI(1, 1), 10000.0);});test('身高體重為0', () {  TestBMI bmi = TestBMI();  expect(bmi.getBMI(0, 0), 0);  expect(bmi.getBMI(1, 0), 0);  expect(bmi.getBMI(0, 1), 0);});test('身高體重為負值', () {  TestBMI bmi = TestBMI();  expect(bmi.getBMI(-150, 25), 11.1);  expect(bmi.getBMI(120, -35), -24.3);  expect(bmi.getBMI(-180, -75), -23.1);});test('身高單位錯誤', () {  TestBMI bmi = TestBMI();  //公尺  expect(bmi.getBMI(1.5, 53), 235555.6);  //毫米  expect(bmi.getBMI(1500, 53), 0.2);});

再來一張紅字條,原來(0,0)進去會是空值。

把參數為0篩選掉直接回傳0,或判斷計算結果為空值就回傳0,解決方式很多總之就是解決了!

— — — — — (小劇場模式結束) — — — — —

最後思考一下,還有哪些方式可以確保code被改壞了,測試就不會通過。

能抓出BUG的測試就是好測試。

元件測試(Widget Test)

用於測試單一元件的相關設定與動作。

基本判斷式:

//沒有找到符合元件
expect(find.text('test'), findsNothing);
//找到一個符合元件
expect(find.text('test'), findsOneWidget);
//找到特定數量符合元件
expect(find.text('test'), findsNWidgets(n));

取得元件:

Widget myButton = new IconButton(  color: YBColor.silver,  key: Key('someWidgetKey'),  icon: ImageIcon(AssetImage('images/menu.png')),);

目前偏好用Key來識別唯一性,用Type來判斷數量是否正確。

//用唯一的key找
find.byKey(Key('someWidgetKey')
//用類別找
find.byType(IconButton);
//用圖示找
find.byIcon(icon);
//用元件本身找
find.byWidget(myButton);

p.s. 有些情況key的使用是要特別注意的,可能會影響效能,詳細內容請看Flutter中key的作用

動作:

testWidgets('測試某某功能', (WidgetTester tester) async {  // 點擊
await tester.tap(find.text('click'));
// 在測試環境中,狀態改變後flutter不會自動重新建構Widgets
// 因此須於模擬動作之後觸發渲染
// 觸發畫面渲染
await tester.pump();
// 在給定的時間內持續觸發畫面渲染,基本上會等待所有動畫完成
// 若時間內動畫仍在進行,將拋出超時錯誤
await tester.pumpAndSettle();
}

執行方法跟單元測試一樣:

$flutter test test/name_test.dart

集成測試(Integration Test)

用於驗證測試的元件和服務是否如預期運作。運行於實機或模擬器。

以下不清楚的請直接閱讀 官網詳細教學文

1.在pubspec.yaml新增flutter_driver:

dev_dependencies:
flutter_driver:
sdk: flutter
test: any

2.在lib同級新增 test_driver 資料夾,資料夾內新增檔案:app.dart、 app_test.dart

counter_app/
lib/
main.dart
test_driver/
app.dart
app_test.dart

3.test_driver/app.dart內容:

import 'package:flutter_driver/driver_extension.dart';
import 'package:counter_app/main.dart' as app;
void main() {
//啟用FlutterDriver
enableFlutterDriverExtension();
//調用main()或runApp開始測試
app.main();
}

4.test_driver/app_test.dart內容:

import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';

void main() {
//分群測試
group('Test Some Process', () {
//使用find.byValueKey取得定義元件
final textFinder = find.byValueKey('counter');
final buttonFinder = find.byValueKey('increment');

FlutterDriver driver;

//進行測試前連線到Flutter driver
setUpAll(() async {
driver = await FlutterDriver.connect();
});

//測試完畢後斷線
tearDownAll(() async {
if (driver != null) {
driver.close();
}
});
//測試案例1
test('starts at 0', () async {
//取得文字內容並驗證
expect(await driver.getText(counterTextFinder), "0");
});
//測試案例2
test('increments the counter', () async {
//點擊按鈕
await driver.tap(buttonFinder);

//驗證文字內容
expect(await driver.getText(counterTextFinder), "1");
});
});
}

5.terminal執行:

$flutter drive --target=test_driver/app.dart

Unit和Widget測試不需要,但集成測試跑在模擬器或實機上,若要分辨在哪個平台運行,目前已知這方法有些問題,用iPhone實機測試但判斷成MacOS,還有待釐清……

if (Platform.isIOS) {  print('Platform is iOS');} else if (Platform.isMacOS) {  print('Platform is MacOS');} else if (Platform.isAndroid) {  print('Platform is Android');} else if (Platform.isWindows) {  print('Platform is Windows');} else if (Platform.isFuchsia) {  print('Platform is Fuchsia');} else if (Platform.isLinux) {  print('Platform is Linux');} else {  print('other');}

持續集成服務(Continuous integration services)

CI可於推送代碼時自動運行測試,即時反饋測試結果。

官網是建議使用Fastlane,但根據過往使用Fastlane的經驗,自動發佈的功能不太用到,自動測試的功能用gitLab的CI/CD取代,加上Fastlane建置和維護成本稍高,因此暫時不考慮使用Fastlane。

覆蓋率(Code coverage)

重新計算覆蓋率,產生coverage/Icov.info檔案

$flutter test --coverage

使用Atom Icov-info查看覆蓋率:

  1. 安裝Atom後,用Atom安裝Icov-info。
  2. 用Atom開啟專案,開啟一個Dart檔案。
  3. 在選單上點選Packages > Lcov Info > Toggle,就會看到覆蓋率進度條。
  4. 覆蓋率低於75%進度條是紅色,接著橘色,最後達到100%顯示綠色。

哪裡有蓋到哪裡沒有,code上也都用紅色和綠色標得清清楚楚。

最後提醒各位,覆蓋率就跟code行數一樣參考用,並沒有很直接的關連保證100%的覆蓋率就不會有疏漏,或是沒有達到100%覆蓋率就無法抓出問題,寫測試的目的各不相同,只能說見仁見智。

--

--

陳小嬰
陳小嬰

Written by 陳小嬰

喜愛動物又注重環保的iOS工程師就是我。Write the code change the world.

No responses yet