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 ")
}
Create an auth
package inside of test [unitTest]
folder together with files for the code. For me it looks like this
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
}
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()
}
}
}
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
}
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))
}
}
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))
}
}
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)
}
}
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) }
}
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) }
}
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))
}
}
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()
}
}
}
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)
}
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)
}
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))
}
}
}
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()
}
}
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()
}
}
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!!)
}
}
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()))
}
}
}
That's it! If you have any suggestions feel free to leave them in the comments. Good luck!
Top comments (0)