Droidcon London: Espresso Patronum

Droidcon London: Espresso Patronum

A presentation about maintainable testing with the robot pattern.

Bc87ea9c7a0f85b8761b716a677c6694?s=128

Adam McNeilly

October 25, 2019
Tweet

Transcript

  1. Espresso Patronum: The Magic of the Robot Pattern Adam McNeilly

    - @AdamMc331 @AdamMc331 #DroidconUK 1
  2. What Is Espresso? Use Espresso to write concise, beautiful, and

    reliable Android UI tests1. 1 https://developer.android.com/training/testing/espresso/index.html @AdamMc331 #DroidconUK 2
  3. Three Classes To Know @AdamMc331 #DroidconUK 3

  4. Three Classes To Know • ViewMatchers @AdamMc331 #DroidconUK 3

  5. Three Classes To Know • ViewMatchers • ViewActions @AdamMc331 #DroidconUK

    3
  6. Three Classes To Know • ViewMatchers • ViewActions • ViewAssertions

    @AdamMc331 #DroidconUK 3
  7. ViewMatchers @AdamMc331 #DroidconUK 4

  8. ViewMatchers • withId(...) @AdamMc331 #DroidconUK 4

  9. ViewMatchers • withId(...) • withText(...) @AdamMc331 #DroidconUK 4

  10. ViewMatchers • withId(...) • withText(...) • isFocusable() @AdamMc331 #DroidconUK 4

  11. ViewMatchers • withId(...) • withText(...) • isFocusable() • isChecked() @AdamMc331

    #DroidconUK 4
  12. ViewActions @AdamMc331 #DroidconUK 5

  13. ViewActions • typeText(...) @AdamMc331 #DroidconUK 5

  14. ViewActions • typeText(...) • scrollTo() @AdamMc331 #DroidconUK 5

  15. ViewActions • typeText(...) • scrollTo() • swipeLeft() @AdamMc331 #DroidconUK 5

  16. ViewActions • typeText(...) • scrollTo() • swipeLeft() • click() @AdamMc331

    #DroidconUK 5
  17. ViewAssertions @AdamMc331 #DroidconUK 6

  18. ViewAssertions • matches(Matcher) @AdamMc331 #DroidconUK 6

  19. ViewAssertions • matches(Matcher) • isLeftOf(Matcher) @AdamMc331 #DroidconUK 6

  20. ViewAssertions • matches(Matcher) • isLeftOf(Matcher) • doesNotExist() @AdamMc331 #DroidconUK 6

  21. Espresso Example // onView gives us a ViewInteraction where we

    can perform an action // or check an assertion. onView(ViewMatcher) .perform(ViewAction) .check(ViewAssertion) @AdamMc331 #DroidconUK 7
  22. Espresso Example // Type into an EditText, verify it appears

    in a TextView onView(withId(R.id.etInput)).perform(typeText("Adam")) onView(withId(R.id.tvOutput)).check(matches(withText("Adam"))) @AdamMc331 #DroidconUK 8
  23. Sample App @AdamMc331 #DroidconUK 9

  24. Happy Path Test @Test fun testSuccessfulRegistration() { onView(withId(R.id.etFirstName)).perform(typeText("Adam")) onView(withId(R.id.etLastName)).perform(typeText("McNeilly")) onView(withId(R.id.etEmail)).perform(typeText("adam@testing.com"))

    onView(withId(R.id.etPhone)).perform(typeText("1234567890")) onView(withId(R.id.registerButton)).perform(click()) onView(withId(R.id.tvFullName)).check(matches(withText("Adam McNeilly"))) onView(withId(R.id.tvEmailAddress)).check(matches(withText("adam@testing.com"))) onView(withId(R.id.tvPhoneNumber)).check(matches(withText("(123)-456-7890"))) } @AdamMc331 #DroidconUK 10
  25. Test Leaving Out A Field @Test fun testMissingEmailError() { onView(withId(R.id.etFirstName)).perform(typeText("Adam"))

    onView(withId(R.id.etLastName)).perform(typeText("McNeilly")) onView(withId(R.id.etPhone)).perform(typeText("1234567890")) onView(withId(R.id.registerButton)).perform(click()) onView(withId(R.id.etEmail)).check(matches(hasErrorText("Must enter an email address."))) } @AdamMc331 #DroidconUK 11
  26. Test An Invalid Field @Test fun testInvalidEmailError() { onView(withId(R.id.etFirstName)).perform(typeText("Adam")) onView(withId(R.id.etLastName)).perform(typeText("McNeilly"))

    onView(withId(R.id.etEmail)).perform(typeText("blahblah")) onView(withId(R.id.etPhone)).perform(typeText("1234567890")) onView(withId(R.id.registerButton)).perform(click()) onView(withId(R.id.etEmail)).check(matches(hasErrorText("Must enter a valid email address."))) } @AdamMc331 #DroidconUK 12
  27. All Together @Test fun testSuccessfulRegistration() { onView(withId(R.id.etFirstName)).perform(typeText("Adam")) onView(withId(R.id.etLastName)).perform(typeText("McNeilly")) onView(withId(R.id.etEmail)).perform(typeText("adam@testing.com")) onView(withId(R.id.etPhone)).perform(typeText("1234567890"))

    onView(withId(R.id.registerButton)).perform(click()) onView(withId(R.id.tvFullName)).check(matches(withText("Adam McNeilly"))) onView(withId(R.id.tvEmailAddress)).check(matches(withText("adam@testing.com"))) onView(withId(R.id.tvPhoneNumber)).check(matches(withText("(123)-456-7890"))) } @Test fun testMissingEmailError() { onView(withId(R.id.etFirstName)).perform(typeText("Adam")) onView(withId(R.id.etLastName)).perform(typeText("McNeilly")) onView(withId(R.id.etPhone)).perform(typeText("1234567890")) onView(withId(R.id.registerButton)).perform(click()) onView(withId(R.id.etEmail)).check(matches(hasErrorText("Must enter an email address."))) } @Test fun testInvalidEmailError() { onView(withId(R.id.etFirstName)).perform(typeText("Adam")) onView(withId(R.id.etLastName)).perform(typeText("McNeilly")) onView(withId(R.id.etEmail)).perform(typeText("blahblah")) onView(withId(R.id.etPhone)).perform(typeText("1234567890")) onView(withId(R.id.registerButton)).perform(click()) onView(withId(R.id.etEmail)).check(matches(hasErrorText("Must enter a valid email address."))) } @AdamMc331 #DroidconUK 13
  28. The Problem @AdamMc331 #DroidconUK 14

  29. The Problem • Verbose @AdamMc331 #DroidconUK 14

  30. The Problem • Verbose • Difficult To Read @AdamMc331 #DroidconUK

    14
  31. The Problem • Verbose • Difficult To Read • Difficult

    To Maintain - No Separation Of Concerns @AdamMc331 #DroidconUK 14
  32. No Separation Of Concerns @AdamMc331 #DroidconUK 15

  33. No Separation Of Concerns @AdamMc331 #DroidconUK 16

  34. No Separation Of Concerns @AdamMc331 #DroidconUK 17

  35. Introducing Robots @AdamMc331 #DroidconUK 18

  36. Separation Of Concerns @AdamMc331 #DroidconUK 19

  37. Let's Create A Robot @Test fun testSuccessfulRegistration() { RegistrationRobot() .firstName("Adam")

    .lastName("McNeilly") .email("adam@testing.com") .phone("1234567890") .register() .assertFullNameDisplay("Adam McNeilly") .assertEmailDisplay("adam@testing.com") .assertPhoneDisplay("(123)-456-7890") } @AdamMc331 #DroidconUK 20
  38. Write your tests as if you're telling a Quality Assurance

    Engineer what to do. @AdamMc331 #DroidconUK 21
  39. Define ViewMatchers class RegistrationRobot { companion object { private val

    FIRST_NAME_INPUT_MATCHER = withId(R.id.etFirstName) private val LAST_NAME_INPUT_MATCHER = withId(R.id.etLastName) private val EMAIL_INPUT_MATCHER = withId(R.id.etEmail) private val PHONE_INPUT_MATCHER = withId(R.id.etPhone) private val REGISTER_INPUT_MATCHER = withId(R.id.registerButton) } } @AdamMc331 #DroidconUK 22
  40. One Method For Each Action class RegistrationRobot { fun firstName(firstName:

    String): RegistrationRobot { onView(FIRST_NAME_MATCHER).perform(clearText(), typeText(firstName), closeSoftKeyboard()) return this } fun register(): RegistrationRobot { onView(REGISTER_INPUT_MATCHER).perform(click()) return this } } @AdamMc331 #DroidconUK 23
  41. One Method For Each Assertion class RegistrationRobot { fun assertEmailDisplay(email:

    String) = apply { onView(EMAIL_DISPLAY_MATCHER).check(matches(withText(email))) } fun assertEmailError(error: String) = apply { onView(EMAIL_INPUT_MATCHER).check(matches(hasErrorText(error))) } } @AdamMc331 #DroidconUK 24
  42. Implementation @Test fun testSuccessfulRegistration() { RegistrationRobot() .firstName("Adam") .lastName("McNeilly") .email("adam@testing.com") .phone("1234567890")

    .register() } @AdamMc331 #DroidconUK 25
  43. Easy To Create Negative Test @Test fun testMissingEmailError() { RegistrationRobot()

    .firstName("Adam") .lastName("McNeilly") .phone("1234567890") .register() .assertEmailError("Must enter an email address.") } @AdamMc331 #DroidconUK 26
  44. Work Some Kotlin Magic, If You Want fun registration(func: RegistrationRobot.()

    -> Unit) = RegistrationRobot().apply(func) // ... @Test fun testSuccessfulRegistrationWithOptIn() { registration { firstName("Adam") lastName("McNeilly") email("adam@testing.com") phone("1234567890") emailOptIn() }.register() } @AdamMc331 #DroidconUK 27
  45. Best Practices @AdamMc331 #DroidconUK 28

  46. Leverage Them For Better Test Reporting class RegistrationRobot { //

    Take a screenshot fun firstName(firstName: String) = apply { onView(FIRST_NAME_MATCHER).perform(clearText(), typeText(firstName), closeSoftKeyboard()) takeScreenshot("entered_first_name") } // Log the step fun firstName(firstName: String) = apply { onView(FIRST_NAME_MATCHER).perform(clearText(), typeText(firstName), closeSoftKeyboard()) Timber.d("Entering first name") } } @AdamMc331 #DroidconUK 29
  47. Use One Robot Per Screen @Test fun testSuccessfulRegistrationWithOptIn() { RegistrationRobot()

    .firstName("Adam") .lastName("McNeilly") .email("adam@testing.com") .phone("1234567890") .emailOptIn() .register() UserProfileRobot() .assertFullNameDisplay("Adam McNeilly") .assertEmailDisplay("adam@testing.com") .assertPhoneDisplay("(123)-456-7890") .assertOptedIn() } @AdamMc331 #DroidconUK 30
  48. Don't Chain Robots // Sounds reasonable... fun register(): UserProfileRobot {

    onView(REGISTER_INPUT_MATCHER).perform(click()) return UserProfileRobot() } // Unable to run negative tests now @Test fun testMissingEmailError() { RegistrationRobot() .register() .assertEmailError("Must enter an email address.") // Undefined Method } @AdamMc331 #DroidconUK 31
  49. Don't Put Conditional Logic In Robot // Sounds reasonable... //

    But who tests the tests? class UserProfileRobot { fun assertOptInStatus(optedIn: Boolean) = apply { val optInMatcher = if (optedIn) isChecked() else isNotChecked() onView(EMAIL_OPT_IN_DISPLAY_MATCHER).check(matches(optInMatcher)) } } @AdamMc331 #DroidconUK 32
  50. Use Separate Methods Instead class UserProfileRobot { fun assertOptedIn() =

    apply { onView(EMAIL_OPT_IN_DISPLAY_MATCHER).check(matches(isChecked())) } fun assertOptedOut() = apply { onView(EMAIL_OPT_IN_DISPLAY_MATCHER).check(matches(isNotChecked())) } } @AdamMc331 #DroidconUK 33
  51. Unit Testing With Robots @AdamMc331 #DroidconUK 34

  52. ViewModel Example class UserViewModel( private val repository: UserRepository ): ViewModel()

    { val state = MutableLiveData<NetworkState<User>>() fun fetchUser() { repository .fetchUser() .subscribe( { user -> state.value = NetworkState.Loaded(user) }, { error -> state.value = NetworkState.Error(error) } ) } } @AdamMc331 #DroidconUK 35
  53. You May Write Tests This Way @Test fun successfulFetch() {

    val mockRepository = mock(UserRepository::class.java) val sampleUser = User("Adam") whenever(mockRepository.fetchUser()).thenReturn(Single.just(sampleUser)) val viewModel = UserViewModel(mockRepository) viewModel.fetchUser() val currentState = viewModel.state.testObserver().observedValue assertEquals(NetworkState.Loaded(sampleUser), currentState) } @AdamMc331 #DroidconUK 36
  54. Create A Robot Here Too class UserViewModelRobot { private val

    mockRepository = mock(UserRepository::class.java) private val viewModel = UserViewModel(mockRepository) fun mockUserResponse(user: User) = apply { val response = Single.just(user) whenever(mockRepository.fetchUser()).thenReturn(response) } fun fetchUser() = apply { viewModel.fetchUser() } fun assertState(expectedState: NetworkState<User>) = apply { val currentState = viewModel.state.testObserver().observedValue assertEquals(expectedState, currentState) } } @AdamMc331 #DroidconUK 37
  55. Implement Robot class UserViewModelTest { private lateinit var testRobot: UserViewModelRobot

    @Before fun setUp() { // Robot should be specific to each test testRobot = UserViewModelRobot() } @Test fun successfulFetch() { val sampleUser = User("Adam") testRobot .mockUserResponse(sampleUser) .fetchUser() .assertState(NetworkState.Loaded(sampleUser)) } } @AdamMc331 #DroidconUK 38
  56. Easy To Add Error Test class UserViewModelRobot { // ...

    fun mockUserError(error: Throwable?) = apply { val response = Single.error<User>(error) whenever(mockRepository.fetchUser()).thenReturn(response) } } class UserViewModelTest { // ... @Test fun failureFetch() { val sampleError = Throwable("Whoops") testRobot .mockUserError(sampleError) .fetchUser() .assertState(NetworkState.Error(sampleError)) } } @AdamMc331 #DroidconUK 39
  57. Let's See It In Action @AdamMc331 #DroidconUK 40

  58. Update Our ViewModel class UserViewModel( private val repository: UserRepository ):

    ViewModel() { // val state = MutableLiveData<NetworkState<User>>() val state: BehaviorSubject<NetworkState<User>> = BehaviorSubject.create() } @AdamMc331 #DroidconUK 41
  59. Update One Robot Method class UserViewModelRobot { fun assertState(expectedState: NetworkState<User>)

    = apply { // val currentState = viewModel.state.testObserver().observedValue val currentState = viewModel.state.value assertEquals(expectedState, currentState) } } @AdamMc331 #DroidconUK 42
  60. Everything Is Passing Again @AdamMc331 #DroidconUK 43

  61. Recap @AdamMc331 #DroidconUK 44

  62. Recap • Utilize robot pattern for more readable and maintainable

    tests @AdamMc331 #DroidconUK 44
  63. Recap • Utilize robot pattern for more readable and maintainable

    tests • Take advantage of this pattern to introduce better test reporting @AdamMc331 #DroidconUK 44
  64. Recap • Utilize robot pattern for more readable and maintainable

    tests • Take advantage of this pattern to introduce better test reporting • Don't code yourself into a corner with additional complexity @AdamMc331 #DroidconUK 44
  65. Recap • Utilize robot pattern for more readable and maintainable

    tests • Take advantage of this pattern to introduce better test reporting • Don't code yourself into a corner with additional complexity • Don't chain robots @AdamMc331 #DroidconUK 44
  66. Recap • Utilize robot pattern for more readable and maintainable

    tests • Take advantage of this pattern to introduce better test reporting • Don't code yourself into a corner with additional complexity • Don't chain robots • Don't include any logic in the robot methods @AdamMc331 #DroidconUK 44
  67. Recap • Utilize robot pattern for more readable and maintainable

    tests • Take advantage of this pattern to introduce better test reporting • Don't code yourself into a corner with additional complexity • Don't chain robots • Don't include any logic in the robot methods • This concept is not specific to Espresso, or UI testing @AdamMc331 #DroidconUK 44
  68. Thank You! Sample App & Slides: https://github.com/AdamMc331/ EspressoPatronum @AdamMc331 #DroidconUK

    45