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

Introducing UI Testing to Your Team

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on. →

Introducing UI Testing to Your Team

Avatar for Mustafa Berkay Mutlu

Mustafa Berkay Mutlu

November 25, 2019

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