Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Droidcon London: Espresso Patronum

Droidcon London: Espresso Patronum

A presentation about maintainable testing with the robot pattern.

Adam McNeilly

October 25, 2019
Tweet

More Decks by Adam McNeilly

Other Decks in Programming

Transcript

  1. Espresso Patronum:
    The Magic of the Robot Pattern
    Adam McNeilly - @AdamMc331
    @AdamMc331
    #DroidconUK 1

    View full-size slide

  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

    View full-size slide

  3. Three Classes To Know
    @AdamMc331
    #DroidconUK 3

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  6. Three Classes To Know
    • ViewMatchers
    • ViewActions
    • ViewAssertions
    @AdamMc331
    #DroidconUK 3

    View full-size slide

  7. ViewMatchers
    @AdamMc331
    #DroidconUK 4

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  12. ViewActions
    @AdamMc331
    #DroidconUK 5

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  17. ViewAssertions
    @AdamMc331
    #DroidconUK 6

    View full-size slide

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

    View full-size slide

  19. ViewAssertions

    matches(Matcher)

    isLeftOf(Matcher)
    @AdamMc331
    #DroidconUK 6

    View full-size slide

  20. ViewAssertions

    matches(Matcher)

    isLeftOf(Matcher)

    doesNotExist()
    @AdamMc331
    #DroidconUK 6

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  23. Sample App
    @AdamMc331
    #DroidconUK 9

    View full-size slide

  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("[email protected]"))
    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("[email protected]")))
    onView(withId(R.id.tvPhoneNumber)).check(matches(withText("(123)-456-7890")))
    }
    @AdamMc331
    #DroidconUK 10

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  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("[email protected]"))
    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("[email protected]")))
    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

    View full-size slide

  28. The Problem
    @AdamMc331
    #DroidconUK 14

    View full-size slide

  29. The Problem
    • Verbose
    @AdamMc331
    #DroidconUK 14

    View full-size slide

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

    View full-size slide

  31. The Problem
    • Verbose
    • Difficult To Read
    • Difficult To Maintain - No Separation Of Concerns
    @AdamMc331
    #DroidconUK 14

    View full-size slide

  32. No Separation Of Concerns
    @AdamMc331
    #DroidconUK 15

    View full-size slide

  33. No Separation Of Concerns
    @AdamMc331
    #DroidconUK 16

    View full-size slide

  34. No Separation Of Concerns
    @AdamMc331
    #DroidconUK 17

    View full-size slide

  35. Introducing Robots
    @AdamMc331
    #DroidconUK 18

    View full-size slide

  36. Separation Of Concerns
    @AdamMc331
    #DroidconUK 19

    View full-size slide

  37. Let's Create A Robot
    @Test
    fun testSuccessfulRegistration() {
    RegistrationRobot()
    .firstName("Adam")
    .lastName("McNeilly")
    .email("[email protected]")
    .phone("1234567890")
    .register()
    .assertFullNameDisplay("Adam McNeilly")
    .assertEmailDisplay("[email protected]")
    .assertPhoneDisplay("(123)-456-7890")
    }
    @AdamMc331
    #DroidconUK 20

    View full-size slide

  38. Write your tests as if you're telling a Quality Assurance
    Engineer what to do.
    @AdamMc331
    #DroidconUK 21

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  42. Implementation
    @Test
    fun testSuccessfulRegistration() {
    RegistrationRobot()
    .firstName("Adam")
    .lastName("McNeilly")
    .email("[email protected]")
    .phone("1234567890")
    .register()
    }
    @AdamMc331
    #DroidconUK 25

    View full-size slide

  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

    View full-size slide

  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("[email protected]")
    phone("1234567890")
    emailOptIn()
    }.register()
    }
    @AdamMc331
    #DroidconUK 27

    View full-size slide

  45. Best Practices
    @AdamMc331
    #DroidconUK 28

    View full-size slide

  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

    View full-size slide

  47. Use One Robot Per Screen
    @Test
    fun testSuccessfulRegistrationWithOptIn() {
    RegistrationRobot()
    .firstName("Adam")
    .lastName("McNeilly")
    .email("[email protected]")
    .phone("1234567890")
    .emailOptIn()
    .register()
    UserProfileRobot()
    .assertFullNameDisplay("Adam McNeilly")
    .assertEmailDisplay("[email protected]")
    .assertPhoneDisplay("(123)-456-7890")
    .assertOptedIn()
    }
    @AdamMc331
    #DroidconUK 30

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  51. Unit Testing With Robots
    @AdamMc331
    #DroidconUK 34

    View full-size slide

  52. ViewModel Example
    class UserViewModel(
    private val repository: UserRepository
    ): ViewModel() {
    val state = MutableLiveData>()
    fun fetchUser() {
    repository
    .fetchUser()
    .subscribe(
    { user ->
    state.value = NetworkState.Loaded(user)
    },
    { error ->
    state.value = NetworkState.Error(error)
    }
    )
    }
    }
    @AdamMc331
    #DroidconUK 35

    View full-size slide

  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

    View full-size slide

  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) = apply {
    val currentState = viewModel.state.testObserver().observedValue
    assertEquals(expectedState, currentState)
    }
    }
    @AdamMc331
    #DroidconUK 37

    View full-size slide

  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

    View full-size slide

  56. Easy To Add Error Test
    class UserViewModelRobot {
    // ...
    fun mockUserError(error: Throwable?) = apply {
    val response = Single.error(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

    View full-size slide

  57. Let's See It In Action
    @AdamMc331
    #DroidconUK 40

    View full-size slide

  58. Update Our ViewModel
    class UserViewModel(
    private val repository: UserRepository
    ): ViewModel() {
    // val state = MutableLiveData>()
    val state: BehaviorSubject> = BehaviorSubject.create()
    }
    @AdamMc331
    #DroidconUK 41

    View full-size slide

  59. Update One Robot Method
    class UserViewModelRobot {
    fun assertState(expectedState: NetworkState) = apply {
    // val currentState = viewModel.state.testObserver().observedValue
    val currentState = viewModel.state.value
    assertEquals(expectedState, currentState)
    }
    }
    @AdamMc331
    #DroidconUK 42

    View full-size slide

  60. Everything Is Passing Again
    @AdamMc331
    #DroidconUK 43

    View full-size slide

  61. Recap
    @AdamMc331
    #DroidconUK 44

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  68. Thank You!
    Sample App & Slides: https://github.com/AdamMc331/
    EspressoPatronum
    @AdamMc331
    #DroidconUK 45

    View full-size slide