Taking care of your UI tests

Taking care of your UI tests

Most Android UI testing talks focus on how Espresso works and how to start writing simple tests.
This is fine for a simple app but maintaining and expanding those tests gets harder as the app grows. This talk will focus on writing complex tests scenarios while making sure we keep them maintainable, expandable and readable.

I’ll use a released app we developed as an example. This will involve multi-screen scenarios, handling asynchronous operations, providing a fake environment as well as some common and less common Espresso gotchas.

Talk given at DevFest Istanbul 2016, DevFest Berlin 2016 & Android Makers France 2017

Recording: https://www.youtube.com/watch?v=dcWTq7MyrBQ

B802469678e56fdee0a95c5e7f27d460?s=128

Florian Mierzejewski

November 12, 2016
Tweet

Transcript

  1. TAKING CARE OF YOUR UI TESTS

  2. ESPRESSO

  3. Maintainable Extendable Readable Stable

  4. @florianmski

  5. None
  6. None
  7. PAGEOBJECT

  8. What? Wraps screen with an application-specific API Allow interacting with

    screen elements easily Test code not affected by UI changes
  9. Why? Test code easier to understand Logic is about the

    intention of the test Don’t care about UI details
  10. None
  11. onView(withId(R.id.track_detail_actions_more)) .perform(click()); onView(withId(R.id.bottom_options_recycler_view)) .perform(actionOnItem(hasDescendant(allOf( withId(R.id.bottom_options_item_title), withText(R.string.track_detail_action_private))), click())); onView(withId(android.R.id.button1)).perform(click());

  12. onView(withId(R.id.track_detail_actions_more)) .perform(click()); onView(withId(R.id.bottom_options_recycler_view)) .perform(actionOnItem(hasDescendant(allOf( withId(R.id.bottom_options_item_title), withText(R.string.track_detail_action_private))), click())); onView(withId(android.R.id.button1)).perform(click());

  13. onView(withId(R.id.track_detail_actions_more)) .perform(click()); onView(withId(R.id.bottom_options_recycler_view)) .perform(actionOnItem(hasDescendant(allOf( withId(R.id.bottom_options_item_title), withText(R.string.track_detail_action_private))), click())); onView(withId(android.R.id.button1)).perform(click());

  14. None
  15. trackDetailScreen.showTrackOptions() .tapOnMakeTrackPrivateOption() .tapOnConfirmInDialog();

  16. None
  17. SCREENS, INTERACTORS & ASSERTIONS

  18. Screens public class TrackDetailScreen extends Screen {
 
 private final

    TrackDetailInteractors interactors;
 
 public TrackDetailScreen() {
 super(R.id.track_detail_container);
 interactors = new TrackDetailInteractors();
 }
 
 public Options showTrackOptions() {
 interactors.trackActions().perform(click());
 return goTo(new Options());
 } }
  19. And part of screens… public static class Options extends BottomSheetScreen

    {
 
 public DialogScreen tapOnMakeTrackPrivateOption() {
 return tapOnOptionWithString(R.string.track_detail_action_private).goTo(dialog());
 }
 
 public DialogScreen tapOnMakeTrackPublicOption() {
 return tapOnOptionWithString(R.string.track_detail_action_public).goTo(dialog());
 }
 
 public UnknownScreen tapOnPlayTrackOption() {
 return tapOnOptionWithString(R.string.track_detail_action_play) .goTo(new UnknownScreen());
 }
 
 }
  20. And part of screens… public static class Options extends BottomSheetScreen

    {
 
 public DialogScreen tapOnMakeTrackPrivateOption() {
 return tapOnOptionWithString(R.string.track_detail_action_private).goTo(dialog());
 }
 
 public DialogScreen tapOnMakeTrackPublicOption() {
 return tapOnOptionWithString(R.string.track_detail_action_public).goTo(dialog());
 }
 
 public UnknownScreen tapOnPlayTrackOption() {
 return tapOnOptionWithString(R.string.track_detail_action_play) .goTo(new UnknownScreen());
 }
 
 }
  21. As well as dialogs… public class DialogScreen extends Screen {


    
 public Screen tapOnConfirmInDialog() {
 return tapOnButton(android.R.id.button1);
 }
 
 public Screen tapOnCancelInDialog() {
 return tapOnButton(android.R.id.button2);
 }
 
 protected Screen tapOnButton(@IdRes int buttonId) {
 onView(withId(buttonId)).perform(click());
 return previousScreen();
 }
 
 }
  22. Interactors public class TrackDetailInteractors {
 
 BetterViewInteraction trackActions() {
 return

    BetterViewInteraction.wrap(onView(withId(R.id.track_detail_actions_more)));
 }
 
 BetterViewInteraction privateIndicator() {
 return BetterViewInteraction.wrap(onView(withText(R.string.track_item_private)));
 }
 
 RecyclerViewScreen recyclerView() {
 return new RecyclerViewScreen(allOf(
 withId(R.id.ak_recycler_view),
 isDescendantOfA(withId(R.id.track_detail_container))));
 } 
 }
  23. Assertions public class TrackDetailAssertions {
 
 private final TrackDetailInteractors interactors;


    
 private TrackDetailAssertions(TrackDetailInteractors interactors) {
 this.interactors = interactors;
 }
 
 public static TrackDetailAssertions assertTrackDetail() {
 return new TrackDetailAssertions(new TrackDetailInteractors());
 }
 
 public TrackDetailAssertions hasPrivacySetToPrivate() {
 interactors.privateIndicator()
 .check(matches(isDisplayed()), "Track is not displayed as being private.");
 return this;
 } }
  24. @Test
 public void shouldChangeTrackToPrivateInTrackDetails() {
 givenPublicTrackDetail();
 
 trackDetailScreen.showTrackOptions()
 .tapOnMakeTrackPrivateOption()
 .tapOnConfirmInDialog();


    
 assertTrackDetail().hasPrivacySetToPrivate();
 }
  25. @Test
 public void shouldChangeTrackToPrivateInTrackDetails() {
 givenPublicTrackDetail();
 
 trackDetailScreen.showTrackOptions()
 .tapOnMakeTrackPrivateOption()
 .tapOnConfirmInDialog();


    
 assertTrackDetail().hasPrivacySetToPrivate();
 }
  26. @Test
 public void shouldChangeTrackToPrivateInTrackDetails() {
 givenPublicTrackDetail();
 
 trackDetailScreen.showTrackOptions()
 .tapOnMakeTrackPrivateOption()
 .tapOnConfirmInDialog();


    
 assertTrackDetail().hasPrivacySetToPrivate();
 }
  27. None
  28. INSTRUMENTATION TESTING ROBOTS https://realm.io/news/kau-jake-wharton-testing-robots/

  29. CONFIGURING

  30. Test classes Single screen Event propagation Navigation

  31. Test class “header” @Rule public final ActivityTestRule<UserPreviewActivity> activityTestRule = new

    ActivityTestRule<>(UserPreviewActivity.class, true, false); @ClassRule public static final TestRule chain = RuleChain.outerRule(AccountTestRule.add(POPULAR_CREATOR)) .around(applicationComponentTestRule()) .around(new IdlingResourceTestRule(rxIdlingResource));
  32. Test class “header” @Rule public final ActivityTestRule<UserPreviewActivity> activityTestRule = new

    ActivityTestRule<>(UserPreviewActivity.class, true, false); @ClassRule public static final TestRule chain = RuleChain.outerRule(AccountTestRule.add(POPULAR_CREATOR)) .around(applicationComponentTestRule()) .around(new IdlingResourceTestRule(rxIdlingResource));
  33. starting account rule starting application component rule starting idling resource

    rule execute all tests finished idling resource rule finished application component rule finished account rule
  34. starting account rule starting application component rule starting idling resource

    rule execute all tests finished idling resource rule finished application component rule finished account rule
  35. starting account rule starting application component rule starting idling resource

    rule execute all tests finished idling resource rule finished application component rule finished account rule
  36. starting account rule starting application component rule starting idling resource

    rule execute all tests finished idling resource rule finished application component rule finished account rule
  37. starting account rule starting application component rule starting idling resource

    rule execute all tests finished idling resource rule finished application component rule finished account rule
  38. starting account rule starting application component rule starting idling resource

    rule execute all tests finished idling resource rule finished application component rule finished account rule
  39. starting account rule starting application component rule starting idling resource

    rule execute all tests finished idling resource rule finished application component rule finished account rule
  40. None
  41. LocaleTestRule @Override
 public Statement apply(final Statement base, Description description) {


    return new Statement() {
 @Override
 public void evaluate() throws Throwable {
 Locale defaultLocale = Locale.getDefault();
 for (Locale locale : locales) {
 changeLocaleTo(locale);
 base.evaluate();
 }
 changeLocaleTo(defaultLocale);
 }
 };
 }
  42. LocaleTestRule @Override
 public Statement apply(final Statement base, Description description) {


    return new Statement() {
 @Override
 public void evaluate() throws Throwable {
 Locale defaultLocale = Locale.getDefault();
 for (Locale locale : locales) {
 changeLocaleTo(locale);
 base.evaluate();
 }
 changeLocaleTo(defaultLocale);
 }
 };
 }
  43. LocaleTestRule @Override
 public Statement apply(final Statement base, Description description) {


    return new Statement() {
 @Override
 public void evaluate() throws Throwable {
 Locale defaultLocale = Locale.getDefault();
 for (Locale locale : locales) {
 changeLocaleTo(locale);
 base.evaluate();
 }
 changeLocaleTo(defaultLocale);
 }
 };
 }
  44. LocaleTestRule @Override
 public Statement apply(final Statement base, Description description) {


    return new Statement() {
 @Override
 public void evaluate() throws Throwable {
 Locale defaultLocale = Locale.getDefault();
 for (Locale locale : locales) {
 changeLocaleTo(locale);
 base.evaluate();
 }
 changeLocaleTo(defaultLocale);
 }
 };
 }
  45. AccountTestRule Add / Remove account from the AccountManager Test app

    with popular / normal / empty user Test Sign-in
  46. ApplicationComponentTestRule Swap dagger graph Disable crash logs / tracking Override

    components
  47. By default, Espresso waits for UI events in the current

    message queue to be handled and for default AsyncTasks to complete before it moves on to the next test operation. There are instances where applications perform background operations (such as communicating with web services) via non-standard means; for example: direct creation and management of threads. In such cases, you have to use Idling Resources to inform Espresso of the app’s long-running operations.
  48. By default, Espresso waits for UI events in the current

    message queue to be handled and for default AsyncTasks to complete before it moves on to the next test operation. There are instances where applications perform background operations (such as communicating with web services) via non-standard means; for example: direct creation and management of threads. In such cases, you have to use Idling Resources to inform Espresso of the app’s long-running operations.
  49. IdlingResourceTestRule Register / unregister idling resource before / after test(s)

    Used mostly in combination with RxIdlingResource
  50. public class ExecutionStrategy<T> implements Observable.Transformer<T, T> {
 
 private final

    Scheduler subscribeOnScheduler;
 private final Scheduler observeOnScheduler;
 
 public ExecutionStrategy(Scheduler subscribeOnScheduler, Scheduler observeOnScheduler) {
 this.subscribeOnScheduler = subscribeOnScheduler;
 this.observeOnScheduler = observeOnScheduler;
 }
 
 @Override
 public Observable<T> call(Observable<T> observable) {
 return observable
 .subscribeOn(subscribeOnScheduler)
 .observeOn(observeOnScheduler);
 }
 
 }
  51. public class ExecutionStrategy<T> implements Observable.Transformer<T, T> {
 
 private final

    Scheduler subscribeOnScheduler;
 private final Scheduler observeOnScheduler;
 
 public ExecutionStrategy(Scheduler subscribeOnScheduler, Scheduler observeOnScheduler) {
 this.subscribeOnScheduler = subscribeOnScheduler;
 this.observeOnScheduler = observeOnScheduler;
 }
 
 @Override
 public Observable<T> call(Observable<T> observable) {
 return observable
 .subscribeOn(subscribeOnScheduler)
 .observeOn(observeOnScheduler);
 }
 
 }
  52. RxIdlingResource public <T> ExecutionStrategy<T> wrap(ExecutionStrategy<T> strategy) {
 
 return new

    ExecutionStrategy<T>() {
 @Override
 public Observable<T> call(Observable<T> observable) {
 return wrap(executionStrategy.call(observable));
 }
 }; 
 }
  53. RxIdlingResource public <T> ExecutionStrategy<T> wrap(ExecutionStrategy<T> strategy) {
 
 return new

    ExecutionStrategy<T>() {
 @Override
 public Observable<T> call(Observable<T> observable) {
 return wrap(executionStrategy.call(observable));
 }
 }; 
 }
  54. RxIdlingResource public <T> Observable<T> wrap(final Observable<T> observable) {
 return observable


    .doOnSubscribe(new Action0() {
 public void call() {
 isIdle.compareAndSet(true, false);
 }
 })
 .finallyDo(new Action0() {
 public void call() {
 if (isIdle.compareAndSet(false, true)) {
 if (resourceCallback != null) {
 resourceCallback.onTransitionToIdle();
 }
 }
 }
 });
 }
  55. public <T> Observable<T> wrap(final Observable<T> observable) {
 return observable
 .doOnSubscribe(new

    Action0() {
 public void call() {
 isIdle.compareAndSet(true, false);
 }
 })
 .finallyDo(new Action0() {
 public void call() {
 if (isIdle.compareAndSet(false, true)) {
 if (resourceCallback != null) {
 resourceCallback.onTransitionToIdle();
 }
 }
 }
 });
 } RxIdlingResource
  56. public <T> Observable<T> wrap(final Observable<T> observable) {
 return observable
 .doOnSubscribe(new

    Action0() {
 public void call() {
 isIdle.compareAndSet(true, false);
 }
 })
 .finallyDo(new Action0() {
 public void call() {
 if (isIdle.compareAndSet(false, true)) {
 if (resourceCallback != null) {
 resourceCallback.onTransitionToIdle();
 }
 }
 }
 });
 } RxIdlingResource
  57. Rules are good for you Reusable Unclutter your tests from

    unnecessary details Separate configuration and actual tests
  58. BETTER FAILURES

  59. Making ViewInteraction great again! Wrap ViewInteraction Use a FailureHandler to

    provide a human explanation of what went wrong
  60. Caused by: junit.framework.AssertionFailedError: 'is displayed on the screen to the

    user' doesn't match the selected view. Expected: is displayed on the screen to the user Got: "ab{id=2131689841, res-name=track_item_private_indicator, visibility=GONE, width=0, height=0, has-focus=false, has- focusable=false, has-window-focus=true, is-clickable=false, is- enabled=true, is-focused=false, is-focusable=false, is-layout- requested=true, is-selected=false, root-is-layout- requested=false, has-input-connection=false, x=0.0, y=0.0, text=Private, input-type=0, ime-target=false, has-links=false}” […]
  61. public BetterViewInteraction check(ViewAssertion viewAssert, String failureMessage, Object... args) {
 viewInteraction.withFailureHandler(new

    FailureHandler() {
 @Override
 public void handle(Throwable t, Matcher<View> viewMatcher) { String errorMessage = String.format(failureMessage, args);
 Throwable error = new AssertionFailed(errorMessage, t);
 defaultFailureHandler.handle(error, viewMatcher);
 }
 }).check(viewAssert);
 return this;
 }
  62. public BetterViewInteraction check(ViewAssertion viewAssert, String failureMessage, Object... args) {
 viewInteraction.withFailureHandler(new

    FailureHandler() {
 @Override
 public void handle(Throwable t, Matcher<View> viewMatcher) { String errorMessage = String.format(failureMessage, args);
 Throwable error = new AssertionFailed(errorMessage, t);
 defaultFailureHandler.handle(error, viewMatcher);
 }
 }).check(viewAssert);
 return this;
 }
  63. public BetterViewInteraction check(ViewAssertion viewAssert, String failureMessage, Object... args) {
 viewInteraction.withFailureHandler(new

    FailureHandler() {
 @Override
 public void handle(Throwable t, Matcher<View> viewMatcher) { String errorMessage = String.format(failureMessage, args);
 Throwable error = new AssertionFailed(errorMessage, t);
 defaultFailureHandler.handle(error, viewMatcher);
 }
 }).check(viewAssert);
 return this;
 }
  64. com.soundcloud.espresso.EspressoAssertionFailedError: Track is not displayed as being private. […] Caused

    by: junit.framework.AssertionFailedError: 'is displayed on the screen to the user' doesn't match the selected view. Expected: is displayed on the screen to the user Got: "ab{id=2131689841, res-name=track_item_private_indicator, visibility=GONE, width=0, height=0, has-focus=false, has-focusable=false, has-window-focus=true, is-clickable=false, is-enabled=true, is-focused=false, is-focusable=false, is-layout-requested=true, is-selected=false, root-is- layout-requested=false, has-input-connection=false, x=0.0, y=0.0, text=Private, input-type=0, ime-target=false, has-links=false}” […]
  65. GOING DEEPER

  66. Feature flags Based on the flavour Developing continuously Switching a

    flag when we want to release the feature
  67. GREAT BUT… HOW DO WE DEAL WITH TESTING BOTH NEW

    & OLD BEHAVIOUR?
  68. A CUSTOM TEST RUNNER! OR, HOW TO SKIP TESTS BASED

    ON A PREDICATE
  69. @RunWhen( value = SomePredicate.class, expected = false)


  70. public final class SomeFeatureFlagEnabled extends Predicate { 
 @Override
 public

    boolean evaluate() {
 return featureFlags().someFeature();
 }
 
 @Override
 public String description() {
 return “Some feature flag enabled";
 } 
 }
  71. public class SkippableAndroidJUnit4 extends BlockJUnit4ClassRunner {
 
 @Override
 protected Statement

    methodBlock(FrameworkMethod testMethod) {
 Statement base = super.methodBlock(testMethod);
 Statement statement = applyRunWhenTo(testMethod.getMethod(), base);
 
 Class<?> clazz = testMethod.getDeclaringClass();
 do {
 statement = applyRunWhenTo(clazz, statement);
 } while ((clazz = clazz.getSuperclass()) != null);
 return statement;
 }
 
 private Statement applyRunWhenTo(AnnotatedElement element, final Statement base) {
 RunWhen annotation = element.getAnnotation(RunWhen.class);
 if (annotation == null) {
 return base;
 }
 return new PredicateStatement(base, annotation.value(), annotation.expected());
 }
 
 }
  72. public class SkippableAndroidJUnit4 extends BlockJUnit4ClassRunner {
 
 @Override
 protected Statement

    methodBlock(FrameworkMethod testMethod) {
 Statement base = super.methodBlock(testMethod);
 Statement statement = applyRunWhenTo(testMethod.getMethod(), base);
 
 Class<?> clazz = testMethod.getDeclaringClass();
 do {
 statement = applyRunWhenTo(clazz, statement);
 } while ((clazz = clazz.getSuperclass()) != null);
 return statement;
 }
 
 private Statement applyRunWhenTo(AnnotatedElement element, final Statement base) {
 RunWhen annotation = element.getAnnotation(RunWhen.class);
 if (annotation == null) {
 return base;
 }
 return new PredicateStatement(base, annotation.value(), annotation.expected());
 }
 
 }
  73. class PredicateStatement extends Statement {
 private final Statement base;
 private

    final Class<? extends Predicate> predicateClass;
 private final Boolean expected;
 
 public PredicateStatement(Statement base, Class<? extends Predicate> predicateClass, Boolean expected) {
 this.base = base;
 this.predicateClass = predicateClass;
 this.expected = expected;
 }
 
 @Override
 public void evaluate() throws Throwable {
 Predicate predicate = PredicateBuilder.build(predicateClass);
 assumeThat(predicate.description(), predicate.evaluate(), Matchers.is(expected));
 base.evaluate();
 }
 }
  74. class PredicateStatement extends Statement {
 private final Statement base;
 private

    final Class<? extends Predicate> predicateClass;
 private final Boolean expected;
 
 public PredicateStatement(Statement base, Class<? extends Predicate> predicateClass, Boolean expected) {
 this.base = base;
 this.predicateClass = predicateClass;
 this.expected = expected;
 }
 
 @Override
 public void evaluate() throws Throwable {
 Predicate predicate = PredicateBuilder.build(predicateClass);
 assumeThat(predicate.description(), predicate.evaluate(), Matchers.is(expected));
 base.evaluate();
 }
 }
  75. GRADLE

  76. def unlockAll = project.task('unlockAllDevices')
 
 command.devices().eachWithIndex { device, index ->


    
 def unlocker = project.task("unlockDevice${index}", type: ForceDeviceUnlock) {
 deviceId = device.id
 }
 unlockAll.dependsOn unlocker
 }
 
 project.afterEvaluate {
 project.tasks.findAll {
 it.name.startsWith('connected') && it.name.endsWith('AndroidTest')
 }*.dependsOn(unlockAll)
 }
  77. def unlockAll = project.task('unlockAllDevices')
 
 command.devices().eachWithIndex { device, index ->


    
 def unlocker = project.task("unlockDevice${index}", type: ForceDeviceUnlock) {
 deviceId = device.id
 }
 unlockAll.dependsOn unlocker
 }
 
 project.afterEvaluate {
 project.tasks.findAll {
 it.name.startsWith('connected') && it.name.endsWith('AndroidTest')
 }*.dependsOn(unlockAll)
 }
  78. class ForceDeviceUnlock extends AdbTask {
 
 @TaskAction
 void unlockDevice() {


    println 'Trying to unlock device using UnlockHelperApp (if installed)'
 def startHelperAppCommand = ['shell', 'am', 'start', '-n', 'io.appium.unlock/.Unlock']
 assertDeviceAndRunCommand(startHelperAppCommand)
 
 println 'Trying to unlock device using unlock keycode'
 def pressUnlockKey = ['shell', 'input', 'keyevent', '82']
 assertDeviceAndRunCommand(pressUnlockKey)
 
 println 'Sending "home" keycode to reset the screen'
 def pressHomeKey = ['shell', 'input', 'keyevent', '3']
 assertDeviceAndRunCommand(pressHomeKey)
 }
 }
  79. class ForceDeviceUnlock extends AdbTask {
 
 @TaskAction
 void unlockDevice() {


    println 'Trying to unlock device using UnlockHelperApp (if installed)'
 def startHelperAppCommand = ['shell', 'am', 'start', '-n', 'io.appium.unlock/.Unlock']
 assertDeviceAndRunCommand(startHelperAppCommand)
 
 println 'Trying to unlock device using unlock keycode'
 def pressUnlockKey = ['shell', 'input', 'keyevent', '82']
 assertDeviceAndRunCommand(pressUnlockKey)
 
 println 'Sending "home" keycode to reset the screen'
 def pressHomeKey = ['shell', 'input', 'keyevent', '3']
 assertDeviceAndRunCommand(pressHomeKey)
 }
 }
  80. class ForceDeviceUnlock extends AdbTask {
 
 @TaskAction
 void unlockDevice() {


    println 'Trying to unlock device using UnlockHelperApp (if installed)'
 def startHelperAppCommand = ['shell', 'am', 'start', '-n', 'io.appium.unlock/.Unlock']
 assertDeviceAndRunCommand(startHelperAppCommand)
 
 println 'Trying to unlock device using unlock keycode'
 def pressUnlockKey = ['shell', 'input', 'keyevent', '82']
 assertDeviceAndRunCommand(pressUnlockKey)
 
 println 'Sending "home" keycode to reset the screen'
 def pressHomeKey = ['shell', 'input', 'keyevent', '3']
 assertDeviceAndRunCommand(pressHomeKey)
 }
 }
  81. class ForceDeviceUnlock extends AdbTask {
 
 @TaskAction
 void unlockDevice() {


    println 'Trying to unlock device using UnlockHelperApp (if installed)'
 def startHelperAppCommand = ['shell', 'am', 'start', '-n', 'io.appium.unlock/.Unlock']
 assertDeviceAndRunCommand(startHelperAppCommand)
 
 println 'Trying to unlock device using unlock keycode'
 def pressUnlockKey = ['shell', 'input', 'keyevent', '82']
 assertDeviceAndRunCommand(pressUnlockKey)
 
 println 'Sending "home" keycode to reset the screen'
 def pressHomeKey = ['shell', 'input', 'keyevent', '3']
 assertDeviceAndRunCommand(pressHomeKey)
 }
 }
  82. DEVICE GOTCHAS

  83. “Ok Google” Autocomplete Updating ADB Update Dialog ☀ &

  84. None
  85. QUESTIONS? @florianmski