Mastering UI Testing for Android web forms
Yeah! We are Android engineers! 🎉 We can build almost anything by ourselves and make it run on any Android device! But sometimes we may need to use a third-party library or incorporate web-based functionality into a WebView
to go fast. This can introduce challenges during UI testing. In this blog post, we will demonstrate the problem, the root of the issue, and how to overcome it.
The problem
Consider the following scenario: Our app, following a series of user actions, must open a WebView
to display a form. This situation is quite common, especially in payment flows. The form consists of two text inputs (one for the username and the other for the password) and a button. Notably, the button starts in a disabled state and is only activated when both input fields contain non-empty values. This activation is achieved through JavaScript code triggered by the onChange
event of each text input. While the specifics of implementing a web form are beyond the scope of this blog post, you can easily envision the visual representation of the screen:
When working with Espresso
, we can smoothly navigate from the previous screen to this web form. But the question is: how do we interact with this web form to continue this flow during our testing? The naive approach would be to use Espresso Web
which help us to interact with and make assertions on components displayed through a WebView
. After setting up the needed dependencies:
androidTestImplementation("androidx.test.espresso:espresso-web:3.4.0")
and writing our tests (Let's assume, for instance, that we've used Vaios
as the username and the highly secure/unbreakable password 123
😛):
onWebView()
.withElement(findElement(Locator.ID, "username"))
.perform(DriverAtoms.webKeys("Vaios"))
.withElement(findElement(Locator.ID, "password"))
.perform(DriverAtoms.webKeys("123"))
we see the following strange behavior:
The intriguing part of this scenario is that we can observe both hints and values in every text field, yet the button remains frustratingly disabled, even when valid values are provided. What on earth could have caused this unexpected behavior? 🤔 What makes it even more perplexing is that if we take matters into our own hands and manually input text using the keyboard, everything works as expected. It's evident that something has gone awry with our testing tools. 😕
What's going on?
The underlying issue lies in how our testing tools handle changes in HTML elements. When the value of an HTML element is modified programmatically, JavaScript events associated with it are flagged as 'non-trusted' for security reasons. This flag, known as the isTrusted
flag, effectively prevents related events from being triggered. Interestingly, Espresso Web
operates in the same manner behind the scenes. It injects code that programmatically changes the value of a text field, triggering this security measure.
In our specific scenario, when we updated the value of each text field, the code defined in the onChange
event never actually executed. This is why we ended up with both the hint and the value visible, as the hint is removed only when the onChange
event is triggered and the value becomes non-empty. Consequently, the button remained perpetually disabled, as the event required to enable it was never initiated.
A possible solution
So how do we overcome this limitation? The solution is pretty simple, we just need to think out of the box. Instead of using Espresso Web
to update HTML values programmatically, we need to simulate changing the values through the keyboard. Achieving this is remarkably simple with the assistance of the sendKeyDownUpSync
function from the Instrumentation
class. This function allows us to simulate the pressing and releasing of a key on a keyboard, effectively mimicking user input. So we could create an extension function like that to perform typing on a web form during testing:
fun Web.WebInteraction<Void>.type(
id: String,
text: String,
): Web.WebInteraction<Void> {
val instrumentation = Instrumentation()
withElement(findElement(Locator.ID, id))
.perform(DriverAtoms.webClick()) // To gain focus
KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)
.getEvents(text.toCharArray())
// Returned KeyEvents have both down and up actions.
// We filtered out the up actions to avoid duplications.
.filter { it.action == KeyEvent.ACTION_DOWN }
.forEach { instrumentation.sendKeySync(it) }
return this
}
Then we could rewrite our test:
onWebView()
.type(id = "username", text = "Vaios")
.type(id = "password", text = "123")
And while running our test scenario we would see that everything worked as expected (👏) on our web form:
Note: On the proposed extension function, we have used KeyCharacterMap
to convert characters into key codes and avoid calling repeatedly the sendKeySync
in favor of conciseness. Keep in mind that while this solution works for many scenarios, it may require some fine-tuning when dealing with special key codes or specific edge cases.
Beyond the described scenario
In this blog post, we venture beyond the confines of Android into the realm of JavaScript. The solution we've proposed appears to effectively address challenges in scenarios like the one we've described. It's worth noting that alternative solutions may also prove viable, such as using the sendText
function within UIAutomator
(if you prefer that tool). By delving deeper into the inner workings of JavaScript, we open up the possibility of discovering additional creative solutions for performing UI testing, ensuring the quality of our app in scenarios where Espresso Web
falls short of our expectations.