Testing Key-Press Events in Robolectric
Let’s say you need write a unit test for key-press events in Android. You want to test how a widget behaves when it receives certain key-press events, like in an EditText. To illustrate, let’s say you have an EditText that takes in an order number, but you want the order number to have a particular format and you want to format it as the user types. You want to verify that the formatted value is correct as the user types, and especially in the case where the user presses the backspace key in the middle of it. And you want to test all of this from a unit test.
Sending Key-Press Events in Robolectric
It turns out you can do this with Robolectric. To invoke a key-press event on an EditText (or any View) in Robolectric, you can use dispatchKeyEvent
like so:
viewBinding.myEditText.dispatchKeyEvent(
KeyEvent(
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_A
)
)
If you’re using Espresso in your Robolectric test, you can use ViewActions.pressKey
as follows:
Espresso.onView(
ViewMatchers.withId(
viewBinding.myEditText.id
)
).perform(
ViewActions.pressKey(
KeyEvent.KEYCODE_A
)
)
Alternatively:
Espresso.onView(
ViewMatchers.withId(
viewBinding.myEditText.id
)
).perform(
ViewActions.pressKey(
EspressoKey
.Builder()
.withKeyCode(KeyEvent.KEYCODE_A)
.build()
)
)
After calling dispatchKeyEvent()
or pressKey()
, you can perform your assertions.
Example
So now we go back to our order number EditText. Order numbers (in our case) consist of alphanumeric characters, with a hyphen after the first 4 characters. For example: AA11-DDEE33
or 1234–5
. When a user enters characters into this order number field, we want to automatically add the hyphen in position 4 if there are 5 or more alphanumeric characters, and remove than hyphen if there are 4 or fewer. For example:
The code to do this is written as:
binding.search.addTextChangedListener(object : TextWatcher {
[ ... ] override fun afterTextChanged(editable: Editable?) {
editable?.let {
// Make a temp copy
val sb = StringBuilder(editable.toString())
// Clear out any hyphens that may be in the
// wrong position
sb.replace(0, sb.length, sb.replace("-".toRegex(), ""))
// Insert a hyphen at position 4 if the string is
// longer than 4 characters
if (sb.length > 4) {
sb.insert(4, '-')
}
// If our string changed, then modify the Editable
if (sb.toString() != editable.toString()) {
editable.replace(0, editable.length, sb)
}
}
})
(For simplicity, we’re ignoring any logic to ensure only alphanumeric characters are entered.)
We want to test the case where we have 5 characters in our order number, the cursor is after the 3rd character, and we press the Backspace key on our keyboard.
Our test case starts with1234-5
, with the cursor after the 3
, and we press backspace such that our text will now be 1245
(note the hyphen is now gone), and our cursor should now be after the 2
.
Our Robolectric code looks like this:
fragment.binding.search.setText("12345")
assertEquals(fragment.binding.search.text.toString(), "1234-5")
fragment.binding.search.setSelection(3)
Espresso.onView(ViewMatchers.withId(fragment.binding.search.id))
.perform(
ViewActions.pressKey(
EspressoKey
.Builder()
.withKeyCode(KeyEvent.KEYCODE_DEL)
.build()
)
)
assertEquals(fragment.binding.search.text.toString(), "1245")
assertEquals(2, fragment.binding.search.selectionStart)
assertEquals(2, fragment.binding.search.selectionEnd)
The call toViewActions.pressKey(...)
is where we press the backspace key in the EditText (KEYCODE_DEL
).
When we run this, the test fails:
expected:<2> but was:<1>
Expected :2
Actual :1
Sure enough, if we run the code-under-test on an emulator, the same thing happens: the cursor jumps to position 1 instead of 2. Our Robolectric test found a valid bug.
It appears that with our code-under-test, when we reset the string we need to be mindful of where the cursor (selection) ends up.
Let’s try to fix this in onAfterTextChanged
:
editable?.let {
// Make a temp copy
val sb = StringBuilder(editable.toString())
// Clear out any hyphens that may be in the wrong position
sb.replace(0, sb.length, sb.replace("-".toRegex(), ""))
// Insert a hyphen at position 4 if the string is longer than 4 characters
if (sb.length > 4) {
sb.insert(4, '-')
}
// If our string changed, then modify the Editable
if (sb.toString() != editable.toString()) {
// Record our current cursor/selection position.
var selStart = binding.search.selectionStart
var selEnd = binding.search.selectionEnd
if (sb.length > editable.length) {
// We inserted a hyphen.
// If the cursor/selection was after it, move it up one.
if (selStart > 4) {
selStart++
selEnd++
}
} else if (sb.length < editable.length) {
// Make sure our selection isn't out-of-bounds
selStart = min(selStart, sb.length)
selEnd = min(selEnd, sb.length)
}
editable.replace(0, editable.length, sb)
// Set the selection to the correct position
binding.search.setSelection(selStart, selEnd)
}
}
Now when we re-run the Robolectric test, it passes.
Note: The above code likely has other bugs in it. But I just wanted to illustrate how Robolectric can be used to test the effect of key presses on widgets.
Can’t You Just Use setText?
If you simply try to replace the text, with the middle character deleted, by using setText(), you’ll find that the selection resets to [0,0]. So if your Robolectric test looked like this:
fragment.binding.search.setText("12345")
assertEquals(fragment.binding.search.text.toString(), "1234-5")
fragment.binding.search.setSelection(3)
fragment.binding.search.setText("1245")
assertEquals(fragment.binding.search.text.toString(), "1245")
assertEquals(2, fragment.binding.search.selectionStart)
assertEquals(2, fragment.binding.search.selectionEnd)
When run you’d get the following test failure:
expected:<2> but was:<0>
Expected :2
Actual :0
setText() won’t properly test the effect of cursor position when deleting a character with the backspace key. That is why we have the technique above for testing key press events in Robolectric.
To find out more about how insertion points (cursors) and selections are done in EditText (and actually, just in general TextViews), take a look at the Selection class. Selections and insertion points are actually done with Spannables.