Portfolio: Adam Gardner

View Original

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 Flows, 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.