Testing Coroutines and Firebase Login Flow
Asynchronous programming has become an essential part of modern software development. It helps developers write efficient code that can handle multiple tasks without blocking the main thread. However, testing asynchronous code can be challenging as it requires managing concurrent operations, race conditions, and other issues.
To address these challenges, Kotlin introduced the Coroutine framework, which makes it easy to write and test asynchronous code. The Coroutine framework provides a clean and concise way to handle asynchronous operations, and it is particularly useful when working with reactive streams or asynchronous code.
In this blog post, we will discuss the AuthViewModelTest
class, which uses Kotlin Coroutines to test asynchronous code in the AuthViewModel
class. The AuthViewModel
is responsible for handling user authentication and registration using Firebase Authentication.
Before diving into the code, let's briefly discuss some of the essential concepts of Kotlin Coroutines.
Kotlin Coroutines
Kotlin Coroutines provide a way to write asynchronous, non-blocking code that looks like synchronous, blocking code. Coroutines use suspend functions to pause the execution of a function until a result is available. A suspend function can be called from a coroutine, and when it is called, the coroutine is paused until the function returns a result. This allows for efficient use of resources and eliminates the need for callbacks or other complex concurrency patterns.
Coroutines run on top of threads, and the Coroutine framework provides a way to manage these threads. Kotlin Coroutines use Dispatcher
to manage threads. A Dispatcher
is responsible for determining which thread or threads the coroutine should run on.
There are several built-in dispatchers, such as Dispatchers.IO
for IO-bound tasks, Dispatchers.Default
for CPU-bound tasks, and Dispatchers.Main
for the main/UI thread. Additionally, you can create your custom dispatchers if needed.
Testing Kotlin Coroutines
Testing asynchronous code with Coroutines is easy and straightforward. Coroutines provide a set of test-specific classes and methods that make it easy to test asynchronous code. These classes and methods allow you to write tests that look like synchronous, blocking code.
To test Coroutines, you need to create a TestScope
object, which provides a controlled environment for Coroutines to run in. You can then use this object to launch a coroutine and test its behavior.
Here is an example of a test using Coroutines:
fun testAsync() = TestScope().runTest {
val result = async {
// some long-running task
}
// assert the result
assertEquals(expected, result.await())
}
AuthViewModelTest
The AuthViewModelTest
class is responsible for testing the AuthViewModel
class, which handles user authentication and registration using Firebase Authentication. The AuthViewModel
class exposes two Flow
s, loginFlow
and signupFlow
, which represent the current state of the authentication and registration operations.
The AuthViewModelTest
class uses the Mockito
library to mock the AuthRepository
class, which provides the interface to Firebase Authentication. The class also uses the InstantTaskExecutorRule
class to ensure that LiveData updates happen on the same thread.
@HiltViewModel
class AuthViewModel @Inject constructor(
private val repository: AuthRepository
) : ViewModel() {
private val _loginFlow = MutableStateFlow<FirebaseResource<FirebaseUser>?>(null)
val loginFlow: StateFlow<FirebaseResource<FirebaseUser>?> = _loginFlow
private val _signupFlow = MutableStateFlow<FirebaseResource<FirebaseUser>?>(null)
val signupFlow: StateFlow<FirebaseResource<FirebaseUser>?> = _signupFlow
val currentUser: FirebaseUser?
get() = repository.currentUser
init {
if (repository.currentUser != null) {
_loginFlow.value = FirebaseResource.Success(repository.currentUser!!)
}
}
fun login(email: String, password: String) = viewModelScope.launch {
_loginFlow.value = FirebaseResource.Loading
val result = repository.login(email, password)
_loginFlow.value = result
}
fun signup(name: String, email: String, password: String) = viewModelScope.launch {
_signupFlow.value = FirebaseResource.Loading
val result = repository.signup(name, email, password)
_signupFlow.value = result
}
fun logout() {
repository.logout()
_loginFlow.value = null
_signupFlow.value = null
}
}
Let's dive deeper into the testLoginSuccess
and testLoginFailure
functions of the AuthViewModelTest
class, which test the login functionality of the AuthViewModel
.
In the testLoginSuccess
function, we first define the input parameters for the login operation, such as the email and password. We also create a mock FirebaseUser
object that represents a successfully authenticated user.
We then use the Mockito
library to mock the behavior of the AuthRepository.login
function, which is responsible for authenticating the user using Firebase Authentication. We tell the mock object to return a FirebaseResource.Success
object with the FirebaseUser
object as the result.
Next, we call the viewModel.login
function, which launches a coroutine and initiates the login operation. We then use the join
method to wait for the coroutine to complete. Finally, we assert that the loginFlow
value of the viewModel
object is equal to the expected FirebaseResource.Success
object.
Here's the code for the testLoginSuccess
function:
@OptIn(ExperimentalCoroutinesApi::class)
class AuthViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Mock
private lateinit var repository: AuthRepository
private lateinit var viewModel: AuthViewModel
private val testDispatcher = StandardTestDispatcher()
@Before
fun setUp() {
MockitoAnnotations.openMocks(this)
Dispatchers.setMain(testDispatcher)
viewModel = AuthViewModel(repository)
}
@Test
fun testLoginSuccess() = TestScope().runTest {
val email = "test@example.com"
val password = "password"
val user = mock(FirebaseUser::class.java)
`when`(repository.login(email, password)).thenReturn(FirebaseResource.Success(user))
val job = viewModel.login(email, password)
job.join()
assertEquals(FirebaseResource.Success(user), viewModel.loginFlow.value)
}
@Test
fun testLoginFailure() = TestScope().runTest {
val email = "test@example.com"
val password = "password"
val exception = Exception("Invalid credentials")
`when`(repository.login(email, password)).thenReturn(FirebaseResource.Failure(exception))
val job = viewModel.login(email, password)
job.join()
assertEquals(FirebaseResource.Failure(exception), viewModel.loginFlow.value)
}
@Test
fun testSignupSuccess() = TestScope().runTest {
val name = "John Doe"
val email = "john.doe@example.com"
val password = "password"
val user = mock(FirebaseUser::class.java)
`when`(repository.signup(name, email, password)).thenReturn(FirebaseResource.Success(user))
viewModel.signup(name, email, password).join()
assertEquals(FirebaseResource.Success(user), viewModel.signupFlow.value)
}
@Test
fun testSignupFailure() = TestScope().runTest {
val name = "John Doe"
val email = "john.doe@example.com"
val password = "password"
val exception = Exception("Invalid credentials")
`when`(repository.signup(name, email, password)).thenReturn(FirebaseResource.Failure(exception))
viewModel.signup(name, email, password).join()
assertEquals(FirebaseResource.Failure(exception), viewModel.signupFlow.value)
}
@Test
fun testLogout() {
viewModel.logout()
assertNull(viewModel.loginFlow.value)
assertNull(viewModel.signupFlow.value)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
}
In these tests, we're using Coroutines to simulate an asynchronous environment and test the behavior of the AuthViewModel
class when it's used for user authentication. These tests ensure that the AuthViewModel
behaves correctly in the presence of both successful and failed login attempts.
By using the TestScope
and runTest
functions, we can write concise and expressive tests for the AuthViewModel
class that are easy to read and maintain.