DEV Community

Yauheni Mookich
Yauheni Mookich

Posted on

Firebase Authentication With Jetpack Compose. Testing. Part 2

Nice to meet you here. This post is the second part of a series of Firebase Authentication with Jetpack Compose. Today we're going to implement UI and Unit testing with the help of Robolectric and MockK. Make sure to have a tab with this post opened


What is Robolectric?

Robolectric is a framework which enables testing Android applications without an emulator, directly on a local computer. It does so by emulating Android environment and its components. Robolectric tests are primarily used in UI and Integration testing. Robolectric tests use the same syntax for verifying UI components as regular UI tests run on an emulator.


What is MockK?

MockK is a Unit testing framework. It allows to mock, or fake, our code. Mocking can make certain functions or properties return the result we want.

Setup

dependencies {
    val mockkVersion = "1.13.11"
    val robolectricVersion = "4.12.1"
    testImplementation("io.mockk:mockk-android:$mockkVersion")
    testImplementation("io.mockk:mockk-agent:$mockkVersion")
    testImplementation("org.robolectric:robolectric:$robolectricVersion ")
}
Enter fullscreen mode Exit fullscreen mode

Create an auth package inside of test [unitTest] folder together with files for the code. For me it looks like this

Image description
Common.kt file should contain shared authentication mocking function so that we could reuse it in UI and Unit testing


Create a Helpers.kt file and insert the following code

val userId = "id"
class CorrectAuthData {
    companion object {
        const val USERNAME: String = "Evgen"
        const val EMAIL: String = "someemail@gmail.com"
        const val PASSWORD: String = "SomePassword123"
    }
}
class IncorrectAuthData {
    companion object {
        const val USERNAME: String = " "
        const val EMAIL: String = "incorrect"
        const val PASSWORD: String = " "
    }
}
inline fun <reified T> mockTask(result: T? = null, exception: Exception? = null): Task<T> {
    val task = mockk<Task<T>>()
    every { task.result } returns result
    every { task.exception } returns exception
    every { task.isCanceled } returns false
    every { task.isComplete } returns true
    return task
}
Enter fullscreen mode Exit fullscreen mode

mockTask function is defined as reified so that it could take in any type of Firebase task (T) as a parameter, which is inserted automatically when the Task starts executing. E.g, Firestore set function is of the following type: com.google.android.gms.tasks.Task<Void>, while signInWithEmailAndPassword is of com.google.android.gms.tasks.Task<com.google.firebase.auth.AuthResult> type


Inside of Common.kt file define the following function

fun mockAuth(userProfileChangeRequest: CapturingSlot<UserProfileChangeRequest>? = null): FirebaseAuth {
    val user = mockk<FirebaseUser>{
        every { uid } returns userId
        every { displayName } returns CorrectAuthData.USERNAME
        every { updateProfile(if (userProfileChangeRequest != null) capture(userProfileChangeRequest) else any()) } returns mockTask()
    }
    return mockk {
        every { currentUser } returns user
        every { signOut() } answers {
            every { currentUser } returns null
        }
        every { createUserWithEmailAndPassword(any(), any()) } answers {
            every { currentUser } returns user
            mockTask()
        }
        every { signInWithEmailAndPassword(any(), any()) } answers {
            every { currentUser } returns user
            mockTask()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

As already said at the beginning, mocking allows us to make certain functions or properties of a mocked object return the values we want them to return. In the code above we define a function which returns a mocked instance of Firebase auth. The mocking syntax is clear: for every call to currentUser return a user instance of FirebaseUser. answers keyword enables us to mock other objects on function call.
userProfileChangeRequest is inserted as a slot to the updateProfile function call. With this we can verify that a call to updateProfile was made with a specific instance of UserProfileChangeRequest. We could also verify its displayName property.


After that define a BaseTestClass which will hold basic properties and methods of every test class and which every test class will implement.

open class BaseTestClass {
    val testScope = TestScope()
    val snackbarScope = TestScope()
    lateinit var auth: FirebaseAuth
}
Enter fullscreen mode Exit fullscreen mode

Unit testing code

Inside of AuthUnitTests class define an init function which is going to initialize authentication and view model instances.

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class AuthUnitTests: BaseTestClass() {
    private val userProfileChangeRequestSlot = slot<UserProfileChangeRequest>()
    private lateinit var viewModel: AuthViewModel

    @Before
    fun init() {
        auth = mockAuth(userProfileChangeRequestSlot)
        val repository = AuthRepository(auth, firestore)
        viewModel = AuthViewModel(repository, CoroutineScopeProvider(testScope))
    }
}
Enter fullscreen mode Exit fullscreen mode

In my previous article I've touched on a topic of inserting CoroutineScope instances. The code above demonstrates that.


Next we're going to test how username, password, and email validators work together with AuthViewModel. First let's define a function inside of our test class which makes sure that validators react appropriately to the input of incorrect format.

@Test
    fun incorrectInput_error() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val resources = context.resources
        viewModel.apply {
            onUsername("")
            assertEquals(uiState.value.validationState.usernameValidationError.asString(context),
                resources.getString(R.string.username_not_long_enough))

            onEmail("dfjdjfk")
            assertEquals(uiState.value.validationState.emailValidationError.asString(context),
                resources.getString(R.string.invalid_email_format))

            onPassword("dfdf")
            assertEquals(uiState.value.validationState.passwordValidationError.asString(context),
                resources.getString(R.string.password_not_long_enough))
            onPassword("eirhgejbrj")
            assertEquals(uiState.value.validationState.passwordValidationError.asString(context),
                resources.getString(R.string.password_not_enough_uppercase))
            onPassword("Geirhgejbrj")
            assertEquals(uiState.value.validationState.passwordValidationError.asString(context),
                resources.getString(R.string.password_not_enough_digits))
        }
    }
Enter fullscreen mode Exit fullscreen mode

Here we're using dummy input values and assert that a validator's error is equal to the one defined in string resources.


Next we're going to test how validators and the view model react to input of correct format

@Test
    fun correctInput_success() {
        viewModel.apply {
            onUsername(CorrectAuthData.USERNAME)
            assertTrue(uiState.value.validationState.usernameValidationError == StringValue.Empty)

            onEmail(CorrectAuthData.EMAIL)
            assertTrue(uiState.value.validationState.emailValidationError == StringValue.Empty)

            onPassword(CorrectAuthData.PASSWORD)
            assertTrue(uiState.value.validationState.passwordValidationError == StringValue.Empty)
        }
    }
Enter fullscreen mode Exit fullscreen mode

Next goes a function which tests the sign up behaviour

@Test
    fun signUp_success() = testScope.runTest {
        viewModel.apply {
            changeAuthType()
            assertEquals(uiState.value.authType, AuthType.SIGN_UP)
            onUsername(CorrectAuthData.USERNAME)
            onEmail(CorrectAuthData.EMAIL)
            onPassword(CorrectAuthData.PASSWORD)
            onCustomAuth()
        }
        advanceUntilIdle()
        verify { auth.createUserWithEmailAndPassword(CorrectAuthData.EMAIL, CorrectAuthData.PASSWORD) }
        assertEquals(userProfileChangeRequestSlot.captured.displayName, CorrectAuthData.USERNAME)
        verify { auth.currentUser!!.updateProfile(userProfileChangeRequestSlot.captured) }
    }
Enter fullscreen mode Exit fullscreen mode

runTest function allows us to execute suspend functions in the test code. It's important to call runTest specifically on the testScope instance, since this is the scope in which our view model is going to call auth functions.
In the function above we're first changing auth type to sign up, then insert credentials and call the auth function.
advanceUntilIdle function makes suspend functions return immediately.
In the end we're verifying if createUserWithEmailAndPassword and updateProfile were called with correct parameters, and assert that displayName of UserProfileChangeRequest instance equals to the one we inserted.

Sign in testing function looks similarly

@Test
    fun signIn_success() = testScope.runTest {
        viewModel.apply {
            assertEquals(uiState.value.authType, AuthType.SIGN_IN)
            onUsername(CorrectAuthData.USERNAME)
            onEmail(CorrectAuthData.EMAIL)
            onPassword(CorrectAuthData.PASSWORD)
            onCustomAuth()
        }
        advanceUntilIdle()
        verify { auth.signInWithEmailAndPassword(CorrectAuthData.EMAIL, CorrectAuthData.PASSWORD) }
    }
Enter fullscreen mode Exit fullscreen mode

UI testing code

In AuthUITests class define a setup method which, in addition to creating a view model and auth mock, will also set content

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class AuthUITests: BaseTestClass() {
    @get: Rule
    val composeRule = createComposeRule()
    private lateinit var viewModel: AuthViewModel

    @Before
    fun setup() {
        auth = mockAuth()
        createViewModel()
        composeRule.apply {
            setContentWithSnackbar(snackbarScope) {
                AuthScreen(onSignIn = { }, viewModel = viewModel)
            }
        }
    }
    private fun createViewModel() {
        val repository = AuthRepository(auth, firestore)
        viewModel = AuthViewModel(repository, CoroutineScopeProvider(testScope))
    }
}
Enter fullscreen mode Exit fullscreen mode

Head on to Common.kt file and define a setContentWithSnackbar extension function

@OptIn(ExperimentalMaterial3Api::class)
fun ComposeContentTestRule.setContentWithSnackbar(
    coroutineScope: CoroutineScope,
    content: @Composable () -> Unit) {
    setContent {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val snackbarHostState = remember { SnackbarHostState() }
        val snackbarController = SnackbarController(snackbarHostState, coroutineScope, context)
        CompositionLocalProvider(LocalSnackbarController provides snackbarController) {
            CustomErrorSnackbar(snackbarHostState = snackbarHostState,
                swipeToDismissBoxState = rememberSwipeToDismissBoxState())
            content()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In the same file define 2 more helper functions

@OptIn(ExperimentalCoroutinesApi::class)
fun ComposeContentTestRule.assertSnackbarIsNotDisplayed(snackbarScope: TestScope) {
    waitForIdle()
    snackbarScope.advanceUntilIdle()
    onNodeWithTag(getString(R.string.error_snackbar)).assertIsNotDisplayed()
}
@OptIn(ExperimentalCoroutinesApi::class)
fun ComposeContentTestRule.assertSnackbarTextEquals(snackbarScope: TestScope, message: String) {
    waitForIdle()
    snackbarScope.advanceUntilIdle()
    onNodeWithTag(getString(R.string.error_snackbar)).assertTextEquals(message)
}
Enter fullscreen mode Exit fullscreen mode

Before making assertions, we first have to make sure that all coroutines get executed, e.g LaunchedEffect in AuthScreen

LaunchedEffect(uiState.authResult) {
        snackbarController.showSnackbar(uiState.authResult)
}
Enter fullscreen mode Exit fullscreen mode

And the one in SnackbarController

fun showSnackbar(result: CustomResult) {
        if (result is CustomResult.DynamicError || result is CustomResult.ResourceError) {
            coroutineScope.launch {
                snackbarHostState.currentSnackbarData?.dismiss()
                snackbarHostState.showSnackbar(result.error.asString(context))
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

That's exactly what waitForIdle() and snackbarScope.advanceUntilIdle() do.


Next let's write the code that verifies that the UI correctly responds to input

    @Test
    fun signIn_testIncorrectInput() {
        composeRule.apply {
            onNodeWithText(getString(R.string.dont_have_an_account)).assertIsDisplayed()
            onNodeWithTag(getString(R.string.email)).performTextReplacement(
                IncorrectAuthData.EMAIL)
            onNodeWithTag(getString(R.string.password)).performTextReplacement(
                IncorrectAuthData.PASSWORD)
            onNodeWithText(getString(R.string.sign_in)).assertIsNotEnabled()
        }
    }
    @Test
    fun signUp_testIncorrectInput() {
        composeRule.apply {
            onNodeWithText(getString(R.string.go_to_signup)).performClick()
            onNodeWithText(getString(R.string.dont_have_an_account)).assertIsNotDisplayed()

            onNodeWithTag(getString(R.string.username)).performTextReplacement(
                IncorrectAuthData.USERNAME)
            onNodeWithText(getString(R.string.username_not_long_enough)).assertIsDisplayed()
            onNodeWithTag(getString(R.string.email)).performTextReplacement(
                IncorrectAuthData.EMAIL)
            onNodeWithText(getString(R.string.invalid_email_format)).assertIsDisplayed()
            onNodeWithTag(getString(R.string.password)).performTextReplacement(
                IncorrectAuthData.PASSWORD)
            onNodeWithText(getString(R.string.password_not_long_enough)).assertIsDisplayed()

            onNodeWithText(getString(R.string.sign_up)).assertIsNotEnabled()
        }
    }
   @Test
    fun signIn_testCorrectInput() {
        composeRule.apply {
            onNodeWithText(getString(R.string.dont_have_an_account)).assertIsDisplayed()
            onNodeWithTag(getString(R.string.email)).performTextReplacement(
                CorrectAuthData.EMAIL)
            onNodeWithTag(getString(R.string.password)).performTextReplacement(
                CorrectAuthData.PASSWORD)
            onNodeWithText(getString(R.string.sign_in)).assertIsEnabled()
        }
    }
    @Test
    fun signUp_testCorrectInput() {
        composeRule.apply {
            onNodeWithText(getString(R.string.go_to_signup)).performClick()
            onNodeWithText(getString(R.string.dont_have_an_account)).assertIsNotDisplayed()
            onNodeWithTag(getString(R.string.username)).performTextReplacement(
                CorrectAuthData.USERNAME)
            onNodeWithTag(getString(R.string.email)).performTextReplacement(
                CorrectAuthData.EMAIL)
            onNodeWithTag(getString(R.string.password)).performTextReplacement(
                CorrectAuthData.PASSWORD)
            onNodeWithText(getString(R.string.sign_up)).assertIsEnabled()
        }
    }
Enter fullscreen mode Exit fullscreen mode

Recall that sign up must not be available if username is not provided, while sign in must be available even if username is not null. This is what we'll test next

@Test
    fun signInCorrectInputTest_onGoToSignUpClick_isSignUpDisabled() {
        composeRule.apply {
            onNodeWithTag(getString(R.string.email)).performTextReplacement(
                CorrectAuthData.EMAIL)
            onNodeWithTag(getString(R.string.password)).performTextReplacement(
                CorrectAuthData.PASSWORD)
            onNodeWithText(getString(R.string.go_to_signup)).performClick()
            onNodeWithText(getString(R.string.sign_up)).assertIsNotEnabled()
        }
    }
    @Test
    fun signUpCorrectInputTest_onGoToSignInClick_isSignInEnabled() {
        composeRule.apply {
            onNodeWithText(getString(R.string.go_to_signup)).performClick()

            onNodeWithTag(getString(R.string.username)).performTextReplacement(
                CorrectAuthData.USERNAME)
            onNodeWithTag(getString(R.string.email)).performTextReplacement(
                CorrectAuthData.EMAIL)
            onNodeWithTag(getString(R.string.password)).performTextReplacement(
                CorrectAuthData.PASSWORD)

            onNodeWithText(getString(R.string.sign_up)).assertIsEnabled()
            onNodeWithText(getString(R.string.go_to_signin)).performClick()
            onNodeWithText(getString(R.string.sign_in)).assertIsEnabled()
        }
    }
Enter fullscreen mode Exit fullscreen mode

And finally, let's test how UI reacts on successful and unsuccessful authentication

@Test
    fun signIn_onSuccess_snackbarNotShown() = testScope.runTest {
        composeRule.apply {
            onNodeWithTag(getString(R.string.email)).performTextReplacement(
                CorrectAuthData.EMAIL)
            onNodeWithTag(getString(R.string.password)).performTextReplacement(
                CorrectAuthData.PASSWORD)
            onNodeWithText(getString(R.string.sign_in)).performClick()

            onNodeWithTag(getString(R.string.email)).assertIsNotEnabled()
            onNodeWithTag(getString(R.string.password)).assertIsNotEnabled()
            onNodeWithText(getString(R.string.go_to_signup)).assertIsNotEnabled()
            onNodeWithText(getString(R.string.sign_in)).assertIsNotEnabled()

            advanceUntilIdle()
            assertSnackbarIsNotDisplayed(snackbarScope)
        }
    }
    @Test
    fun signUp_onSuccess_snackbarNotShown() = testScope.runTest {
        composeRule.apply {
            onNodeWithText(getString(R.string.go_to_signup)).performClick()
            onNodeWithTag(getString(R.string.username)).performTextReplacement(
                CorrectAuthData.USERNAME)
            onNodeWithTag(getString(R.string.email)).performTextReplacement(
                CorrectAuthData.EMAIL)
            onNodeWithTag(getString(R.string.password)).performTextReplacement(
                CorrectAuthData.PASSWORD)
            onNodeWithText(getString(R.string.sign_up)).performClick()

            onNodeWithTag(getString(R.string.username)).assertIsNotEnabled()
            onNodeWithTag(getString(R.string.email)).assertIsNotEnabled()
            onNodeWithTag(getString(R.string.password)).assertIsNotEnabled()
            onNodeWithText(getString(R.string.go_to_signin)).assertIsNotEnabled()
            onNodeWithText(getString(R.string.sign_up)).assertIsNotEnabled()

            advanceUntilIdle()
            assertSnackbarIsNotDisplayed(snackbarScope)
        }
    }
    @Test
    fun signIn_onError_snackbarShown() = testScope.runTest {
        val exception = Exception("exception")
        every { auth.signInWithEmailAndPassword(CorrectAuthData.EMAIL, CorrectAuthData.PASSWORD) } returns mockTask(exception = exception)
        composeRule.apply {
            onNodeWithTag(getString(R.string.email)).performTextReplacement(
                CorrectAuthData.EMAIL)
            onNodeWithTag(getString(R.string.password)).performTextReplacement(
                CorrectAuthData.PASSWORD)
            onNodeWithText(getString(R.string.sign_in)).performClick()

            onNodeWithTag(getString(R.string.email)).assertIsNotEnabled()
            onNodeWithTag(getString(R.string.password)).assertIsNotEnabled()
            onNodeWithText(getString(R.string.go_to_signup)).assertIsNotEnabled()
            onNodeWithText(getString(R.string.sign_in)).assertIsNotEnabled()

            advanceUntilIdle()
            assertSnackbarTextEquals(snackbarScope, exception.message!!)
        }
    }

    @Test
    fun signUp_onError_snackbarShown() = testScope.runTest {
        val exception = Exception("exception")
        every { auth.createUserWithEmailAndPassword(CorrectAuthData.EMAIL, CorrectAuthData.PASSWORD) } returns mockTask(exception = exception)
        composeRule.apply {
            onNodeWithText(getString(R.string.go_to_signup)).performClick()
            onNodeWithTag(getString(R.string.username)).performTextReplacement(
                CorrectAuthData.USERNAME)
            onNodeWithTag(getString(R.string.email)).performTextReplacement(
                CorrectAuthData.EMAIL)
            onNodeWithTag(getString(R.string.password)).performTextReplacement(
                CorrectAuthData.PASSWORD)
            onNodeWithText(getString(R.string.sign_up)).performClick()

            onNodeWithTag(getString(R.string.username)).assertIsNotEnabled()
            onNodeWithTag(getString(R.string.email)).assertIsNotEnabled()
            onNodeWithTag(getString(R.string.password)).assertIsNotEnabled()
            onNodeWithText(getString(R.string.go_to_signin)).assertIsNotEnabled()
            onNodeWithText(getString(R.string.sign_up)).assertIsNotEnabled()

            advanceUntilIdle()
            assertSnackbarTextEquals(snackbarScope, exception.message!!)
        }
    }
Enter fullscreen mode Exit fullscreen mode

onNodeWithTag(getString(R.string.email)) represents email auth field

The code above clearly demonstrates the advantage of inserting CoroutineScope into view models. We could use the viewModelScope in Robolectric tests, but it would deprive us of a possibility of verifying InProgress state since the suspending code would automatically advance. Also, before making assertions on InProgress state, make sure that in the view model you don't update the state inside of coroutine body, e.g:

fun onCustomAuth() {
        val authType = _uiState.value.authType
        updateAuthResult(CustomResult.InProgress)
        scope.launch {
            try {
                if (authType == AuthType.SIGN_UP) {
                    authRepository.signUp(_uiState.value.authState)
                }
                authRepository.signIn(_uiState.value.authState)
                updateAuthResult(CustomResult.Success)
            } catch (e: Exception) {
                updateAuthResult(CustomResult.DynamicError(e.toStringIfMessageIsNull()))
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

That's it! If you have any suggestions feel free to leave them in the comments. Good luck!

Top comments (0)