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. 5.
  2. 6.
  3. 8.

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

    screen elements easily Test code not affected by UI changes
  4. 9.

    Why? Test code easier to understand Logic is about the

    intention of the test Don’t care about UI details
  5. 10.
  6. 14.
  7. 16.
  8. 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());
 } }
  9. 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());
 }
 
 }
  10. 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());
 }
 
 }
  11. 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();
 }
 
 }
  12. 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))));
 } 
 }
  13. 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;
 } }
  14. 27.
  15. 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));
  16. 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));
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  22. 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
  23. 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
  24. 40.
  25. 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);
 }
 };
 }
  26. 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);
 }
 };
 }
  27. 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);
 }
 };
 }
  28. 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);
 }
 };
 }
  29. 45.

    AccountTestRule Add / Remove account from the AccountManager Test app

    with popular / normal / empty user Test Sign-in
  30. 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.
  31. 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.
  32. 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);
 }
 
 }
  33. 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);
 }
 
 }
  34. 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));
 }
 }; 
 }
  35. 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));
 }
 }; 
 }
  36. 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();
 }
 }
 }
 });
 }
  37. 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
  38. 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
  39. 57.

    Rules are good for you Reusable Unclutter your tests from

    unnecessary details Separate configuration and actual tests
  40. 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}” […]
  41. 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;
 }
  42. 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;
 }
  43. 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;
 }
  44. 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}” […]
  45. 66.
  46. 70.

    public final class SomeFeatureFlagEnabled extends Predicate { 
 @Override
 public

    boolean evaluate() {
 return featureFlags().someFeature();
 }
 
 @Override
 public String description() {
 return “Some feature flag enabled";
 } 
 }
  47. 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());
 }
 
 }
  48. 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());
 }
 
 }
  49. 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();
 }
 }
  50. 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();
 }
 }
  51. 75.
  52. 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)
 }
  53. 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)
 }
  54. 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)
 }
 }
  55. 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)
 }
 }
  56. 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)
 }
 }
  57. 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)
 }
 }
  58. 84.