Share
Embed
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
Florian Mierzejewski
November 12, 2016
Transcript
85 slides
-
What? Wraps screen with an application-specific API Allow interacting with
screen elements easily Test code not affected by UI changes -
Why? Test code easier to understand Logic is about the
intention of the test Don’t care about UI details -
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()); } } -
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()); } } -
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()); } } -
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(); } } -
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)))); } } -
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; } } -
@Test public void shouldChangeTrackToPrivateInTrackDetails() { givenPublicTrackDetail(); trackDetailScreen.showTrackOptions() .tapOnMakeTrackPrivateOption() .tapOnConfirmInDialog();
assertTrackDetail().hasPrivacySetToPrivate(); } -
@Test public void shouldChangeTrackToPrivateInTrackDetails() { givenPublicTrackDetail(); trackDetailScreen.showTrackOptions() .tapOnMakeTrackPrivateOption() .tapOnConfirmInDialog();
assertTrackDetail().hasPrivacySetToPrivate(); } -
@Test public void shouldChangeTrackToPrivateInTrackDetails() { givenPublicTrackDetail(); trackDetailScreen.showTrackOptions() .tapOnMakeTrackPrivateOption() .tapOnConfirmInDialog();
assertTrackDetail().hasPrivacySetToPrivate(); } -
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)); -
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)); -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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); } }; } -
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); } }; } -
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); } }; } -
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); } }; } -
AccountTestRule Add / Remove account from the AccountManager Test app
with popular / normal / empty user Test Sign-in -
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. -
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. -
IdlingResourceTestRule Register / unregister idling resource before / after test(s)
Used mostly in combination with RxIdlingResource -
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); } } -
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); } } -
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)); } }; } -
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)); } }; } -
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(); } } } }); } -
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 -
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 -
Rules are good for you Reusable Unclutter your tests from
unnecessary details Separate configuration and actual tests -
Making ViewInteraction great again! Wrap ViewInteraction Use a FailureHandler to
provide a human explanation of what went wrong -
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}” […] -
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; } -
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; } -
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; } -
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}” […] -
Feature flags Based on the flavour Developing continuously Switching a
flag when we want to release the feature -
GREAT BUT… HOW DO WE DEAL WITH TESTING BOTH NEW
& OLD BEHAVIOUR? -
A CUSTOM TEST RUNNER! OR, HOW TO SKIP TESTS BASED
ON A PREDICATE -
public final class SomeFeatureFlagEnabled extends Predicate { @Override public
boolean evaluate() { return featureFlags().someFeature(); } @Override public String description() { return “Some feature flag enabled"; } } -
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()); } } -
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()); } } -
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(); } } -
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(); } } -
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) } -
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) } -
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) } } -
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) } } -
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) } } -
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) } }