在前幾天的 Widget Test 介紹中,我們只介紹了如何測試畫面結果,如何使用測試替身,幾乎沒有談到測試使用者互動的部分。當畫面上提供資訊給使用者,使用者就可以透過按下按鈕、滑動列表、長按等等操作與程式互動,今天就來介紹這些一些常見的操作吧。
假設今天我們想做一個聊天室 App,一進入 App 有一個顯示所有聊天室的列表,當使用者點擊右下角的 Floating Action Button 時,會跳出一個創建聊天室的視窗。建立完聊天室後,回到聊天室列表就會看到新的聊天室。[範例連結]
在測試中我們使用 Fake 來輔助測試,讓呼叫創建聊天室 API 不需要真的去打後端就能測試。首先,我們先建立畫面跟測試替身。
main() {
testWidgets("create chat room success", (tester) async {
await tester.pumpWidget(Provider<ChatRoomRepository>.value(
value: FakeChatRoomRepository(),
child: const ChatRoomPage(),
));
});
}
class FakeChatRoomRepository implements ChatRoomRepository {
final List<ChatRoom> chatRooms = [];
@override
List<ChatRoom> get() => chatRooms;
@override
void add(ChatRoom chatRoom) {
chatRooms.add(chatRoom);
}
}
我們在介紹 Widget Test 的第一天中,在 Counter 範例的測試中,已經用過 WidgetTester 的 tap 方法來模擬使用者點擊了,在這個測試中也是一模一樣的做法。
main() {
testWidgets("create chat room success", (tester) async {
await tester.pumpWidget(Provider<ChatRoomRepository>.value(
value: FakeChatRoomRepository(),
child: const ChatRoomPage(),
));
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text("建立"), findsOneWidget);
});
}
有觀眾朋友可能會好奇,為什麼要驗證建立按鈕有沒有出現呢?由於 Widget Test 在執行的時候,開發人員無法看到畫面,但是如果我們把測試寫完後,再來跑測試的話,最後發現測試錯了,開發人員會很難找到發生的原因,所以我們可以在中途用 expect 確保測試執行到這邊都是符合預期。
回到測試,我們執行一下,也確實得到綠燈,那我們就可以進行下一步驟了。
在 Widget Test 中,大部分的動作都是透過 WidgetTester 這個物件幫忙完成,像是我們接下來要做的輸入文字,就可以使用 enterText 方法。呼叫 enterText 的時候,我們需要給它一個 Finder 參數,告訴他我們想在哪個 Widget 上輸入文字。畫面上有兩個 TextField,問題就回到我們前天討論的議題了,如果我們直接使用 find.byType 傳入 TextField 肯定是不行的,因為畫面上有兩個 TextField,這邊我們就用 find.byWidgetPredicate 吧,分別找到相對應的 TextField。
接著我們就能在 enterText 的第二個參數放上要輸入的文字。
testWidgets("create chat room success", (tester) async {
...
await tester.enterText(
find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == "聊天室名稱"),
"地球暖化討論群",
);
await tester.enterText(
find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == "聊天室說明"),
"愛地球",
);
});
最後一樣是用 tap 方法點擊按鈕,完成聊天室創建。
testWidgets("create chat room success", (tester) async {
...
await tester.tap(find.text("建立"));
await tester.pump();
});
完成測試之後並執行,最後也成功得到一個綠燈。
testWidgets("create chat room success", (tester) async {
await tester.pumpWidget(Provider<ChatRoomRepository>.value(
value: FakeChatRoomRepository(),
child: const ChatRoomPage(),
));
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
await tester.enterText(
find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == "聊天室名稱"),
"地球暖化討論群",
);
await tester.enterText(
find.byWidgetPredicate((widget) => widget is TextField && widget.decoration?.labelText == "聊天室說明"),
"愛地球",
);
await tester.tap(find.text("建立"));
await tester.pump();
expect(find.text("地球暖化討論群"), findsOneWidget);
expect(find.text("愛地球"), findsOneWidget);
});
接著我們重構一下測試,提升測試可讀性。
testWidgets("create chat room success", (tester) async {
await givenView(tester, const ChatRoomPage());
await whenTapAddButton(tester);
await whenEnterChatRoomName(tester, "地球暖化討論群");
await whenEnterChatRoomDescription(tester, "愛地球");
await whenTapCreateButton(tester);
thenShowText("地球暖化討論群");
thenShowText("愛地球");
});
最後我們把原本中間驗證建立按鈕的測試程式碼刪掉,因為那只是我們在寫測試過程中的輔助,而不是我們真正想驗證個結果。假設今天建立按鈕真的沒有出現,測試也會壞在按下建立按鈕的那一行,使得測試失敗,所以我們不需要特別驗證建立按鈕有沒有出現。
接下來讓我們繼續用其他測試案例來看看不同操作吧。
假設使用者建立聊天室建立到一半,突然不想建了,點擊旁邊黑色區塊來關閉彈跳時窗,此時回到聊天室列表時,就應該看不到剛剛輸入的聊天室資訊。
在這個情境中,我們可以重複使用上一個測試的操作步驟,只有兩個地方不同,一是在原本按下建立按鈕的步驟改成點擊黑色背景關閉彈跳視窗,二則是要驗證聊天室名稱與說明沒有出現在聊天室列表中。
testWidgets("cancel chat room creation", (tester) async {
await givenView(tester, const ChatRoomPage());
await whenTapAddButton(tester);
await whenEnterChatRoomName(tester, "地球暖化討論群");
await whenEnterChatRoomDescription(tester, "愛地球");
// 關閉建立聊天室彈跳視窗
expect(find.text("地球暖化討論群"), findsNothing);
expect(find.text("愛地球"), findsNothing);
});
那我們要怎麼點到黑色背景的部分呢?想要點擊某個 Widget 必須先用 Finder 找到,但是這個黑色背景是由 showDialog 這個 API 控制的,所以我們很難用 Finder 找到他。
要解決這問題方法很簡單,在這邊我們可以使用 WidgetTester 的 tapAt 方法,在這個方法中,我們可以傳入一個座標,要模擬使用者點擊某個特定位置。在這個測試中,只要我們座標給得夠邊邊,其實我們就能模擬點到黑色背景了。
testWidgets("cancel chat room creation", (tester) async {
...
// 關閉建立聊天室彈跳視窗
await tester.tapAt(const Offset(0, 0));
await tester.pump();
expect(find.text("地球暖化討論群"), findsNothing);
expect(find.text("愛地球"), findsNothing);
});
最後我們執行測試,也成功得到綠燈。
讓我們繼續測試另一個情境,當使用者在聊天室列表對著某個聊天室左滑時,就能從列標中刪除聊天室。
這一次呢,我們直接在先準備資料的階段,就塞好一個聊天室,然後再測試中刪除這個準備好的聊天室。
testWidgets("delete chat room", (tester) async {
var fakeChatRoomRepository = FakeChatRoomRepository();
fakeChatRoomRepository.add(const ChatRoom("韭菜投資群組", "你不理財,財不理你"));
await givenView(tester, const ChatRoomPage(), repository: fakeChatRoomRepository);
// 左滑刪除聊天室
expect(find.text("韭菜投資群組"), findsNothing);
expect(find.text("你不理財,財不理你"), findsNothing);
});
那這次我們要怎麼實現左滑呢?聰明的觀眾朋友肯定也知道,一樣是要使用 WidgetTester 身上的方法,當我們模擬拖動效果時,就可以用 drag 方法。在 drag 方法中傳入 Finder 指定我們想拖動的個物件,然後用 Offset 來指定距離。在下面的例子中,Offset(-500, 0) 表示在 x 軸方向上,要移動 -500 的距離,在 y 軸則是不動,模擬使用者左滑的效果。
testWidgets("delete chat room", (tester) async {
...
// 左滑刪除聊天室
await tester.drag(find.text("韭菜投資群組"), const Offset(-500, 0));
await tester.pumpAndSettle();
expect(find.text("韭菜投資群組"), findsNothing);
expect(find.text("你不理財,財不理你"), findsNothing);
});
有觀眾朋友可能會注意到,我們呼叫 drag 方法後,不是用 pump 方法更新畫面,而是用 pumpAndSettle 方法更新畫面,那 pumpAndSettle 是什麼呢?其實簡單來說,pumpAndSettle 方法可以想像成不斷重複的呼叫 pump 方法,直到沒有新的畫面要更新為止。
那好奇的人可能就會問,為什麼我們會需要一直更新畫面呢?不是滑完更新一次就好了嗎?其實最主要的原因是因為左滑刪除效果,在滑動過程中 Flutter 會不斷更新畫面,讓使用者體驗順暢。
所以在測試中,如果我們只呼叫了一次 pump 方法,或許畫面才刷新到一半,此時聊天室還在畫面上,所以測試就會失敗了。當我們的畫面或操作會有動畫效果,我們測試的時候就會需要 pumpAndSettle 方法的幫助,幫我們重複的執行 pump 方法,直到畫面不再更新,我們才能準確驗證結果。
與 Finder 類似,Widget Test 也支援模擬使用者各種不同的操作,除了上面幾個例子提到的之外,還有 longPress 方法模擬使用者長按,或者 sendKeyEvent 方法模擬使用者按下鍵盤按鍵 …等。有需要的觀眾朋友也可以自己嘗試看看。
當我們熟悉幾個常用的 Finder 與 WidgetTester 的方法後,我們基本上就可以應付大部分的測試情境了,今天也還介紹了 pumpAndSettle 方法與其使用時機。從 pumpAndSettle 的使用也可看出 Widget Test 與單元測試很不同的地方,寫 Widget Test 需要對 Flutter 的機制有一定了解,我們才能正確的測試。