top of page
Matt

Efficient Android UI Testing: Functional Techniques without Dependency Injection Frameworks

Introduction

As discussed in my other blog posts, the importance of writing good, clean, automated tests for your project cannot be overstated. In this post, I will demonstrate how to write tests for Android using a functional approach. I'll illustrate how to inject functions with side effects to facilitate various scenarios, and how to do this without the need for a Dependency Injection (DI) framework, which often adds unnecessary boilerplate and complexity.


Structure your app for testing

In functional programming, functions are first class constructs, as opposed to classes. To effectively test our app, we need to inject functions, enabling us to write tests that cover all the different behaviours of the app. For instance, consider a simple app displaying a news feed for the day. We could write a range of tests for behaviours such as:

  • Displaying a friendly message if there is no news.

  • Correctly displaying news headlines.

  • Showing a friendly error message if there is an error in getting news.


For this, we could have a function getNewsF that returns Either<Error, Response>. We need to inject this function into our app code, so in our tests, we can control the function's behaviour when called, allowing us to effectively write the tests outlined above.


BTW the Either type is a functional type and comes from arrow core: https://arrow-kt.io/


AppF and AppFProvider:

Our apps usually contain multiple functions we wish to inject. First, let's create a class called AppF. This is a simple data class containing our functions:

data class AppF(
    val getNewsF: () -> Either<Error, Response>
)

The entry point for injecting AppF is a lazy setter that accesses an AppF instance from a singleton class called AppFProvider:

object AppFProvider {
    var appF: AppF = AppF()
}

and then this is injected in the MainActivity:

class MainActivity : ComponentActivity() {
    private val appF: AppF by lazy { AppFProvider.appF }
    ...
}

By default, AppFProvider is set with the REAL instance, while in tests, we can change it to any implementation we require. We must manually create our scenario within the test to control activation. Once we finish with our test, we close the scenario. Here's how we do it:

fun init(appF: AppF = buildTestAppF()): ActivityScenario<MainActivity> {
    AppFProvider.appF = appF

    return ActivityScenario.launch(MainActivity::class.java)
}

Example getNews Tests:

@Test
fun testIfGettingNewsErrorsThenErrorIsDisplayedAndLogged() {
    val expectedError = Error("Oh dear! failed to get news")
    val loggedErrors = mutableListOf<Error>()
    val logError = { error: Error ->
        loggedErrors.add(error)
        Unit
    }
    init(
        AppF(
            getNewsF = { expectedError.left() }
        )
    ).use {
        loggedErrors.shouldHaveSize(1)
        loggedErrors[0].shouldBe(expectedError)

        onView(withText("Failed to get news. Please try again later"))
            .check(matches(isDisplayed()))
    }
}

How to Support

This content will always remain free, and if you find it valuable, please consider sharing it with others. Additionally, downloading our games and leaving honest reviews greatly supports us. Feel free to reach out with any questions or feedback, and we'll do our best to respond.


Download Falling Sky from the Apple App Store today: https://apps.apple.com/app/id6446787964


14 views
bottom of page