Painless UI Testing with Kaspresso
SATURDAY MARCH 14 2020 - 14 MIN
A few months ago I wrote a blog post under a similar title about better Unit Testing for Kotlin with Kotest (formerly KotlinTest). The focus of that post was to demonstrate a more organized, boilerplate free and self-documenting TDD/BDD friendly testing framework for Kotlin.
I have been using Kotest for about a year now and it is safe to say that it has encouraged me to test more of my code. While frameworks like Kotest and Spek provide a good alternative for unit testing, the UI part still feels lacking. Most of us still use Espresso for writing UI tests that, even with its super clean API, feel very repetitive and verbose. For some time, I worked around this by extracting the repetitive code for reuse. It wasn't very clean.
Caffeine Overflow
Part of my dev-wishlist was to someday establish this perfect loop of TDD flow with Android where all the features start with a failing UI test, followed by multiple unit tests to satisfy the business logic. At the end of which we have a solid well-tested and self-documented feature. So I started searching for other options which lead me to several frameworks, most of which were named after a coffee.
One of the great finds was Kakao by Agoda, it provides a more readable and reusable DSL on top of Espresso. The idea is to encapsulate the UI in Screen
interfaces which can then be reused. Here is an example of what a form UI would look like:
class FormScreen : Screen<FormScreen>() { | |
val phone = KView { withId(R.id.phone) } | |
val email = KEditText { withId(R.id.email) } | |
val submit = KButton { withId(R.id.submit) } | |
} |
The resulting tests are reduced to a combination of actions and assertions inside the screen:
onScreen<FormScreen> { | |
phone { | |
hasText("971201771") | |
} | |
button { | |
click() | |
} | |
} |
But Wait, There's More!
Kakao seemed like a pretty reasonable solution until I stumbled upon Kaspresso. It combines Espresso and UI Automator into single DSL based API. For the Espresso part, it just wraps around Kakao with some added features such as improved logging, built-in screenshots, ADB command execution, runtime permissions management and more.
Grade Calculator 2.0
What could be a better example than to revisit the grade calculator we built in the previous post? Let's add some UI and this time build in a purely Test-Driven style.
Installation
Let's create a simple Android project with an empty Activity. Then add the following test dependencies:
testImplementation 'junit:junit:4.13' | |
implementation 'androidx.test:core:1.2.0' | |
androidTestImplementation 'androidx.test.ext:junit:1.1.1' | |
testImplementation 'io.kotlintest:kotlintest-runner-junit5:3.1.7' | |
testImplementation 'io.mockk:mockk:1.9.3' | |
androidTestImplementation 'com.kaspersky.android-components:kaspresso:1.1.0' |
If you set up your project using the Android Studio wizard, then most of these dependencies should already be present. The only dependencies that we must add manually are Kotest, Mockk, and Kaspresso.
Kotest and Mockk are the same as last time for unit testing and mocking, while Kaspresso will be used for UI tests.
Adding The UI Test
Let's add a UI test for the happy path of our calculator without touching the activity first:
import androidx.test.rule.ActivityTestRule | |
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase | |
import com.zuhaibahmad.kaspressotestingdemo.screens.GradeCalculatorScreen | |
import org.junit.Rule | |
import org.junit.Test | |
class GradeCalculatorUiTest : TestCase() { | |
@Rule | |
@JvmField | |
var rule = ActivityTestRule(GradeCalculatorActivity::class.java, false, false) | |
val screen = GradeCalculatorScreen() | |
@Test | |
fun onCorrectScoreSubmit_shouldDisplayCorrectGrade() = before { | |
// No-op | |
}.after { | |
// No-op | |
}.run { | |
screen { | |
step("Open grade calculator screen") { | |
rule.launchActivity(null) | |
} | |
step("Submit obtained marks") { | |
inputMarks.replaceText("90") | |
buttonSubmit.click() | |
} | |
step("Verify the displayed grade is correct") { | |
labelGrade.hasText("Your Grade is: A") | |
} | |
} | |
} | |
} |
If we try to run this test, it will fail because we have not added the GradeCalculatorScreen
yet. This test serves as a blueprint of what we want to achieve for the happy path of grade calculation. Let's break down each step:
- TestCase - The first thing you might have noticed is that the test class extends
TestCase
, which is a base class provided by Kaspresso that gives access to most of its features. - Activity Test Rule - Next, we create an activity test rule for the grade activity. This part is the same as normal Instrumentation tests.
- GradeCalculatorScreen Instance - This instance of the screen will be used in the Kaspresso DSL.
- Test Body - For the test body, you might notice the chain of 3 separate blocks. You can use the block syntax instead of the expression body, but then it will add an extra indent to all the code inside the block which I do not like very much.
- Before-After-Run - The
before
andafter
blocks are interceptors for anything you might want to do before or after the test respectively. For example,before
can be used for mocking andafter
can be used to release resources. - Steps - Finally, the run block is where actual testing happens
- Every step is described with a
step
block. This part is optional, you can skip the steps if you want and directly write the encapsulating code. However, it is very helpful since not only it provides a comment on what is happening but this information also gets printed in the logs. - In the first step, we launch the activity, this can be done directly with the test rule though.
- In the next two steps, we perform some actions on the
GradeCalculatorScreen
and then verify their results. Note that all the methods come from the library, we only provide view bindings in theScreen
implementations.
- Every step is described with a
Implementing The Kaspresso Screen
At this point, we cannot even compile the application due to missing classes. Let's provide an implementation for our GradeCalculatorScreen
.
import com.agoda.kakao.edit.KEditText | |
import com.agoda.kakao.screen.Screen | |
import com.agoda.kakao.text.KButton | |
import com.agoda.kakao.text.KTextView | |
import com.zuhaibahmad.kaspressotestingdemo.R | |
class GradeCalculatorScreen: Screen<GradeCalculatorScreen>() { | |
val inputMarks = KEditText { withId(R.id.etMarks) } | |
val buttonSubmit = KButton { withId(R.id.btSubmit) } | |
val labelGrade = KTextView { withId(R.id.tvGrade) } | |
} |
As explained earlier, the screen class just binds the UI elements and organizes them into one place. Since we do not have these elements in the layout XML, the project won't compile unless we add them too. So here's a simple layout for it:
<?xml version="1.0" encoding="utf-8"?> | |
<androidx.constraintlayout.widget.ConstraintLayout | |
xmlns:android="http://schemas.android.com/apk/res/android" | |
xmlns:app="http://schemas.android.com/apk/res-auto" | |
xmlns:tools="http://schemas.android.com/tools" | |
android:layout_width="match_parent" | |
android:layout_height="match_parent" | |
android:padding="16dp" | |
tools:context=".GradeCalculatorActivity"> | |
<androidx.appcompat.widget.AppCompatEditText | |
android:id="@+id/etMarks" | |
android:layout_width="0dp" | |
android:layout_height="wrap_content" | |
android:gravity="center" | |
android:hint="@string/hint_enter_your_marks" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintLeft_toLeftOf="parent" | |
app:layout_constraintRight_toRightOf="parent" | |
app:layout_constraintTop_toTopOf="parent" /> | |
<androidx.appcompat.widget.AppCompatTextView | |
android:id="@+id/tvGrade" | |
android:layout_width="0dp" | |
android:layout_height="wrap_content" | |
android:gravity="center" | |
android:textAppearance="@style/TextAppearance.AppCompat.Large" | |
android:textColor="@color/colorAccent" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toBottomOf="@id/etMarks" | |
tools:text="Your Grade Is: A" /> | |
<androidx.appcompat.widget.AppCompatButton | |
android:id="@+id/btSubmit" | |
android:layout_width="0dp" | |
android:layout_height="wrap_content" | |
android:text="@string/submit" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" /> | |
</androidx.constraintlayout.widget.ConstraintLayout> |
Now, the test will compile but the test will fail because we have not provided any business logic yet.
Adding The Business Logic
The next step is to provide the business logic to calculate the grade.
Since there is nothing new about this part, I will just copy the tests and resulting logic from the previous post. You can read the Kotest post to understand better.
Create a GradeCalculatorSpec
class inside the test package and add the specs for grade calculation:
class GradeCalculatorSpec : BehaviorSpec({ | |
Given("a grade calculator") { | |
val calculator = spyk(GradeCalculator()) | |
every { calculator.totalMarks } returns 100 | |
val total = calculator.totalMarks | |
When("obtained marks are 90 or above") { | |
val grade = calculator.getGrade(93, total) | |
Then("grade is A") { | |
grade.shouldBe("A") | |
} | |
} | |
When("obtained marks are between 80 and 89") { | |
val grade = calculator.getGrade(88, total) | |
Then("grade is B") { | |
grade.shouldBe("B") | |
} | |
} | |
When("obtained marks are between 70 and 79") { | |
val grade = calculator.getGrade(78, total) | |
Then("grade is C") { | |
grade.shouldBe("C") | |
} | |
} | |
When("obtained marks are between 60 and 69") { | |
val grade = calculator.getGrade(68, total) | |
Then("grade is D") { | |
grade.shouldBe("D") | |
} | |
} | |
When("obtained marks are below 60") { | |
val grade = calculator.getGrade(59, total) | |
Then("grade is F") { | |
grade.shouldBe("F") | |
} | |
} | |
} | |
}) |
Once all the tests are passing, the resulting implementation would look something like this:
class GradeCalculator { | |
var totalMarks = 0 | |
fun getGrade(obtainedMarks: Int, totalMarks: Int): String { | |
val percentage = getPercentage(obtainedMarks, totalMarks) | |
return when { | |
percentage >= 90 -> "A" | |
percentage in 80..89 -> "B" | |
percentage in 70..79 -> "C" | |
percentage in 60..69 -> "D" | |
else -> "F" | |
} | |
} | |
private fun getPercentage(obtainedMarks: Int, totalMarks: Int): Int { | |
return (obtainedMarks / totalMarks.toFloat() * 100).roundToInt() | |
} | |
} |
Binding It All Together
The final step is to bind the GradeCalculator
with the UI. Let's add a click listener to the button and use the user input to calculate the grade.
class GradeCalculatorActivity : AppCompatActivity() { | |
companion object { | |
private const val TOTAL_MARKS = 100 | |
} | |
private val gradeCalculator = GradeCalculator() | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
setContentView(R.layout.activity_grade_calculator) | |
btSubmit.setOnClickListener { | |
val inputMarks = etMarks.text.toString().toInt() | |
val grade = gradeCalculator.getGrade(inputMarks, TOTAL_MARKS) | |
tvGrade.text = "Your Grade is: $grade" | |
} | |
} | |
} |
If all goes well, all tests should be passing now. Also, if you look at the logcat output, it will have a complete summary of all test steps.
Bonus: If there was no issue with permission, it should have also captured the screenshots for each step automagically! Look inside the gallery for them.
Here's the pidcat output of my tests:
KASPRESSO I --------------------------------------------------------------------------- | |
I BEFORE TEST SECTION | |
I --------------------------------------------------------------------------- | |
I --------------------------------------------------------------------------- | |
I TEST SECTION | |
I --------------------------------------------------------------------------- | |
I ___________________________________________________________________________ | |
I TEST STEP: "1. Open grade calculator screen" in GradeCalculatorUiTest | |
ActivityTestRule W getActivityIntent() returned null using default: Intent(Intent.ACTION_MAIN) | |
ActivityThread W handleWindowVisibility: no activity for token android.os.BinderProxy@efc119d | |
LifecycleMonitor D Lifecycle status change: com.zuhaibahmad.kaspressotestingdemo.GradeCalculatorActivity@76bf15e in: PRE_ON_CREATE | |
D running callback: com.kaspersky.kaspresso.device.languages.LanguageImpl$lifecycleCallback$1@934993f | |
D callback completes: com.kaspersky.kaspresso.device.languages.LanguageImpl$lifecycleCallback$1@934993f | |
essotestingdem W Accessing hidden method Landroid/graphics/drawable/Drawable;->getOpticalInsets()Landroid/graphics/Insets; (light greylist, linking) | |
W Accessing hidden field Landroid/graphics/Insets;->left:I (light greylist, linking) | |
W Accessing hidden field Landroid/graphics/Insets;->right:I (light greylist, linking) | |
W Accessing hidden field Landroid/graphics/Insets;->top:I (light greylist, linking) | |
W Accessing hidden field Landroid/graphics/Insets;->bottom:I (light greylist, linking) | |
W Accessing hidden method Landroid/view/View;->getAccessibilityDelegate()Landroid/view/View$AccessibilityDelegate; (light greylist, linking) | |
W Accessing hidden method Landroid/view/View;->computeFitSystemWindows(Landroid/graphics/Rect;Landroid/graphics/Rect;)Z (light greylist, reflect | |
ion) | |
W Accessing hidden method Landroid/view/ViewGroup;->makeOptionalFitsSystemWindows()V (light greylist, reflection) | |
W Accessing hidden method Landroid/widget/TextView;->getTextDirectionHeuristic()Landroid/text/TextDirectionHeuristic; (light greylist, linking) | |
LifecycleMonitor D Lifecycle status change: com.zuhaibahmad.kaspressotestingdemo.GradeCalculatorActivity@76bf15e in: CREATED | |
D running callback: com.kaspersky.kaspresso.device.languages.LanguageImpl$lifecycleCallback$1@934993f | |
D callback completes: com.kaspersky.kaspresso.device.languages.LanguageImpl$lifecycleCallback$1@934993f | |
D Lifecycle status change: com.zuhaibahmad.kaspressotestingdemo.GradeCalculatorActivity@76bf15e in: STARTED | |
D running callback: com.kaspersky.kaspresso.device.languages.LanguageImpl$lifecycleCallback$1@934993f | |
D callback completes: com.kaspersky.kaspresso.device.languages.LanguageImpl$lifecycleCallback$1@934993f | |
D Lifecycle status change: com.zuhaibahmad.kaspressotestingdemo.GradeCalculatorActivity@76bf15e in: RESUMED | |
D running callback: com.kaspersky.kaspresso.device.languages.LanguageImpl$lifecycleCallback$1@934993f | |
D callback completes: com.kaspersky.kaspresso.device.languages.LanguageImpl$lifecycleCallback$1@934993f | |
OpenGLRenderer D HWUI GL Pipeline | |
HostConnection D HostConnection::get() New Host Connection established 0xe8b21370, tid 6867 | |
D HostComposition ext ANDROID_EMU_CHECKSUM_HELPER_v1 ANDROID_EMU_dma_v1 ANDROID_EMU_YUV420_888_to_NV21 ANDROID_EMU_YUV_Cache ANDROID_EMU_async_u | |
nmap_buffer GL_OES_vertex_array_object GL_KHR_texture_compression_astc_ldr ANDROID_EMU_gles_max_version_2 | |
ConfigStore I android::hardware::configstore::V1_0::ISurfaceFlingerConfigs::hasWideColorDisplay retrieved: 0 | |
I android::hardware::configstore::V1_0::ISurfaceFlingerConfigs::hasHDRDisplay retrieved: 0 | |
OpenGLRenderer I Initialized EGL, version 1.4 | |
D Swap behavior 1 | |
W Failed to choose config with EGL_SWAP_BEHAVIOR_PRESERVED, retrying without... | |
D Swap behavior 0 | |
eglCodecCommon D setVertexArrayObject: set vao to 0 (0) 0 0 | |
EGL_emulation D eglCreateContext: 0xe8b05420: maj 2 min 0 rcv 2 | |
D eglMakeCurrent: 0xe8b05420: ver 2 0 (tinfo 0xe8b03790) | |
HostConnection D createUnique: call | |
D HostConnection::get() New Host Connection established 0xe8b215f0, tid 6867 | |
D HostComposition ext ANDROID_EMU_CHECKSUM_HELPER_v1 ANDROID_EMU_dma_v1 ANDROID_EMU_YUV420_888_to_NV21 ANDROID_EMU_YUV_Cache ANDROID_EMU_async_u | |
nmap_buffer GL_OES_vertex_array_object GL_KHR_texture_compression_astc_ldr ANDROID_EMU_gles_max_version_2 | |
eglCodecCommon E GoldfishAddressSpaceHostMemoryAllocator: ioctl_ping failed for device_type=5, ret=-1 | |
EGL_emulation D eglMakeCurrent: 0xe8b05420: ver 2 0 (tinfo 0xe8b03790) | |
AssistStructure I Flattened final assist data: 2688 bytes, containing 1 windows, 9 views | |
KASPRESSO I TEST STEP: "1. Open grade calculator screen" in GradeCalculatorUiTest SUCCEED. It took 0 minutes, 0 seconds and 339 millis. | |
I ___________________________________________________________________________ | |
KASPRESSO I ___________________________________________________________________________ | |
I TEST STEP: "2. Submit obtained marks" in GradeCalculatorUiTest | |
essotestingdem W Accessing hidden method Landroid/os/MessageQueue;->next()Landroid/os/Message; (light greylist, reflection) | |
W Accessing hidden field Landroid/os/MessageQueue;->mMessages:Landroid/os/Message; (light greylist, reflection) | |
W Accessing hidden method Landroid/os/Message;->recycleUnchecked()V (light greylist, reflection) | |
W Accessing hidden method Landroid/view/WindowManagerGlobal;->getInstance()Landroid/view/WindowManagerGlobal; (light greylist, reflection) | |
W Accessing hidden field Landroid/view/WindowManagerGlobal;->mViews:Ljava/util/ArrayList; (light greylist, reflection) | |
W Accessing hidden field Landroid/view/WindowManagerGlobal;->mParams:Ljava/util/ArrayList; (light greylist, reflection) | |
ViewInteraction I Performing 'replace text(90)' action on view (with id: com.zuhaibahmad.kaspressotestingdemo:id/etMarks) | |
KASPRESSO I replace text(90) on AppCompatEditText(id=etMarks;hint=Enter Your Marks;) | |
essotestingdem W Accessing hidden method Landroid/view/ViewConfiguration;->getDoubleTapMinTime()I (light greylist, reflection) | |
ViewInteraction I Performing 'single click' action on view (with id: com.zuhaibahmad.kaspressotestingdemo:id/btSubmit) | |
KASPRESSO I single click on AppCompatButton(id=btSubmit;text=Submit;) | |
I TEST STEP: "2. Submit obtained marks" in GradeCalculatorUiTest SUCCEED. It took 0 minutes, 0 seconds and 315 millis. | |
I ___________________________________________________________________________ | |
KASPRESSO I ___________________________________________________________________________ | |
I TEST STEP: "3. Verify the displayed grade is correct" in GradeCalculatorUiTest | |
ViewInteraction I Checking 'com.kaspersky.kaspresso.proxy.ViewAssertionProxy@d4df345' assertion on view (with id: com.zuhaibahmad.kaspressotestingdemo:id/tvGrad | |
e) | |
KASPRESSO I Check with text: is "Your Grade is: A" on AppCompatTextView(id=tvGrade;text=Your Grade is: A;) | |
I TEST STEP: "3. Verify the displayed grade is correct" in GradeCalculatorUiTest SUCCEED. It took 0 minutes, 0 seconds and 5 millis. | |
I ___________________________________________________________________________ | |
KASPRESSO I --------------------------------------------------------------------------- | |
I AFTER TEST SECTION | |
I --------------------------------------------------------------------------- | |
I --------------------------------------------------------------------------- | |
I TEST PASSED | |
I --------------------------------------------------------------------------- |
Using Kaspresso, you can also handle runtime permissions, change device state, execute ADB commands right from your code. The flexibility it provides for writing tests in truly amazing.
Conclusion
This is a very basic example that does not handle any edge cases. However, I hope it was enough to give you a taste of test-driven development with Android.
We started with a crude idea without any implementation details in mind and used tests to shape our logic. There are a couple of benefits to this approach:
- We ended up with a minimum workable solution with 100% code coverage
- As every part of our logic is backed by Unit and UI tests, the tests themselves serve as a documentation for the features.
- Since both the architecture and the logic is shaped by the tests themselves, it is quite easy to freely refactor the code further without worrying about regression bugs.
You can find complete source code for this post here
For suggestions and queries, just contact me.