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. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. The Problem • Verbose • Difficult To Read • Difficult

    To Maintain - No Separation Of Concerns @AdamMc331 #DroidconUK 14
  9. 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
  10. Write your tests as if you're telling a Quality Assurance

    Engineer what to do. @AdamMc331 #DroidconUK 21
  11. 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
  12. 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
  13. 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
  14. 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
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  22. 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
  23. 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
  24. 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
  25. 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
  26. 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
  27. 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
  28. Recap • Utilize robot pattern for more readable and maintainable

    tests • Take advantage of this pattern to introduce better test reporting @AdamMc331 #DroidconUK 44
  29. 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
  30. 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
  31. 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
  32. 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