Android Project Architecture for Instrumented Testing with Kotlin/Java
First, a bit about me. I am an experienced Software Engineer who previously worked as a Mobile Application Developer, Full-Stack Software Engineer, Virtual Reality and Augmented Reality Developer, and Cloud Engineer.
Kotlin and Testing in Android
In Kotlin/Java native Android development, integration tests are commonly referred to as instrumented tests. Designing a project structure that facilitates efficient instrumented testing is a challenging task. At times, it becomes necessary to mock certain logic. In my scenario, I’m working with the GraphQL API using the Apollo client, requiring me to mock the logic for making API requests. Apollo API calls in Android operate asynchronously. Mocking asynchronous logic presents challenges in Android, especially when we aim to avoid mocking the logic of the asynchronous callback while mocking the method executing the asynchronous call.
This article outlines the step-by-step approach to the architecture and project structure I employ in my Android projects, along with the considerations that led me to adopt this structure. I primarily use the Kotlin programming language, so I assume you are already familiar with Kotlin. It’s worth noting that Kotlin has been officially endorsed by Google as the preferred programming language for Android development, superseding Java.
This is how it all begins….
“One day,” I began working on a Kotlin Android project and started implementing a Login feature. In my LoginActivity
class, I had to consume the GraphQL API.
Here is the dummy code for my LoginActivity
class.
class LoginActivity : AppCompatActivity()
{
//some code hidden
.
.
. override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// some code hidden ....
view.login_btn_submit.setOnClickListener {
this.handleLoginButtonClick()
} } fun handleLoginButtonClick()
{
//some code hidden here
.
.
. val mutation = ... // build the login mutation here
apolloClient.mutate(mutation).enqueue(object: ApolloCall.Callback<LoginMutation.Data>() {
override fun onFailure(e: ApolloException) {
text_view_error_message.text = "There was an error logging in"
}
override fun onResponse(response: Response<LoginMutation.Data>) {
startMainActivity()
}
}) }
}
The above code functions well until you commence writing tests for it. For enthusiasts of test-driven development, the problem becomes apparent. There is a need to mock the logic of making the API call during the test wiring. The following was the initial design that struck me:
To make the code fully testable, my initial approach involved refactoring the login API request code into a separate class. Subsequently, this class would be declared as a property of the LoginActivity
class. The AuthenticationService
class would then be injected into the LoginActivity
class using dependency injection. This way, I could mock the AuthenticationService
class in my tests and manipulate its behavior.
Here is the dummy code for my AuthenticationService
class:
class AuthenticationService: IAuthenticationService
{
fun login(email: String, password: String, view: View, activity: Activity) {
val mutation = ... // build the login mutation here
apolloClient.mutate(mutation).enqueue(object: ApolloCall.Callback<LoginMutation.Data>() {
override fun onFailure(e: ApolloException) {
view.text_view_error_message.text = "There was an error logging in"
}
override fun onResponse(response: Response<LoginMutation.Data>) {
activity.startActivity(Intent(pass the parameters here to start new activity)) // this is just dummy code
}
})
}
}
The class extends the IAuthenticationService
interface (you can infer the structure of IAuthenticationService
). Additionally, the LoginActivity
class will have a class property with the type IAuthenticationService
. As you may have already noticed, I am implementing dependency injection.
However, there are two apparent issues with the above class that need addressing.
2. It violates the single responsibility of the SOLID principles
We are passing the view and activity parameters into the login method. However, the AuthenticationService
class should not be responsible for dealing with UI and navigation flow. This violates the first rule of SOLID - the Single Responsibility Principle. A class should exhibit high cohesion and focus on a single responsibility.
2. The asynchronous nature is sometimes problematic for writing tests.
The asynchronous nature of the method presents a challenge because the completion of its execution is unpredictable. I must emulate this behavior in my tests. Even if I can mock the AuthenticationService
class and its login
method, how can I ensure in my test that it updates the UI as expected within the callback method of the asynchronous call? If I am mocking the logic within the asynchronous callback method, the purpose of writing instrumented/integrated tests for my project becomes questionable.
If I were to mock the entire login
method of the IAuthenticationService
interface, the purpose of adding instrumented tests for the LoginActivity
class becomes questionable. This is because I would have to mock the logic of updating the UI (view and activity parameters) as well, thereby compromising the robustness of regression tests. How can I effectively mock the behavior of the login
method to ensure that the instrumented tests are written efficiently, covering every possible aspect of the implementation?
I had to refactor the view and activity parameters into the LoginActivity
class and delegate the UI tasks to it. By doing so, when writing tests, I could mock the login
method to trigger an event that can be listened to within the LoginActivity
class. The LoginActivity
class would then update the UI within the event listener, allowing me to replicate the behavior of the asynchronous call.
How can I achieve that?
Solution:
I implemented the solution by employing a reactive programming approach called RxJava/RxKotlin.
If you want to learn more about RxJava, you can visit this link: RxJava Tutorial. Since I am using Kotlin, I refer to it as RxKotlin. RxKotlin is essentially a ReactiveX programming API tailored for Kotlin. But what does that mean? It’s an API designed for asynchronous programming with observable streams. Now, what exactly is an observable stream? Sometimes, I tend to simplify definitions for better understanding.
ReactiveX, in my understanding, is akin to “an observer pattern that patiently awaits something to happen. When that event occurs, the event listener elsewhere reacts to it and performs actions based on the event.” In my scenario, the AuthenticationService
runs an asynchronous call. Upon completion, it triggers an event. The LoginActivity
class is set to listen for that event and take actions based on the message (data) sent from the triggered event.
Time to return to coding. I registered an observer and observable in the LoginActivity
class.
class LoginActivity: AppCompatActivity()
{
//some code hidden
.
. companion object {
lateinit var loginObservable: Observable<DataModel>
lateinit var loginObserver: Observer<DataModel>
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// some code hidden .... loginObservable = Observable.empty()
loginObserver = setUpLoginObserver()
loginObservable.subscribe(loginObserver) view.login_btn_submit.setOnClickListener {
this.handleLoginButtonClick()
} } private fun setUpLoginObserver(): Observer<DataModel> {
return object: Observer<DataModel> {
override fun onSubscribe(d: Disposable) {
//do something
}
override fun onNext(t: DataModel) {
//do something
}
override fun onError(e: Throwable) {
//do something
}
override fun onComplete() {
//do something
}
}
}}
NOTE: DataModel
is a custom class I created to pass data from the service class to the activity class.
As evident in the code, I created two fields within the companion object. If you are coming from a Java background, you can perceive them as static properties. These are the observable and the observer. Subsequently, the observer subscribes to the observable. Consequently, when something happens (changes) in the observable, the observer will execute one of the callbacks (as seen in the setUpLoginObserver
method) based on the event.
Here is the updated version of the dummy code for the AuthenticationService
class.
class AuthenticationService: IAuthenticationService
{
fun login(email: String, password: String) {
val mutation = ... // build the login mutation here
apolloClient.mutate(mutation).enqueue(object: ApolloCall.Callback<LoginMutation.Data>() {
override fun onFailure(e: ApolloException) {
}
override fun onResponse(response: Response<LoginMutation.Data>) { //pass your data in the data parameter (DataModel) class type
LoginActivity.loginObserver.onNext(data)
}
})
}
}
As evident in the code, within the success callback of the asynchronous call, it triggers the onNext
callback of the observer. The LoginActivity
will execute the onNext
callback and update the UI. Now, I can create a mock version of the AuthenticationService
class to be used in the test. With this mock class, I can simulate the behavior of the asynchronous call and trigger the onNext
event of the observer.
NOTE: I am utilizing Dagger 2 and Espresso for my instrumented tests. If you wish to delve into Dagger 2, I recommend this article: Dagger 2 for Dummies in Kotlin with One Page Simple Code Project.
I created a mock version of the AuthenticationService
class, named FakeAuthenticationService
, with the following dummy code.
class FakeAuthenticationService: IAuthenticationService
{
companion object {
var SCENARIO_UNDER_TEST = 0
val SCENARIO_LOGIN_ERROR: Int = 1
val SCENARIO_LOGIN_SUCCESSFUL: Int = 2
}
override fun login(email: String, password: String) { //Maybe run the code with a timeout if you want it to be asynchronous
val dataModel = DataModel()
when (SCENARIO_UNDER_TEST) {
SCENARIO_LOGIN_ERROR -> {
dataModel.message = "Unable to login"
}
SCENARIO_LOGIN_SUCCESSFUL -> {
loginModel.message = "Login successful"
loginModel.accessToken = "fake-access-token" }
}
LoginActivityt.loginObserver.onNext(dataModel) }
}
As evident in the code, it still implements the IAuthenticationService
interface, allowing me to use this class instead of the AuthenticationService
class for my tests—thanks to "Dependency Injection." The code showcases how I mocked the login
method to behave according to the scenario I am testing. Below is how I tested a scenario for the login feature.
As visible, I am testing whether the error message is displayed when the request throws an error. I define the scenario I want to test at the outset of the test. I plan to create a separate article to cover configuring the tests and mocking classes with Dagger 2, as that is where the magic happens.
This is the workflow of my project.
Optional: making it better using MVVM pattern
However, I would like to introduce another element to the current architecture to enhance the project’s maintainability and organization. As you can observe, I created a class called DataModel
to facilitate data transfer between the observable and the observer. I have renamed this class to LoginViewModel
. The LoginViewModel
class will now serve as the view model for the LoginActivity
. I assume you are already familiar with the View Model component in the MVVM pattern for mobile application development, as described here: Model–view–viewmodel.
Below is the project structure I ultimately adopted, which is fully conducive to instrumented testing.
This article solely addresses the design of an Android project structure or architecture that is fully testable through instrumented/integrated tests. I plan to create a separate article focused on configuring Dagger 2 to mock the classes, providing more detailed insights into the codebase. I trust you find this article helpful, especially if you are endeavoring to establish an Android project that seamlessly integrates with instrumented tests.