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

Introducing UI Testing to Your Team

Introducing UI Testing to Your Team

Mustafa Berkay Mutlu

November 25, 2019
Tweet

More Decks by Mustafa Berkay Mutlu

Other Decks in Programming

Transcript

  1. A Typical Test Case • ID and name of the

    test • Preconditions • Steps with expected result
  2. A Typical Test Case • Step: When I enter MoneyBeam

    and select a contact • Expected: Then I see the money amount entrance screen and the screen has these: • Top left: origin account's name and balance • Top right: destination account's name • Center: money input • Center: description text • Bottom: Continue button that is not in enabled/clickable state
  3. • 3200+ test cases • Testing 500 to 850 cases,

    in each release • Releasing every once in 3 weeks • This is a lot of work!
  4. • Automated end-to-end UI tests • Both for Android and

    iOS • Black-box testing • Uses Appium Photo by ActionVance on Unsplash Batman Project
  5. • Slow test execution • Not included in our CI

    pipeline • Difficult to maintain • Flakiness caused by E2E Batman Project
  6. Espresso • Focused on testing the UI (not E2E) •

    Maintained by the same feature developers
  7. @Test fun moneyBeamSample() { apiServer.registerJsonResponse(MoneyBeamApiRequests.MyApiRequest("a-param"), responseRaw().jsonify()) val intent = MoneyBeamTransferActivity.newIntent(targetContext,

    "Namey McNameface") setupUser { firstName("John") lastName("Doe") currency("GBP") balance(10.toBigDecimal()) } activityScenarioRule.launchActivity(intent) ...
  8. @Test fun moneyBeamSample() { apiServer.registerJsonResponse(MoneyBeamApiRequests.MyApiRequest("a-param"), responseRaw().jsonify()) val intent = MoneyBeamTransferActivity.newIntent(targetContext,

    "Namey McNameface") setupUser { firstName("John") lastName("Doe") currency("GBP") balance(10.toBigDecimal()) } activityScenarioRule.launchActivity(intent) ...
  9. @Test fun moneyBeamSample() { apiServer.registerJsonResponse(MoneyBeamApiRequests.MyApiRequest("a-param"), responseRaw().jsonify()) val intent = MoneyBeamTransferActivity.newIntent(targetContext,

    "Namey McNameface") setupUser { firstName("John") lastName("Doe") currency("GBP") balance(10.toBigDecimal()) } activityScenarioRule.launchActivity(intent) ...
  10. @Test fun moneyBeamSample() { apiServer.registerJsonResponse(MoneyBeamApiRequests.MyApiRequest("a-param"), responseRaw().jsonify()) val intent = MoneyBeamTransferActivity.newIntent(targetContext,

    "Namey McNameface") setupUser { firstName("John") lastName("Doe") currency("GBP") balance(10.toBigDecimal()) } activityScenarioRule.launchActivity(intent) ...
  11. @Test fun moneyBeamSample() { apiServer.registerJsonResponse(MoneyBeamApiRequests.MyApiRequest("a-param"), responseRaw().jsonify()) val intent = MoneyBeamTransferActivity.newIntent(targetContext,

    "Namey McNameface") setupUser { firstName("John") lastName("Doe") currency("GBP") balance(10.toBigDecimal()) } activityScenarioRule.launchActivity(intent) ...
  12. @Test fun moneyBeamSample() { apiServer.registerJsonResponse(MoneyBeamApiRequests.MyApiRequest("a-param"), responseRaw().jsonify()) val intent = MoneyBeamTransferActivity.newIntent(targetContext,

    "Namey McNameface") setupUser { firstName("John") lastName("Doe") currency("GBP") balance(10.toBigDecimal()) } activityScenarioRule.launchActivity(intent) ...
  13. ... assertClickableNavigateUpButton() onView(allOf(withParent(withId(originContainer)), withId(nameText))) .check(matches(allOf(isDisplayed(), withText("John Doe")))) onView(allOf(withParent(withId(originContainer)), withId(initialsText))) .check(matches(allOf(isDisplayed(),

    withText("JD")))) onView(allOf(withParent(withId(originContainer)), withId(balanceText))) .check(matches(allOf(isDisplayed(), withText("£10.00")))) assertDisplayed(descriptionText, "Send to Namey") assertNotDisplayed(errorText) isButtonEnabled(continueButton) writeTo(amountEditText, "1100") assertDisplayed(errorText, "You can only send MoneyBeams up to £1,000") assertNotDisplayed(descriptionText) isButtonDisabled(continueButton) }
  14. ... assertClickableNavigateUpButton() onView(allOf(withParent(withId(originContainer)), withId(nameText))) .check(matches(allOf(isDisplayed(), withText("John Doe")))) onView(allOf(withParent(withId(originContainer)), withId(initialsText))) .check(matches(allOf(isDisplayed(),

    withText("JD")))) onView(allOf(withParent(withId(originContainer)), withId(balanceText))) .check(matches(allOf(isDisplayed(), withText("£10.00")))) assertDisplayed(descriptionText, "Send to Namey") assertNotDisplayed(errorText) isButtonEnabled(continueButton) writeTo(amountEditText, "1100") assertDisplayed(errorText, "You can only send MoneyBeams up to £1,000") assertNotDisplayed(descriptionText) isButtonDisabled(continueButton) }
  15. ... assertClickableNavigateUpButton() onView(allOf(withParent(withId(originContainer)), withId(nameText))) .check(matches(allOf(isDisplayed(), withText("John Doe")))) onView(allOf(withParent(withId(originContainer)), withId(initialsText))) .check(matches(allOf(isDisplayed(),

    withText("JD")))) onView(allOf(withParent(withId(originContainer)), withId(balanceText))) .check(matches(allOf(isDisplayed(), withText("£10.00")))) assertDisplayed(descriptionText, "Send to Namey") assertNotDisplayed(errorText) isButtonEnabled(continueButton) writeTo(amountEditText, "1100") assertDisplayed(errorText, "You can only send MoneyBeams up to £1,000") assertNotDisplayed(descriptionText) isButtonDisabled(continueButton) }
  16. ... assertClickableNavigateUpButton() onView(allOf(withParent(withId(originContainer)), withId(nameText))) .check(matches(allOf(isDisplayed(), withText("John Doe")))) onView(allOf(withParent(withId(originContainer)), withId(initialsText))) .check(matches(allOf(isDisplayed(),

    withText("JD")))) onView(allOf(withParent(withId(originContainer)), withId(balanceText))) .check(matches(allOf(isDisplayed(), withText("£10.00")))) assertDisplayed(descriptionText, "Send to Namey") assertNotDisplayed(errorText) isButtonEnabled(continueButton) writeTo(amountEditText, "1100") assertDisplayed(errorText, "You can only send MoneyBeams up to £1,000") assertNotDisplayed(descriptionText) isButtonDisabled(continueButton) }
  17. ... assertClickableNavigateUpButton() onView(allOf(withParent(withId(originContainer)), withId(nameText))) .check(matches(allOf(isDisplayed(), withText("John Doe")))) onView(allOf(withParent(withId(originContainer)), withId(initialsText))) .check(matches(allOf(isDisplayed(),

    withText("JD")))) onView(allOf(withParent(withId(originContainer)), withId(balanceText))) .check(matches(allOf(isDisplayed(), withText("£10.00")))) assertDisplayed(descriptionText, "Send to Namey") assertNotDisplayed(errorText) isButtonEnabled(continueButton) writeTo(amountEditText, "1100") assertDisplayed(errorText, "You can only send MoneyBeams up to £1,000") assertNotDisplayed(descriptionText) isButtonDisabled(continueButton) }
  18. ... moneyBeamSampleRobot { assertClickableNavigateUpButton() isOriginDisplayed("John Doe", "JD", "£10.00") isDestinationDisplayed("Namey McNameface",

    "NM") isDescriptionDisplayed("Send to Namey") isContinueButtonDisabled() enterAmount("1100") isErrorDisplayed("You can only send MoneyBeams up to £1,000") isContinueButtonDisabled() enterAmount("10") isDescriptionDisplayed("Send to Namey") isContinueButtonEnabled() } }
  19. ... moneyBeamSampleRobot { assertClickableNavigateUpButton() isOriginDisplayed("John Doe", "JD", "£10.00") isDestinationDisplayed("Namey McNameface",

    "NM") isDescriptionDisplayed("Send to Namey") isContinueButtonDisabled() enterAmount("1100") isErrorDisplayed("You can only send MoneyBeams up to £1,000") isContinueButtonDisabled() enterAmount("10") isDescriptionDisplayed("Send to Namey") isContinueButtonEnabled() } }
  20. ... moneyBeamSampleRobot { assertClickableNavigateUpButton() isOriginDisplayed("John Doe", "JD", "£10.00") isDestinationDisplayed("Namey McNameface",

    "NM") isDescriptionDisplayed("Send to Namey") isContinueButtonDisabled() enterAmount("1100") isErrorDisplayed("You can only send MoneyBeams up to £1,000") isContinueButtonDisabled() enterAmount("10") isDescriptionDisplayed("Send to Namey") isContinueButtonEnabled() } }
  21. ... moneyBeamSampleRobot { assertClickableNavigateUpButton() isOriginDisplayed("John Doe", "JD", "£10.00") isDestinationDisplayed("Namey McNameface",

    "NM") isDescriptionDisplayed("Send to Namey") isContinueButtonDisabled() enterAmount("1100") isErrorDisplayed("You can only send MoneyBeams up to £1,000") isContinueButtonDisabled() enterAmount("10") isDescriptionDisplayed("Send to Namey") isContinueButtonEnabled() } }
  22. class MoneyBeamSampleRobot : BaseRobot() { private val amountEditText = R.id.editMoneyBeamTransferAmount

    private val descriptionText = R.id.textMoneyBeamTransferAmountDescription private val errorText = R.id.textMoneyBeamTransferAmountError // other view IDs fun isDescriptionDisplayed(text: String) { assertDisplayed(descriptionText, text) assertNotDisplayed(errorText) } fun isErrorDisplayed(text: String) { ... } fun isOriginDisplayed(name: String, initials: String, balance: String) { onView(allOf(withParent(withId(originContainer)), withId(nameText))).check(matches(allOf(isDisplayed(), withText(name)))) onView(allOf(withParent(withId(originContainer)), withId(initialsText))).check(matches(allOf(isDisplayed(), withText(initials)))) onView(allOf(withParent(withId(originContainer)), withId(balanceText))).check(matches(allOf(isDisplayed(), withText(balance)))) } fun isDestinationDisplayed(name: String, initials: String) { ... } fun isContinueButtonEnabled() = isButtonEnabled(continueButton) fun isContinueButtonDisabled() = isButtonDisabled(continueButton) fun enterAmount(amount: String) = writeTo(amountEditText, amount) fun clickOnContinueButton() = clickOn(continueButton) }
  23. class MoneyBeamSampleRobot : BaseRobot() { private val amountEditText = R.id.editMoneyBeamTransferAmount

    private val descriptionText = R.id.textMoneyBeamTransferAmountDescription private val errorText = R.id.textMoneyBeamTransferAmountError // other view IDs fun isDescriptionDisplayed(text: String) { assertDisplayed(descriptionText, text) assertNotDisplayed(errorText) } fun isErrorDisplayed(text: String) { ... } fun isOriginDisplayed(name: String, initials: String, balance: String) { onView(allOf(withParent(withId(originContainer)), withId(nameText))).check(matches(allOf(isDisplayed(), withText(name)))) onView(allOf(withParent(withId(originContainer)), withId(initialsText))).check(matches(allOf(isDisplayed(), withText(initials)))) onView(allOf(withParent(withId(originContainer)), withId(balanceText))).check(matches(allOf(isDisplayed(), withText(balance)))) } fun isDestinationDisplayed(name: String, initials: String) { ... } fun isContinueButtonEnabled() = isButtonEnabled(continueButton) fun isContinueButtonDisabled() = isButtonDisabled(continueButton) fun enterAmount(amount: String) = writeTo(amountEditText, amount) fun clickOnContinueButton() = clickOn(continueButton) }
  24. class MoneyBeamSampleRobot : BaseRobot() { private val amountEditText = R.id.editMoneyBeamTransferAmount

    private val descriptionText = R.id.textMoneyBeamTransferAmountDescription private val errorText = R.id.textMoneyBeamTransferAmountError // other view IDs fun isDescriptionDisplayed(text: String) { assertDisplayed(descriptionText, text) assertNotDisplayed(errorText) } fun isErrorDisplayed(text: String) { ... } fun isOriginDisplayed(name: String, initials: String, balance: String) { onView(allOf(withParent(withId(originContainer)), withId(nameText))).check(matches(allOf(isDisplayed(), withText(name)))) onView(allOf(withParent(withId(originContainer)), withId(initialsText))).check(matches(allOf(isDisplayed(), withText(initials)))) onView(allOf(withParent(withId(originContainer)), withId(balanceText))).check(matches(allOf(isDisplayed(), withText(balance)))) } fun isDestinationDisplayed(name: String, initials: String) { ... } fun isContinueButtonEnabled() = isButtonEnabled(continueButton) fun isContinueButtonDisabled() = isButtonDisabled(continueButton) fun enterAmount(amount: String) = writeTo(amountEditText, amount) fun clickOnContinueButton() = clickOn(continueButton) }
  25. class MoneyBeamSampleRobot : BaseRobot() { private val amountEditText = R.id.editMoneyBeamTransferAmount

    private val descriptionText = R.id.textMoneyBeamTransferAmountDescription private val errorText = R.id.textMoneyBeamTransferAmountError // other view IDs fun isDescriptionDisplayed(text: String) { assertDisplayed(descriptionText, text) assertNotDisplayed(errorText) } fun isErrorDisplayed(text: String) { ... } fun isOriginDisplayed(name: String, initials: String, balance: String) { onView(allOf(withParent(withId(originContainer)), withId(nameText))).check(matches(allOf(isDisplayed(), withText(name)))) onView(allOf(withParent(withId(originContainer)), withId(initialsText))).check(matches(allOf(isDisplayed(), withText(initials)))) onView(allOf(withParent(withId(originContainer)), withId(balanceText))).check(matches(allOf(isDisplayed(), withText(balance)))) } fun isDestinationDisplayed(name: String, initials: String) { ... } fun isContinueButtonEnabled() = isButtonEnabled(continueButton) fun isContinueButtonDisabled() = isButtonDisabled(continueButton) fun enterAmount(amount: String) = writeTo(amountEditText, amount) fun clickOnContinueButton() = clickOn(continueButton) }
  26. class MoneyBeamSampleRobot : BaseRobot() { private val amountEditText = R.id.editMoneyBeamTransferAmount

    private val descriptionText = R.id.textMoneyBeamTransferAmountDescription private val errorText = R.id.textMoneyBeamTransferAmountError // other view IDs fun isDescriptionDisplayed(text: String) { assertDisplayed(descriptionText, text) assertNotDisplayed(errorText) } fun isErrorDisplayed(text: String) { ... } fun isOriginDisplayed(name: String, initials: String, balance: String) { onView(allOf(withParent(withId(originContainer)), withId(nameText))).check(matches(allOf(isDisplayed(), withText(name)))) onView(allOf(withParent(withId(originContainer)), withId(initialsText))).check(matches(allOf(isDisplayed(), withText(initials)))) onView(allOf(withParent(withId(originContainer)), withId(balanceText))).check(matches(allOf(isDisplayed(), withText(balance)))) } fun isDestinationDisplayed(name: String, initials: String) { ... } fun isContinueButtonEnabled() = isButtonEnabled(continueButton) fun isContinueButtonDisabled() = isButtonDisabled(continueButton) fun enterAmount(amount: String) = writeTo(amountEditText, amount) fun clickOnContinueButton() = clickOn(continueButton) }
  27. Animations "To avoid flakiness, we highly recommend that you turn

    off system animations on the virtual or physical devices used for testing." - Espresso docs
  28. Animations • System animations • Dynamic Animations from AndroidX Our

    solution: add a switch to turn off custom animations
  29. Dealing Asynchronicity • Tests need to know when to wait

    and proceed • Causing flakiness Our solution: make use of Idling Resources • OkHttpIdlingResource • RxIdler
  30. Dealing with Localization English (US) English (Canada) • Same account

    (United States) • Same currency (USD) • Same language (English) • Different device locale • Result: different money representation
  31. Dealing with Localization • Using device locale for formatting monetary

    amounts • Test device's locale is unpredictable • Causing flaky tests Our solution: add a test rule to define the device locale
  32. Dealing With Flakiness • Identify the cause and update the

    test • Add retry for failed tests (FlakyTestRule from the Barista library)
  33. Selectively Running the Tests • Find changed files from the

    Git history • Find which features are changed • Run the tests of the changed features • If there is a change in a common module, trigger the full run • See: goo.gle/androidx-dependency-tracker
  34. android-project git:(my-branch) ./run-on-remote.sh -h | Running on Remote usage: -s

    | --skip-build Skip building tasks -n name | --test-name name Specify the name of the test. Default is branch name -d device | --device device Specify the name of the device on which the tests will be run. Default is random -p packages | --packages packages The list of packages to run the tests (comma separated). Default is all -l | --log Log the output to log.txt -h | --help Display help
  35. Running on Remote Test Name Device Name Execution Time Duration

    my-branch-name my-branch-name my-branch-name
  36. Reporting the Test Results • Link Espresso tests with test

    case IDs • Add a test rule to watch the test results • Post the test results to test management tool
  37. Unit Tests ~500 UI Tests Functional UI Tests Oct 14

    Jan 2016 Jan 2017 Jan 2018 Feb 2019 ~9000 Unit tests Today
  38. Unit Tests ~500 UI Tests Oct 14 Jan 2016 Jan

    2017 Jan 2018 Feb 2019 Today Functional UI Tests ~9000 Unit tests
  39. Unit Tests ~500 UI Tests Oct 14 Jan 2016 Jan

    2017 Jan 2018 Feb 2019 Today Functional UI Tests Automated Regression 10% ~9000 Unit tests
  40. Unit Tests ~500 UI Tests Oct 14 Jan 2016 Jan

    2017 Jan 2018 Feb 2019 Today Functional UI Tests Automated Regression 50% ~9000 Unit tests
  41. • Faster test execution • Part of our CI pipeline

    • Maintained together with the production code • ☑ Mostly trusty Espresso Project
  42. Snapshot Testing Verifies view attributes: • Position • Color •

    Size Is amount input displayed? ✅ Passed Is amount input displayed? ✅ Passed
  43. Snapshot Testing Verifies view attributes: • Position • Color •

    Size Is amount input displayed? ✅ Passed Is amount input displayed? ❌ Failed