Press to Test - Test Driven Development in Android Part 6
30 May 2019This is Part 6 in a series documenting my experiences learning Android development in Kotlin. The code is available to follow along on GitHub.
In Part 1 I got a simple UI toy up and running, with tests running via Espresso in an emulator.
In Part 2 I used Roboletric to get (a lightly refactored version of) the same tests running in a local JVM rather than the emulator.
Part 3 was spent working out how to wait for a condition (the snack bar disappearing) on both the emulator and Robolectric tests.
In Part 4 and Part 5 I added unit tests that can run without the ten-second Robolectric tax by introducing something that I think is a ViewModel, but doesn’t look anything like Google’s examples.
In those last two episodes I spent my time avoiding the Android Architecture Components, which include a ViewModel and LiveData and other things that are designed to help in this sort of situation. This was because every time I read the documentation I wanted to give up programming and live on a remote island with only 1950’s technology - maybe the Isle of Wight.
The time has now come to bite the bullet, because at some point I have to find some work, and I don’t suppose most potential gigs will be hand-rolling their own ViewModels. So today I shall mostly be wearing Architecture Components.
The Warmup
I start by working my way through the Data Binding Codelab. It all kind of makes sense, although for my taste too many important details are buried in XML files, and in code embedded in XML attributes in particular. One thing that I do become aware of is that lifecyle is important - as activities can be restarted we want continuity, but not at the expense of memory leaks.
OK, I can’t put it off any longer.
Migration
Migration of PressToTest starts in Gradle
android {
...
dataBinding {
enabled true
}
}
and then I need to add a data
element to activity_main.xml
. Unfortunately I seem to need to add it into a root element that doesn’t exist in my (Android Studio generated) layout file. Ho hum. I add the missing root element and the data element and this version still builds and runs.
Now, to cut a long story short, there is an unedifying hour of faff when I discover that my project (created only a week ago using Android Studio’s new project command) is using APIs in com.android.support
and the data binding examples are all using APIs from androidx
. I try to fix Gradle library references by hand and end up in all sorts of trouble trying to find compatible versions of things. I go to bed with a messed up build.
With early morning clarity I think to Google for “androidx migration”. The results point me to an Android Studio command - Migrate to AndroidX
- and then warn about all the ways that it will fail to go a good job. I’ve nothing to loose, so I rollback, invoke the command, and everything builds and runs just as before!
[Edit - looks like I spoke too soon. It did run, but later on the tests failed to build and had to be fixed.]
Making the button label in ViewModel
a LiveData
seems like the next logical step.
class ViewModel(
private val defaultText: String,
private val pressedText: String,
private val onButtonTextChanged: (String) -> Unit,
private var goBoom: () -> Unit
) {
// private var buttonText: String by Delegates.observable(defaultText) { _, _, newValue ->
// onButtonTextChanged(newValue)
// }
var buttonText = MutableLiveData<String>(defaultText)
Now I find that MutableLiveData
doesn’t have a constructor taking the default value; except it does in the code lab that I’ve just completed. I sigh, look for the differences between the codelab build and my own and add implementation "androidx.lifecycle:lifecycle-extensions:2.1.0-alpha04"
which seems to do the trick.
Apart from that, the binding of the button text to a LiveData
is remarkably painless. Add a data section to the layout and reference our ViewModel
<data>
<variable
name="viewmodel"
type="com.oneeyedmen.presstotest.ViewModel"/>
</data>
Tell the layout that the button’s text should use viewmodel.buttonText
<Button
android:text="@{viewmodel.buttonText}"
Have the ViewModel
update the MutableLiveData
class ViewModel(
// ...
) {
var buttonText = MutableLiveData<String>(defaultText)
@VisibleForTesting
internal fun onTouchAction(actionCode:Int) {
when (actionCode) {
MotionEvent.ACTION_DOWN -> buttonText.value = pressedText
MotionEvent.ACTION_UP -> buttonText.value = defaultText
}
}
}
and use DataBindingUtil.setContentView
rather then AppCompatActivity.setContentView
to wire up the databinding in MainActivity
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main).let {
it.lifecycleOwner = this
it.viewmodel = ViewModel(
button = button,
defaultText = getString(R.string.default_button_label),
pressedText = getString(R.string.pressed_button_label),
goBoom = this::boom
)
}
}
private fun boom() {
Snackbar.make(button, getString(R.string.explosion), Snackbar.LENGTH_SHORT)
.setAction("Action", null).show()
}
}
Awesomely this runs, and works, and passes the InstrumentedAcceptanceTests
. Less awesomely, Android Studio lets me do all those things without warning me that my unit tests no longer compile. No matter, the test can be simpler now because it can read directly from the buttonText
LiveModel
safe in the knowledge that data binding has its back.
class PressToTestTests {
// ...
val viewModel = ViewModel(
defaultText = "Press to Test",
pressedText = "Release to Detonate",
goBoom = { boomCount++ }
)
private val buttonText get() = viewModel.buttonText.value
@Test
fun `button message changes on pressing`() {
assertEquals("Press to Test", buttonText)
viewModel.onTouchAction(MotionEvent.ACTION_DOWN)
assertEquals("Release to Detonate", buttonText)
viewModel.onTouchAction(MotionEvent.ACTION_UP)
assertEquals("Press to Test", buttonText)
}
}
Run that and - oh no not again java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked. See http://g.co/androidstudio/not-mocked for details.
The top hit on Google provides the answer - add an InstantTaskExecutorRule
, as had been previously predicted on Reddit.
Now I run the last remaining test - the Robolectric InternalAcceptanceTest
, which fails - apparently Espresso says that the button’s label hasn’t changed after the touch event. I try adding the InstantExecutorRule
- no dice. I try waiting for the label change - no dice. I break out the debugger and find that when the ‘LiveData’ changes it sees its observer as inactive, so does nothing.
I return to Google, ending up searching for ‘“livedata” “robolectric” “inactive”’, and the top hit of LiveData seems to not notify observers looks promising. It’s an unfixed bug in Robolectric, with a workaround of activityController.get().getLifecycle().handleLifecycleEvent(Lifecycle.Event.ON_START)
.
Unfortunately there’s no clue as to where to put this code, but I figure that before every test would be a good bet, so I add an @Before
to the InternalAcceptanceTests
class InternalAcceptanceTests : AcceptanceTests(robolectricWaiter) {
@Before fun hack() {
activityController.get().getLifecycle().handleLifecycleEvent(Lifecycle.Event.ON_START)
}
}
which of course doesn’t compile, and I have no idea what the activityController
is. I find that handleLifecycleEvent is a method on LifecyleRegistry
, and LifeCycleRegistry
is a subtype of LifeCycle
, so in the end just guess and try
class InternalAcceptanceTests : AcceptanceTests(robolectricWaiter) {
@Before fun hack() {
(activityRule.activity.lifecycle as LifecycleRegistry).handleLifecycleEvent(Lifecycle.Event.ON_START)
}
}
which works, at least for this test! Quick, check it in.
Events Dear Boy
Now we have used Data Binding to observe ViewModel.buttonText
and update the, erm, button text, but we still have manual wiring of ViewModel
to listen to the Button
’s events.
Now it looks like I can associate the button’s OnTouchListener
with the ViewModel
if I revert to exporting a listener property, viz
class ViewModel(
//...
) {
val onTouchListener = OnTouchListener { _, event ->
onTouchAction(event.action)
false
}
}
and do the binding in the XML
<Button
android:id="@+id/button"
android:text="@{viewmodel.buttonText}"
app:onTouchListener="@{viewmodel.onTouchListener}"
...
/>
That passes the tests, even when I remove the code to add the OnTouchListener
to the Button
in the ViewModel
constructor. So I do the same with the onClickListener
so that in the end the ViewModel
secondary constructor disappears and we are left with
class PressToTestTests {
@get:Rule
val rule = InstantTaskExecutorRule()
private var boomCount = 0
val viewModel = ViewModel(
defaultText = "Press to Test",
pressedText = "Release to Detonate",
goBoom = { boomCount++ }
)
private val buttonText get() = viewModel.buttonText.value
@Test
fun `button message changes on pressing`() {
assertEquals("Press to Test", buttonText)
viewModel.onTouchAction(MotionEvent.ACTION_DOWN)
assertEquals("Release to Detonate", buttonText)
viewModel.onTouchAction(MotionEvent.ACTION_UP)
assertEquals("Press to Test", buttonText)
}
@Test
fun `clicking button sets off the explosion`() {
assertEquals(0, boomCount)
viewModel.onClick()
assertEquals(1, boomCount)
viewModel.onClick()
assertEquals(2, boomCount)
}
}
class ViewModel(
private val defaultText: String,
private val pressedText: String,
private var goBoom: () -> Unit
) {
var buttonText = MutableLiveData<String>(defaultText)
val onTouchListener = OnTouchListener { _, event ->
onTouchAction(event.action)
false
}
val onClickListener = OnClickListener { onClick() }
@VisibleForTesting
internal fun onTouchAction(actionCode: Int) {
when (actionCode) {
MotionEvent.ACTION_DOWN -> buttonText.value = pressedText
MotionEvent.ACTION_UP -> buttonText.value = defaultText
}
}
@VisibleForTesting
internal fun onClick() {
goBoom()
}
}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main).let {
it.lifecycleOwner = this
it.viewmodel = ViewModel(
defaultText = getString(R.string.default_button_label),
pressedText = getString(R.string.pressed_button_label),
goBoom = this::boom
)
}
}
private fun boom() {
Snackbar.make(button, getString(R.string.explosion), Snackbar.LENGTH_SHORT)
.setAction("Action", null).show()
}
}
<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<data>
<variable
name="viewmodel"
type="com.oneeyedmen.presstotest.ViewModel"/>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.oneeyedmen.presstotest.MainActivity">
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{viewmodel.buttonText}"
android:onClickListener="@{viewmodel.onClickListener}"
app:onTouchListener="@{viewmodel.onTouchListener}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Punt
That just leaves the goBoom
action not translated to the new newness. Googling for “livedata snackbar” gives a lot of hits about SingleLiveEvent
, but this isn’t part of the standard API and there seem to be competing implementations. I’m reasonably convinced that I don’t have to care about lifecycles with the current goBoom: () -> Unit
implementation, and it has been a tiring day. so I punt and leave the snackbar invocation as it is. Philosophically I think that there is a difference between the databinding used for the label and the boom - the former is just data synchronisation whereas the latter is an effect (both in the audiovisual and functional programming sense) - so I’m happy to leave them looking different.
Wrap Up
Mid way through this post I would have bet that I would be rejecting the data binding approach now and rolling back the code. Actually, when I compare the two solutions I think that the data-binding wins out. The details hidden in the XML aren’t too bad and ViewModel
is simplified by the removal of that ugly constructor. It’s a shame that it requires code-generation behind the scenes to work though, and that I had to bodge the Robolectric Espresso test to get it working.
I think that I have one more installment in me. I plan to return to my happy place and reimplement this example to give some perspective on the Android solution.