End to End Test with Espresso in Android (P2)
Bài viết này mình sẽ tiếp tục giới thiệu cơ bản về các API của Espresso. Sau đây mình sẽ nói về một số api của Espresso để thực hiện các e2e test. Trước hết, bạn cần biết những kịch bản mà người dùng sẽ thao tác với các phần tử UI và tương tác với chúng, ví dụ như bấm button A sẽ hiện text ...
Bài viết này mình sẽ tiếp tục giới thiệu cơ bản về các API của Espresso.
Sau đây mình sẽ nói về một số api của Espresso để thực hiện các e2e test.
Trước hết, bạn cần biết những kịch bản mà người dùng sẽ thao tác với các phần tử UI và tương tác với chúng, ví dụ như bấm button A sẽ hiện text "Hello", bấm vào button B thì sẽ xuất hiện một popup tương ứng, như vậy chúng ta mới biết chúng ta sẽ test cái gì và sử dụng api phù hợp để thực hiện. Đồng thời, framework sẽ ngăn chặn truy cập trực tiếp và các activity và các view của application, bởi vì giữ các object và operating này trên UI thread của chúng là một nguồn gốc chính của test flakiness. Vì vậy, bạn sẽ không thấy các phương thức như getView() và getCurrentActivity() trong Espresso API. Bạn vẫn có thể làm việc an toàn trên các subclass của ViewAction và ViewAssertion.
API components
Các thành phần chính của Espresso bao gồm:
- Espresso: Entry point để tương tác với các view (thông qua onView() và onData()). Đồng thời cung cấp cái API không liên quan (không ràng buộc) với bất gì view nào, như pressBack().
- ViewMatchers: Một tập hợp của các object implement Matcher<? super View> interface. Bạn có thể pass một hoặc nhiều phương thức onView() để xác định một view trong hệ thống phân cấp view hiện tại.
- ** ViewActions**: Một tập hợp của các ViewAction object có thể pass phương thức ViewInteraction.perform(), ví dụ như click().
- ViewAssertions: Một tập hợp các ViewAssertion object có thể pass phương thức ViewInteraction.check(). Thông thường, bạn sẽ sử dụng matches assertion - sử dụng một View matcher để xác định trạng thái của view hiện tại được chọn.
onView(withId(R.id.my_view)) // withId(R.id.my_view) is a ViewMatcher .perform(click()) // click() is a ViewAction .check(matches(isDisplayed())); // matches(isDisplayed()) is a ViewAssertion
Finding a view
Trong đa số các trường hợp, phương thức onView() là một hamcrest matcher, tức là nó chỉ match với một và chỉ một view trong hệ thống phân cấp view hiện tại. Matchers rất mạnh và sẽ rất quen thuộc đối với những ai đã từng làm việc với Mockito hay JUnit. Nếu bạn chưa rõ, hay chưa biết gì về hamcrest matchers, bạn có thể đọc ở đây để nắm được rõ hơn.
Thông thường, một view sẽ có duy nhất một R.id tương ứng và một matcher đơn giản withId sẽ giúp ta tìm kiếm view đó dễ dàng. Tuy nhiên bên cạnh đó cũng có khá nhiều trường hợp mà bạn không thể xác định đc R.id trong quá trình test development. Ví dụ một view cụ thể có thể không có R.id hoặc R.id của nó không là duy nhất. Điều này khiến cho việc test trở nên khó khăn và phức tạp hơn, vì cách thông thường để truy cập vào view bằng findViewById() không hoạt động. Do đó, bạn có thể cần phải truy cập vào cách thành phần private của Activity hoặc Fragment - mà đang chứa view đó hoặc tìm một container mà chúng ta xác định được R.id và điều hướng đến nội dung của nó cho một view cụ thể.
Espresso xử lý vấn đề này một cách cleanly bằng cách cho phép bạn thu hẹp tìm kiếm bằng cách sử dụng các ViewMatcher object hoặc các ViewMatcher object custom của chính bạn. Để tìm kiếm một view theo R.id bạn có thể sử dụngonView():
onView(withId(R.id.my_view))
Đôi khi, giá trị R.id được chia sẻ cho khá nhiều view =)) Khi điều này xảy ra tức là nếu bạn cố gắng sử dụng một phương thức nào đó cho giá trị R.id, nó sẽ bắn ra một exception, như AmbiguousViewMatcherException. Exception được bắn ra sẽ kèm theo message tương ứng, từ đó bạn có thể kiểm tra lại xem, và tìm view phù hợp với giá trị R.id không duy nhất này.
java.lang.RuntimeException: android.support.test.espresso.AmbiguousViewMatcherException This matcher matches multiple views in the hierarchy: (withId: is <123456789>) ... +----->SomeView{id=123456789, res-name=plus_one_standard_ann_button, visibility=VISIBLE, awidth=523, height=48, has-focus=false, has-focusable=true, window-focus=true, is-focused=false, is-focusable=false, enabled=true, selected=false, is-layout-requested=false, text=, root-is-layout-requested=false, x=0.0, y=625.0, child-count=1} ****MATCHES**** | +------>OtherView{id=123456789, res-name=plus_one_standard_ann_button, visibility=VISIBLE, awidth=523, height=48, has-focus=false, has-focusable=true, window-focus=true, is-focused=false, is-focusable=true, enabled=true, selected=false, is-layout-requested=false, text=Hello!, root-is-layout-requested=false, x=0.0, y=0.0, child-count=1} ****MATCHES****
Vậy làm sao để ta lấy được view mong muốn. Khi đó bạn cần check xem view mong muốn có thuộc tính gì là duy nhất có thể sử dụng để nhận diện được. Như trong message trên, ta có thể nhìn thấy xuất hiện 2 view cùng R.id, nhưng chúng có text khác nhau, vì vậy ta có thể sử dụng thuộc tính này để thêm vào khi xác định view mong muốn. Lúc này ta sẽ sử dụng combination matchers:
onView(allOf(withId(R.id.my_view), withText("Hello!")))
Bạn cũng có thể sử dụng phép phủ định:
onView(allOf(withId(R.id.my_view), not(withText("Hello!"))))
Vô đây ViewMatchers để biết chi tiết các phương thức mà Espresso cung cấp.
Considerations
- Trong một ứng dụng có hành vi tốt, tức là tất cả các view có thể tương tác có chứa text hoặc một một nội dung mô tả nào đó. Nếu bạn không thể thu hẹp phạm vi tìm kiếm khi sử dụng phương thức withText() hay withContentDescription(), thì hãy xem xét xử lý nó như một lỗi truy cập accessibility bug.
- Sử dụng ít matcher nhất để tìm ra view mong muốn. Đừng quá chi tiết vì nó sẽ khiến framework phải làm nhiều việc không thực sự cần thiết: Ví dụ: Hầu hết các view chúng ta chỉ cần định danh theo R.id là đủ, nhưng vẫn có trường hợp trùng R.id và view đó có một số đặc tính duy nhất là text, awidth, ... thì ta chỉ cần sử dụng đến text là đủ phân biệt không nhất thiết phải sử dụng hết các đặc tính của view đó.
- Nếu target view là một thành phần trong AdapterView, như ListView, GridView, hoặc Spinner - phương thức onView() có thể sẽ không hoặt động. Trong trường hợp này, bạn nên sử dụng onData() để thay thế.
Performing an action on a view
Khi đã tìm được một matcher phù hợp cho view mong muốn, ban có thể thực hiện các ViewAction bằng cách sử dụng các perform method.
Ví dụ, để thực hiện action click:
onView(...).perform(click());
Bạn cũng có thể thực hiện nhiều hơn một action:
onView(...).perform(typeText("Hello"), click());
Nếu view đang được đặt bên trong mộtScrollView(vertical hoặc horizontal), hãy xem xét trước khi thực hiện action, có thể cho view đó hiện ra rồi mới click chẳng hạn, khi đó bạn kết hợp click() và typeText() với scrollTo().
onView(...).perform(scrollTo(), click());
Note: Phương thức scrollTo() sẽ không có tác dụng khi view đó đã được hiển thị trên màn hình, vì vậy hãy cẩn thận khi sử dụng phương thức này trong trường hợp bạn thực hiện test trên cả 2 loại màn hình bé và màn hình to.
Để xem chi tiết hơn bạn có thể đọc thêm tại đây ViewActions.
Checking view assertions
Assertions có thể được áp dụng cho các view được chọn hiện tại với phương thức check(). Assertion được sử dụng nhiều nhất là matches() assertion. Nó sử dụng như một ViewMatcher object để truy cập vào trạng thái của view được chọn.
Ví dụ, bạn có thể kiểm tra xem một view có chứa text "Hello!" hay không:
onView(...).check(matches(withText("Hello!")));
Note: Không được put các "assertion" vào trong đối số của onView(). Thay vào đó, bạn cần phải xác định những gì cần kiểm tra trong check block.
Nếu bạn muốn xác định rằng "Hello!" là một nội dung của view, bạn có thể kiểm tra theo cách sau:
// Don't use assertions like withText inside onView. onView(allOf(withId(...), withText("Hello!"))).check(matches(isDisplayed()));
Mặt khác, nếu bạn muốn xác định rằng một view có có text "Hello!" được hiển thị - visible thì phần code phía trên ok.
Note: Hãy nhớ cần chú ý đến sự khác biệt giữa việc xác định rằng một view không được hiển thị và việc xác định một view không có trong hệ thống phân cấp view.
View assertion simple test
Trong ví dụ này, mình sẽ tạo một Activitychứa mộtButton, mộtTextView. Kịch bản sẽ là: Khi bấm vào Button, thành phần của TextView sẽ đổi thành "Hello World!".
Sau đây sẽ là các bước test khi ta thực hiện bằng Espresso:
Click on the button
Bước đầu tiên, ta cần xác định Button sẽ được bấm, ở đây ta sẽ xác định nó theo R.id. Sau khi tìm được ta sẽ thực hiện action click:
onView(withId(R.id.button_simple)).perform(click());
Verify the TextView text
Đối với TextView ta cũng cần xác định nó đầu tiên bằng R.id, sau đó xác định xem nó có chứa text "Hello World!" hay không:
onView(withId(R.id.text_simple)).check(matches(withText("Hello Espresso!")));
Checking data loading in adapter views
AdapterView là một widget đặc biệt, data của nó được load dynamically từ một Adapter. Ví dụ phổ biến nhất của loại này chính là ListView. Trái ngược với các widget tĩnh như LinearLayout, chỉ có các subset của AdapterView children được add vào trong view hierarchy hiện tại. Phương thứconView() sẽ không thể tìm được các view mà chúng chưa được load lên.
Để xử lý điền này, Espresso cung cấp cho chúng ta một entry point riêng biệt onData() - able trong lần load adapter item đầu tiên,.
Note: Bạn có thể bỏ qua phương thức onData() cho những item trong adapter views mà chúng đã được khởi tạo hiển trên screen bởi vì chúng đã được load lên rồi. Mặc dù vậy, onData() vẫn sẽ luôn an toàn hơn.
Warning: Một custom AdapterView có thể sẽ có vấn đề với phương thức onData() nếu chúng phá vỡ các phương thức thừa kế, đặc biệt với phương thức getItem(). Trong những trường hợp như vậy, cách tốt nhất là tái cấu trúc lại mã nguồn của của bạn. Nếu không thể làm như vậy, bạn có implement một matching custom AdapterViewProtocol. Để biết thêm chi tiết, bạn có thể xem class AdapterViewProtocols được cung cấp bởi Espresso.
Adapter view simple test
Sau đây chúng ta sẽ thực hiện một simple test, để hiểu hơn cách sử dụng onData(). Tạo một Activity có chứa một Spinner, và Spinner này sẽ có một list item của nó. Khi một item được chọn, sẽ có một TextView thay đổi nội dung tương ứng: "Item is %s", %s ở đây sẽ chứa item được chọn.
Đầu tiên chúng ta phải xác định được mục tiêu của simple test này, đó là: open Spinner -> chọn một item -> TextView hiển thị nội dung tương ứng item được chọn. Ở đây chúng ta sẽ sử dụng onData() với Spinner thay vì onView().
Open the item selection
onView(withId(R.id.spinner_simple)).perform(click());
Select an item
Spinner sẽ tạo ra một ListView để hiển thị các item. List item này có thể rất dài, một số phần tử có thể không được add vào view hierarchy. Bằng cách sử dụng onData() chúng ta sẽ buộn item mong muốn được add vào view hierarchy. Ở đây các item của Spinner là các String, nên trong trường hợp này ta thử tìm item "Espresso" bằng cách:
onData(allOf(is(instanceOf(String.class)), is("Espresso"))).perform(click());
Verify text is correct
onView(withId(R.id.spinnertext_simple)).check(matches(withText(containsString("Espresso"))));
Debugging
Espresso có cung cấp thông tin debug khi test chạy fail để bạn có thể xác định được lỗi một cách dễ dàng.
Logging
Tất cả Espresso logs sẽ được hiển thị ngay trong logcat luôn:
ViewInteraction: Performing 'single click' action on view with text: Espresso
View hierarchy
Espresso sẽ hiển thị view hierarchy trong exception message khi onView() bị fail.
- Nếu onView() không thể tìm thấy target view, NoMatchingViewException sẽ được bắn ra. Bạn có thể kiểm tra view hierarchy trong exception để xem lý do tại sao matcher không tìm thấy bất kỳ view nào.
- Nếu onView() tìm thấy nhiều view phù hợp với matcher của bạn, AmbiguousViewMatcherException sẽ được bắn ra. Kiểm tra thông báo lỗi để xác định đâu sẽ là view mong muốn.
Khi bạn phải làm việc với một view hierarchy phức tạp hoặc hành vi bất ngờ (không xác định) của các widget, bạn có thể tham khảo Hierarchy Viewer trong Android Studio, nó rất là hữu dụng đó.
Adapter view warnings
Espresso sẽ cảnh báo người dùng về sự xuất hiện của một widget AdapterView. Khi phương thức onView trả về một NoMatchingViewException và một AdapterView được hiển thị trong view hierarchy, giải pháp phổ biến cho trường hợp này là sử dụng onData() thay thế. Hãy sử dụng các thông tin mà exception trả về để gọi onData() một cách hợp lý.
Trên đây mình đã trình bày cơ bản về việc tìm kiếm một view, cách thực hiện actionView, và những điều chú ý về widget đặc biệt - AdapterView trong Espresso. Trong phần sau mình sẽ trình về Espresso recipes.