Skip to content

Instantly share code, notes, and snippets.

@bharatdodeja
Last active December 9, 2018 07:35
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bharatdodeja/cc5957dd95f6066a6e663c64c3933ac1 to your computer and use it in GitHub Desktop.
Save bharatdodeja/cc5957dd95f6066a6e663c64c3933ac1 to your computer and use it in GitHub Desktop.
Android instrumentation testing using Espresso and Robot pattern
package com.bharatdodeja.robotpattern.utils
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/**
* Created by bharat.dodeja@gmail.com
*/
class FailureScreenshotRule : TestWatcher() {
override fun failed(e: Throwable?, description: Description) {
val parentFolderPath = "failures/${description.className}"
takeScreenshotWithParent(parentFolder = parentFolderPath,
name = description.methodName)
}
}
package com.bharatdodeja.robotpattern.utils
import android.support.annotation.StringRes
import android.support.design.widget.TextInputLayout
import android.view.View
import org.hamcrest.Description
import org.hamcrest.TypeSafeMatcher
/**
* Error text matcher for TextInputLayout
*
* Created by bharat.dodeja@gmail.com
*/
class TextInputLayoutMatcher(@param:StringRes private val expectedErrorTextId: Int) :
TypeSafeMatcher<View>(View::class.java) {
override fun matchesSafely(view: View): Boolean {
if (view !is TextInputLayout) {
return false
}
val error = view.error ?: return false
val hint = error.toString()
return view.resources.getString(expectedErrorTextId) == hint
}
override fun describeTo(description: Description) {
description.appendText("with error text: ")
}
}
fun hasErrorTextInTextInputLayout(@StringRes expectedId: Int): TextInputLayoutMatcher {
return TextInputLayoutMatcher(expectedId)
}
package com.bharatdodeja.robotpattern.login
import android.support.test.espresso.IdlingRegistry
import android.support.test.filters.LargeTest
import android.support.test.rule.ActivityTestRule
import android.support.test.runner.AndroidJUnit4
import com.bharatdodeja.robotpattern.utils.FailureScreenshotRule
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Created by bharat.dodeja@gmail.com
*/
@LargeTest
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@Rule
@JvmField
var activityTestRule = ActivityTestRule(LoginActivity::class.java)
@get:Rule
val screenShotRule = FailureScreenshotRule()
/**
* Prepare your test fixture for this test. In this case we clickRegister an IdlingResources with
* Espresso. IdlingResource resource is a great way to tell Espresso when your app is in an
* idle state. This helps Espresso to synchronize your test actions, which makes tests significantly
* more reliable.
*/
@Before
fun registerIdlingResource() {
IdlingRegistry.getInstance().register(
activityTestRule.activity.getIdlingResource())
}
@After
fun unRegisterIdlingResource() {
IdlingRegistry.getInstance().unregister(
activityTestRule.activity.getIdlingResource())
}
@Test
fun enterEmptyCredentials_showValidationError() {
loginScreen {
userName("")
password("")
} clickLogin {
screenshot("enterEmptyCredentials_showValidationError")
isEmailEmptyError()
isPasswordEmptyError()
}
}
@Test
fun enterCredentialsInInvalidFormat_showInvalidFormatError() {
loginScreen {
userName("invalidEmail")
password("123")
} clickLogin {
screenshot("enterCredentialsInInvalidFormat_showInvalidFormatError")
isEmailInvalidFormatError()
isPasswordInvalidFormatError()
}
}
@Test
fun onClickOfForgotPassword_showsForgotPasswordScreen() {
loginScreen {
} clickForgotPassword {
screenshot("onClickOfForgotPassword_showsForgotPasswordScreen")
isForgotPasswordScreenShown()
}
}
@Test
fun enteredWrongCredentials_showError() {
loginScreen {
userName("john.deo@gmail.com")
password("some wrong password")
} clickLogin {
screenshot("enteredWrongCredentials_showError")
userDoesNotExistError()
}
}
}
package com.bharatdodeja.robotpattern.login
import android.support.test.espresso.Espresso.onView
import android.support.test.espresso.action.ViewActions.click
import android.support.test.espresso.action.ViewActions.replaceText
import android.support.test.espresso.assertion.ViewAssertions.matches
import android.support.test.espresso.matcher.ViewMatchers.*
import com.bharatdodeja.robotpattern.forgotpassword.forgotPasswordScreen
import com.bharatdodeja.robotpattern.register.registrationScreen
import com.bharatdodeja.robotpattern.utils.BaseRobot
import com.bharatdodeja.robotpattern.utils.hasErrorTextInTextInputLayout
/**
* Robot class for [LoginActivityTest], separating HOW from WHAT of testing
* by following robot pattern as test architecture.
*
* Created by bharat.dodeja@gmail.com
*/
class LoginRobot : BaseRobot() {
fun userName(email: String) {
onView(withId(R.id.etEmailId))
.perform(replaceText(email))
}
fun password(password: String) {
onView(withId(R.id.etPassword))
.perform(replaceText(password))
}
infix fun clickLogin(func: Result.() -> Unit): Result {
onView(withText(R.string.label_login))
.perform(click())
return Result().apply { func() }
}
infix fun clickForgotPassword(func: Result.() -> Unit): Result {
onView(withText(R.string.label_forgot_password))
.perform(click())
return Result().apply { func() }
}
infix fun clickRegisterHere(func: Result.() -> Unit): Result {
onView(withText(R.string.label_register_user))
.perform(click())
return Result().apply { func() }
}
class Result : BaseRobot() {
fun userDoesNotExistError() {
onView(withText(R.string.msg_user_does_not_exists))
.check(matches(isDisplayed()))
}
fun isEmailEmptyError() {
onView(withId(R.id.tilEmailId))
.check(matches(hasErrorTextInTextInputLayout(error_email_empty)))
}
fun isPasswordEmptyError() {
onView(withId(R.id.tilPassword))
.check(matches(hasErrorTextInTextInputLayout(error_password_empty)))
}
fun isEmailInvalidFormatError() {
onView(withId(R.id.tilEmailId))
.check(matches(hasErrorTextInTextInputLayout(error_email_format)))
}
fun isPasswordInvalidFormatError() {
onView(withId(R.id.tilPassword))
.check(matches(hasErrorTextInTextInputLayout(error_password_length)))
}
fun isRegistrationScreenShown() {
registrationScreen { }
}
fun isForgotPasswordScreenShown() {
forgotPasswordScreen { }
}
fun isSuccess() {
onView(withText(R.string.msg_login_successful))
.check(matches(isDisplayed()))
}
}
}
/**
* Extension function for building LoginRobot and asserting expected views are available
*/
fun loginScreen(func: LoginRobot.() -> Unit): LoginRobot {
//assert expected views are displayed
//assert branding Logo is displayed
onView(withId(R.id.iv_logo))
.check(matches(isDisplayed()))
//assert Email ID field is displayed
onView(withId(R.id.etEmailId))
.check(matches(isDisplayed()))
//assert Password field is displayed
onView(withId(R.id.etPassword))
.check(matches(isDisplayed()))
//assert Login button is displayed
onView(withText(R.string.label_login))
.check(matches(isDisplayed()))
//assert Forgot Password button is displayed
onView(withText(R.string.label_forgot_password))
.check(matches(isDisplayed()))
//assert Register button is displayed
onView(withText(R.string.label_register_user))
.check(matches(isDisplayed()))
return LoginRobot().apply { func() }
}
package com.bharatdodeja.robotpattern.utils
import android.os.Environment.DIRECTORY_PICTURES
import android.os.Environment.getExternalStoragePublicDirectory
import android.support.test.runner.screenshot.BasicScreenCaptureProcessor
import java.io.File
/**
* Created by bharat.dodeja@gmail.com
*/
class MyScreenCaptureProcessor(parentFolderPath: String) : BasicScreenCaptureProcessor() {
init {
this.mDefaultScreenshotPath = File(
File(
getExternalStoragePublicDirectory(DIRECTORY_PICTURES),
"carpool"
).absolutePath,
"screenshots/$parentFolderPath"
)
}
override fun getFilename(prefix: String): String = prefix
}
package com.bharatdodeja.robotpattern.utils
/**
* Created by bharat.dodeja@gmail.com
*/
open class BaseRobot {
fun screenshot(name: String) {
takeScreenshotWithParent(name = name)
}
}

Instrumentation Testing using Espresso

Espresso allow UI tests to have stable interactions with your app, but without discipline these tests can become hard to manage and require frequent updating. Robot pattern allows you to create stable, readable, and maintainable tests with the aid of Kotlin’s language features.

Test Excecution

Let's say there is a login screen and user needs to login access dashboard by entering email and password and clicking on the login button. This is what we want the test to validate. But there’s also the how. Really, what we are looking for is the what.

Traditional Testing (Problem)

A way that you might write the test is something like this:

LoginScreen login = (LoginScreen) obtainScreen();

login.email("john.deo@gmail.com");
login.password.("********");
login.loginButton.click();

Thread.sleep(1000);

assertThat(obtainScreen()).isInstanceOf(SuccessScreen.class);

The problem is that what you’ve essentially done is taken your test and just shoved it into the view and really tightly coupled the two things together.

If our view changes in any significant fashion, we’re going to have to change our test as well. You’re not throwing it out, but you’re refactoring it.

Your test thus ended up being about how the test was being accomplished, not about what was being accomplished.

As a result, whenever you have to change it you have to reinterpret parts of what the test was doing in order to make sure that the behavior is the same on the other side.

Robots: Separating the What From the How (Solution)

What is a robot? Ultimately, this is a pattern and it’s open for interpretation. A robot is just a class that encodes the high level actions that we want to take on a view, or anything else. The how is what we’re going to encode in this robot.

LoginRobot

class LoginRobot {
  LoginRobot userName("john.deo@gmail.com")
  LoginRobot password("***********")
}

class ResultRobot {
  ResultRobot isSuccess()
}

We can enter an email. We don’t know how the email gets entered. The only thing we know is that if we want to enter an email on this screen, it requires some value.

We can enter a password. We don’t know where the password’s going on the screen, we just know that the screen allows us to enter a password field.

Then we can take an action, which is press the Login button. Now, our Login action can actually return a different Result robot that knows how to interact with the screen that’s coming up.

We can verify whether or not that results screen is showing success. Ultimately, if the login failed, some hypothetical assertion happening in here would fail and break your test. There’s all kinds of crap that we can shove in here which is the how. It doesn’t matter what it is.

Now, we have this sweet little API that we can use:

LoginRobot robot = new LoginRobot();
ResultRovot result = robot.
  .userName("john.deo@gmail.com")
  .password("***********")
  .clickLogin();
  
result.isSuccess();

Here, we’ve encoded the what into our test within our builder, as opposed to the how.

The test is declarative, terse, and your robots are a little crazy. The robot is written once, the tests are written many times.

Robots using Kotlin DSL

DSLs are a way of providing an API that is cleaner, more readable and, most importantly, more structured than traditional APIs.

Instead of having to call individual functions in an imperative way, DSLs use nested descriptions, which creates clean structures; we could even call it “grammar”. DSLs define possibilities of combining different constructs with each other and also make copious use of scopes, in which different functionalities can be used.

Our test is okay. It’s nice and declarative and terse. But Kotlin can do us a lot better. We want to try and leverage the language features that Kotlin provides such as higher order functions, infix function, apply etc., while ultimately retaining the type safety aspect of it.

Let’s replace the builder with more advanced primitives of the language. We’re going to switch to a little factory function.

fun loginScreen(func: LoginRobot.() -> Unit): LoginRobot { func () }

class LoginRobot {
  fun userName(email: String) { ... }
  fun password(password: String) { ... }
  infix fun clickLogin(func: ResultRobot.() -> Unit): ResultRobot { 
    ... 
    return ResultRobot().apply { func() }
  }
}

class ResultRobot {
  fun isSuccess() { ... }
}

By calling the func within the apply block, the function returns a value which is the robot itself, as opposed to void. This will allow us to chain methods nicely.

Now that we have this, what does this change our calling code into?

loginScreen {
  userName("john.deo@gmail.com")
  password("********")
} clickLogin {
  isSuccess()
}

Robot Strategy

The general pattern here starts with an entry point, which is just the first screen you see, and then you have whatever actions you want to take on that view, or assertions. We tend to not do a lot of assertions, we want to push that into more unit tests.

We implicitly assert things by just walking through the app, and ensuring that it does what we expect, and that the things that we want to interact with are on the screen. isSuccess() is like an explicit assertion – you are asserting that something is being displayed on the screen.

Then our clickLogin() method is basically any time you’re transitioning between screens, which are also transitions between robots. Our screens are moving, and we need to move on to another robot and our fancy little infix function here gets that for us.

Descriptive Stack Traces

Another cool thing – we kind of get some nice stack traces out of this that are very descriptive:

Exception in thread "main" java.Lang.AssertionError:
    Expected <Success!> but found <Failure!>
    
    at ResultRobot.isSuccess(ResultRobot.kt:18)
    at LoginTest.singleFundingSourceSuccess.2.invoke(LoginTest.kt:27)
    at LoginRobot.submit(LoginRobot.kt:13)
    at LoginTest.singleFundingSourceSuccess(LoginTest.kt:8)

You don’t have to click on the test, see what line the failure was and kind of figure out what happened. This stack trace essentially becomes very descriptive. We know that we’re in the singleFundingSourceSuccess() test. We know the login robot called clickLogin(). So, it’s pressing the Login button on the login screen.

Then we know the results robot was asserting success. So you get a stack trace, which mimics the steps that were taken in order to get to in this case, the failure. You potentially don’t even have to go look at the test, unless you need the data that was entered. Your stack traces replicate the process through which you got to the failing screen, which is a nice side effect.

The Robot Pattern

We should think of this as a very broad pattern. There’s no library here. This is a pure and simple pattern.

If you actually take the time and think about the architecture of your tests, keeping the same separation of concerns that app architectures give you in your tests, you will end up with higher quality, more maintainable tests that will be correct in the long-term and actually save you from writing code.

Screenshot

Capturing screenshots of instrumentation tests are a great way to generate visual report. It can be part of confluance report of your test suits results.

There are other third party libraries such as Spoon, Falcon, which provides advanced features.

For Screenshot to save screenshots, your app must have the WRITE_EXTERNAL_STORAGE permission.

fun takeScreenshot(parentFolder: String = "Foo", name: String) {
    val screenCapture = Screenshot.capture()
    val processors = setOf(MyScreenCaptureProcessor(parentFolder))
    try {
        screenCapture.apply {
            setName(name)
            process(processors)
        }
    } catch (ex: IOException) {
        Log.e("Screenshots", "Could not take the screenshot", ex)
    }
}

Custom Rule for Test Failure Screenshot

Capturing screenshots when test fails, it is great way of debuging a test failure in a visual way.

To achieve this we need to exptend TestWathcher class and give implementation to failed(). In this method, you will get call before your test fails, so that you can grab a screenshot before activity is finished.

Here is how we can take screenshot of test failure.

class FailureScreenshotRule : TestWatcher() {
  override fun failed(e: Throwable?, description: Description) {
    val parentFolderPath = "failures/${description.className}"
      takeScreenshot(parentFolder = parentFolderPath,
          name = description.methodName)
  }
}

Here is how you will declare custom rule in your Test file.

@get:Rule
val screenShotRule = FailureScreenshotRule()
package com.bharatdodeja.robotpattern.utils
import android.support.test.runner.screenshot.Screenshot
import android.util.Log
import java.io.IOException
/**
* Created by bharat.dodeja@gmail.com
*/
fun takeScreenshotWithParent(parentFolder: String = "RobotPattern", name: String) {
val fileName = "".plus(System.currentTimeMillis())
.plus(name.replace(" ", "_"))
val screenCapture = Screenshot.capture()
val processors = setOf(MyScreenCaptureProcessor(parentFolder))
try {
screenCapture.apply {
setName(fileName)
process(processors)
}
} catch (ex: IOException) {
Log.e("Screenshots", "Could not take the screenshot", ex)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment