Merge pull request #68 from fknives/compose

Compose
This commit is contained in:
Gergely Hegedis 2022-04-11 13:06:56 +03:00 committed by GitHub
commit 1abd50468f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1458 additions and 2 deletions

View file

@ -161,6 +161,9 @@ In this section we will see how to test component depending on context such as R
In this tests we will also see how to interact with View components in tests via Espresso.
We will also see how to test a specific Activity (same concept can be applied to fragments)
Bonus:
* Testing Compose UI: Open the [compose instruction set](./codekata/compose.instructionset.md)
#### Robolectric and Android Tests.
Open the [shared tests instruction set](./codekata/sharedtests.instructionset.md).

View file

@ -34,6 +34,10 @@ android {
buildFeatures {
viewBinding true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = project.androidx_compose_version
}
sourceSets {
@ -68,18 +72,32 @@ dependencies {
implementation "androidx.appcompat:appcompat:$androidx_appcompat_version"
implementation "com.google.android.material:material:$androidx_material_version"
implementation "androidx.constraintlayout:constraintlayout:$androidx_constraintlayout_version"
implementation "androidx.constraintlayout:constraintlayout-compose:$androidx_compose_constraintlayout_version"
implementation "androidx.lifecycle:lifecycle-livedata-core-ktx:$androidx_livedata_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidx_livedata_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$androidx_livedata_version"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_version"
implementation "androidx.activity:activity-compose:$activity_ktx_version"
implementation "androidx.navigation:navigation-compose:$androidx_navigation"
implementation "androidx.compose.ui:ui:$androidx_compose_version"
implementation "androidx.compose.ui:ui-tooling:$androidx_compose_version"
implementation "androidx.compose.foundation:foundation:$androidx_compose_version"
implementation "androidx.compose.material:material:$androidx_compose_version"
implementation "androidx.compose.animation:animation-graphics:$androidx_compose_version"
implementation "com.google.accompanist:accompanist-insets:$google_accompanist_version"
implementation "com.google.accompanist:accompanist-swiperefresh:$google_accompanist_version"
implementation "io.insert-koin:koin-android:$koin_version"
implementation "io.insert-koin:koin-androidx-compose:$koin_version"
implementation "androidx.room:room-runtime:$androidx_room_version"
kapt "androidx.room:room-compiler:$androidx_room_version"
implementation "androidx.room:room-ktx:$androidx_room_version"
implementation "io.coil-kt:coil:$coil_version"
implementation "io.coil-kt:coil-compose:$coil_version"
implementation project(":core")
@ -115,6 +133,9 @@ dependencies {
androidTestImplementation "androidx.test.espresso:espresso-core:$testing_espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-intents:$testing_espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$testing_espresso_version"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$androidx_compose_version"
testImplementation "androidx.compose.ui:ui-test-junit4:$androidx_compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$androidx_compose_version"
androidTestImplementation project(':mockserver')
androidTestImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version"
androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version"

View file

@ -0,0 +1,173 @@
package org.fnives.test.showcase.ui
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.fnives.test.showcase.R
import org.fnives.test.showcase.compose.screen.AppNavigation
import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule
import org.fnives.test.showcase.testutils.idling.DispatcherTestRule
import org.fnives.test.showcase.testutils.idling.anyResourceIdling
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.koin.test.KoinTest
@RunWith(AndroidJUnit4::class)
class AuthComposeInstrumentedTest : KoinTest {
@get:Rule
val composeTestRule = createComposeRule()
private val stateRestorationTester = StateRestorationTester(composeTestRule)
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule()
private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
private val dispatcherTestRule = DispatcherTestRule()
private lateinit var robot: ComposeLoginRobot
private lateinit var navigationRobot: ComposeNavigationRobot
@Rule
@JvmField
val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule)
.around(dispatcherTestRule)
@Before
fun setup() {
stateRestorationTester.setContent {
AppNavigation(isUserLogeInUseCase = IsUserLoggedInUseCase(FakeUserDataLocalStorage()))
}
robot = ComposeLoginRobot(composeTestRule)
navigationRobot = ComposeNavigationRobot(composeTestRule)
}
/** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */
@Test
fun properLoginResultsInNavigationToHome() {
mockServerScenarioSetup.setScenario(
AuthScenario.Success(password = "alma", username = "banan")
)
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.assertAuthScreen()
robot.setPassword("alma")
.setUsername("banan")
.assertUsername("banan")
.assertPassword("alma")
composeTestRule.mainClock.autoAdvance = false
robot.clickOnLogin()
composeTestRule.mainClock.advanceTimeByFrame()
robot.assertLoading()
composeTestRule.mainClock.autoAdvance = true
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.assertHomeScreen()
}
/** GIVEN empty password and username WHEN signIn THEN error password is shown */
@Test
fun emptyPasswordShowsProperErrorMessage() {
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.assertAuthScreen()
robot.setUsername("banan")
.assertUsername("banan")
.clickOnLogin()
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
robot.assertErrorIsShown(R.string.password_is_invalid)
.assertNotLoading()
navigationRobot.assertAuthScreen()
}
/** GIVEN password and empty username WHEN signIn THEN error username is shown */
@Test
fun emptyUserNameShowsProperErrorMessage() {
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.assertAuthScreen()
robot
.setPassword("banan")
.assertPassword("banan")
.clickOnLogin()
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
robot.assertErrorIsShown(R.string.username_is_invalid)
.assertNotLoading()
navigationRobot.assertAuthScreen()
}
/** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */
@Test
fun invalidCredentialsGivenShowsProperErrorMessage() {
mockServerScenarioSetup.setScenario(
AuthScenario.InvalidCredentials(password = "alma", username = "banan")
)
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.assertAuthScreen()
robot.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
composeTestRule.mainClock.autoAdvance = false
robot.clickOnLogin()
composeTestRule.mainClock.advanceTimeByFrame()
robot.assertLoading()
composeTestRule.mainClock.autoAdvance = true
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
robot.assertErrorIsShown(R.string.credentials_invalid)
.assertNotLoading()
navigationRobot.assertAuthScreen()
}
/** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */
@Test
fun networkErrorShowsProperErrorMessage() {
mockServerScenarioSetup.setScenario(
AuthScenario.GenericError(username = "alma", password = "banan")
)
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.assertAuthScreen()
robot.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
composeTestRule.mainClock.autoAdvance = false
robot.clickOnLogin()
composeTestRule.mainClock.advanceTimeByFrame()
robot.assertLoading()
composeTestRule.mainClock.autoAdvance = true
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
robot.assertErrorIsShown(R.string.something_went_wrong)
.assertNotLoading()
navigationRobot.assertAuthScreen()
}
/** GIVEN username and password WHEN restoring THEN username and password fields contain the same text */
@Test
fun restoringContentShowPreviousCredentials() {
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.assertAuthScreen()
robot.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
stateRestorationTester.emulateSavedInstanceStateRestore()
navigationRobot.assertAuthScreen()
robot.assertUsername("alma")
.assertPassword("banan")
}
}

View file

@ -0,0 +1,52 @@
package org.fnives.test.showcase.ui
import android.content.Context
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import org.fnives.test.showcase.compose.screen.auth.AuthScreenTag
class ComposeLoginRobot(
composeTestRule: ComposeTestRule,
) : ComposeTestRule by composeTestRule {
fun setUsername(username: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username)
}
fun setPassword(password: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password)
}
fun assertPassword(password: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick()
onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password)
}
fun assertUsername(username: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username)
}
fun clickOnLogin(): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoginButton).performClick()
}
fun assertLoading(): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed()
}
fun assertNotLoading(): ComposeLoginRobot = apply {
onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0)
}
fun assertErrorIsShown(stringId: Int): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoginError)
.assertTextContains(ApplicationProvider.getApplicationContext<Context>().resources.getString(stringId))
}
}

View file

@ -0,0 +1,18 @@
package org.fnives.test.showcase.ui
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithTag
import org.fnives.test.showcase.compose.screen.AppNavigationTag
class ComposeNavigationRobot(
private val composeTestRule: ComposeTestRule,
) {
fun assertHomeScreen(): ComposeNavigationRobot = apply {
composeTestRule.onNodeWithTag(AppNavigationTag.HomeScreen).assertExists()
}
fun assertAuthScreen(): ComposeNavigationRobot = apply {
composeTestRule.onNodeWithTag(AppNavigationTag.AuthScreen).assertExists()
}
}

View file

@ -25,6 +25,16 @@
</activity>
<activity android:name=".ui.home.MainActivity" />
<activity android:name=".ui.auth.AuthActivity" />
<activity
android:name="org.fnives.test.showcase.compose.ComposeActivity"
android:configChanges="colorMode|density|fontScale|fontWeightAdjustment|keyboard|keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,28 @@
package org.fnives.test.showcase.compose
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import com.google.accompanist.insets.ProvideWindowInsets
import org.fnives.test.showcase.compose.screen.AppNavigation
class ComposeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TestShowCaseApp()
}
}
}
@Composable
fun TestShowCaseApp() {
ProvideWindowInsets {
MaterialTheme {
AppNavigation()
}
}
}

View file

@ -0,0 +1,58 @@
package org.fnives.test.showcase.compose.screen
import androidx.compose.foundation.background
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.delay
import org.fnives.test.showcase.compose.screen.auth.AuthScreen
import org.fnives.test.showcase.compose.screen.auth.rememberAuthScreenState
import org.fnives.test.showcase.compose.screen.home.HomeScreen
import org.fnives.test.showcase.compose.screen.home.rememberHomeScreenState
import org.fnives.test.showcase.compose.screen.splash.SplashScreen
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.koin.androidx.compose.get
@Composable
fun AppNavigation(isUserLogeInUseCase: IsUserLoggedInUseCase = get()) {
val navController = rememberNavController()
LaunchedEffect(isUserLogeInUseCase) {
delay(500)
navController.navigate(if (isUserLogeInUseCase.invoke()) "Home" else "Auth")
}
NavHost(
navController,
startDestination = "Splash",
modifier = Modifier.background(MaterialTheme.colors.surface)
) {
composable("Splash") { SplashScreen() }
composable("Auth") {
AuthScreen(
modifier = Modifier.testTag(AppNavigationTag.AuthScreen),
authScreenState = rememberAuthScreenState(
onLoginSuccess = { navController.navigate("Home") }
)
)
}
composable("Home") {
HomeScreen(
modifier = Modifier.testTag(AppNavigationTag.HomeScreen),
homeScreenState = rememberHomeScreenState(
onLogout = { navController.navigate("Auth") }
)
)
}
}
}
object AppNavigationTag {
const val AuthScreen = "AppNavigationTag.AuthScreen"
const val HomeScreen = "AppNavigationTag.HomeScreen"
}

View file

@ -0,0 +1,211 @@
package org.fnives.test.showcase.compose.screen.auth
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Snackbar
import androidx.compose.material.SnackbarHost
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import com.google.accompanist.insets.statusBarsPadding
import org.fnives.test.showcase.R
@Composable
fun AuthScreen(
modifier: Modifier = Modifier,
authScreenState: AuthScreenState = rememberAuthScreenState()
) {
ConstraintLayout(modifier.fillMaxSize()) {
val (title, credentials, snackbar, loading, login) = createRefs()
Title(
modifier = Modifier
.statusBarsPadding()
.constrainAs(title) { top.linkTo(parent.top) }
)
CredentialsFields(
authScreenState = authScreenState,
modifier = Modifier.constrainAs(credentials) {
top.linkTo(title.bottom)
bottom.linkTo(login.top)
}
)
Snackbar(
authScreenState = authScreenState,
modifier = Modifier.constrainAs(snackbar) {
bottom.linkTo(login.top)
}
)
if (authScreenState.loading) {
CircularProgressIndicator(
Modifier
.testTag(AuthScreenTag.LoadingIndicator)
.constrainAs(loading) {
bottom.linkTo(login.top)
centerHorizontallyTo(parent)
}
)
}
LoginButton(
modifier = Modifier
.constrainAs(login) { bottom.linkTo(parent.bottom) }
.padding(16.dp),
onClick = { authScreenState.onLogin() }
)
}
}
@Composable
private fun CredentialsFields(authScreenState: AuthScreenState, modifier: Modifier = Modifier) {
Column(
modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
UsernameField(authScreenState)
PasswordField(authScreenState)
}
}
@OptIn(ExperimentalComposeUiApi::class, androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi::class)
@Composable
private fun PasswordField(authScreenState: AuthScreenState) {
var passwordVisible by remember { mutableStateOf(false) }
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
value = authScreenState.password,
label = { Text(text = stringResource(id = R.string.password)) },
placeholder = { Text(text = stringResource(id = R.string.password)) },
trailingIcon = {
val image = AnimatedImageVector.animatedVectorResource(R.drawable.show_password)
Icon(
painter = rememberAnimatedVectorPainter(image, passwordVisible),
contentDescription = null,
modifier = Modifier
.clickable { passwordVisible = !passwordVisible }
.testTag(AuthScreenTag.PasswordVisibilityToggle)
)
},
onValueChange = { authScreenState.onPasswordChanged(it) },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Password
),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
authScreenState.onLogin()
}),
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.testTag(AuthScreenTag.PasswordInput)
)
}
@Composable
private fun UsernameField(authScreenState: AuthScreenState) {
OutlinedTextField(
value = authScreenState.username,
label = { Text(text = stringResource(id = R.string.username)) },
placeholder = { Text(text = stringResource(id = R.string.username)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
onValueChange = { authScreenState.onUsernameChanged(it) },
modifier = Modifier
.fillMaxWidth()
.testTag(AuthScreenTag.UsernameInput)
)
}
@Composable
private fun Snackbar(authScreenState: AuthScreenState, modifier: Modifier = Modifier) {
val snackbarState = remember { SnackbarHostState() }
val error = authScreenState.error
LaunchedEffect(error) {
if (error != null) {
snackbarState.showSnackbar(error.name)
authScreenState.dismissError()
}
}
SnackbarHost(hostState = snackbarState, modifier) {
val stringId = error?.stringResId()
if (stringId != null) {
Snackbar(modifier = Modifier.padding(horizontal = 16.dp)) {
Text(text = stringResource(stringId), Modifier.testTag(AuthScreenTag.LoginError))
}
}
}
}
@Composable
private fun LoginButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
Box(modifier) {
Button(
onClick = onClick,
Modifier
.fillMaxWidth()
.testTag(AuthScreenTag.LoginButton)
) {
Text(text = "Login")
}
}
}
@Composable
private fun Title(modifier: Modifier = Modifier) {
Text(
stringResource(id = R.string.login_title),
modifier = modifier.padding(16.dp),
style = MaterialTheme.typography.h4
)
}
private fun AuthScreenState.ErrorType.stringResId() = when (this) {
AuthScreenState.ErrorType.INVALID_CREDENTIALS -> R.string.credentials_invalid
AuthScreenState.ErrorType.GENERAL_NETWORK_ERROR -> R.string.something_went_wrong
AuthScreenState.ErrorType.UNSUPPORTED_USERNAME -> R.string.username_is_invalid
AuthScreenState.ErrorType.UNSUPPORTED_PASSWORD -> R.string.password_is_invalid
}
object AuthScreenTag {
const val UsernameInput = "AuthScreenTag.UsernameInput"
const val PasswordInput = "AuthScreenTag.PasswordInput"
const val LoadingIndicator = "AuthScreenTag.LoadingIndicator"
const val LoginButton = "AuthScreenTag.LoginButton"
const val LoginError = "AuthScreenTag.LoginError"
const val PasswordVisibilityToggle = "AuthScreenTag.PasswordVisibilityToggle"
}

View file

@ -0,0 +1,110 @@
package org.fnives.test.showcase.compose.screen.auth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.fnives.test.showcase.core.login.LoginUseCase
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.model.auth.LoginStatus
import org.fnives.test.showcase.model.shared.Answer
import org.koin.androidx.compose.get
@Composable
fun rememberAuthScreenState(
stateScope: CoroutineScope = rememberCoroutineScope { Dispatchers.Main },
loginUseCase: LoginUseCase = get(),
onLoginSuccess: () -> Unit = {},
): AuthScreenState {
return rememberSaveable(saver = AuthScreenState.getSaver(stateScope, loginUseCase, onLoginSuccess)) {
AuthScreenState(stateScope, loginUseCase, onLoginSuccess)
}
}
class AuthScreenState(
private val stateScope: CoroutineScope,
private val loginUseCase: LoginUseCase,
private val onLoginSuccess: () -> Unit = {},
) {
var username by mutableStateOf("")
private set
var password by mutableStateOf("")
private set
var loading by mutableStateOf(false)
private set
var error by mutableStateOf<ErrorType?>(null)
private set
fun onUsernameChanged(username: String) {
this.username = username
}
fun onPasswordChanged(password: String) {
this.password = password
}
fun onLogin() {
if (loading) {
return
}
loading = true
stateScope.launch {
val credentials = LoginCredentials(
username = username,
password = password
)
when (val response = loginUseCase.invoke(credentials)) {
is Answer.Error -> error = ErrorType.GENERAL_NETWORK_ERROR
is Answer.Success -> processLoginStatus(response.data)
}
loading = false
}
}
private fun processLoginStatus(loginStatus: LoginStatus) {
when (loginStatus) {
LoginStatus.SUCCESS -> onLoginSuccess()
LoginStatus.INVALID_CREDENTIALS -> error = ErrorType.INVALID_CREDENTIALS
LoginStatus.INVALID_USERNAME -> error = ErrorType.UNSUPPORTED_USERNAME
LoginStatus.INVALID_PASSWORD -> error = ErrorType.UNSUPPORTED_PASSWORD
}
}
fun dismissError() {
error = null
}
enum class ErrorType {
INVALID_CREDENTIALS,
GENERAL_NETWORK_ERROR,
UNSUPPORTED_USERNAME,
UNSUPPORTED_PASSWORD
}
companion object {
private const val USERNAME = "USERNAME"
private const val PASSWORD = "PASSWORD"
fun getSaver(
stateScope: CoroutineScope,
loginUseCase: LoginUseCase,
onLoginSuccess: () -> Unit,
): Saver<AuthScreenState, *> = mapSaver(
save = { mapOf(USERNAME to it.username, PASSWORD to it.password) },
restore = {
AuthScreenState(stateScope, loginUseCase, onLoginSuccess).apply {
onUsernameChanged(it.getOrElse(USERNAME) { "" } as String)
onPasswordChanged(it.getOrElse(PASSWORD) { "" } as String)
}
}
)
}
}

View file

@ -0,0 +1,125 @@
package org.fnives.test.showcase.compose.screen.home
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import org.fnives.test.showcase.R
import org.fnives.test.showcase.model.content.FavouriteContent
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
homeScreenState: HomeScreenState = rememberHomeScreenState()
) {
Column(modifier.fillMaxSize()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Title(Modifier.weight(1f))
Image(
painter = painterResource(id = R.drawable.logout_24),
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colors.primary),
modifier = Modifier
.padding(16.dp)
.clickable { homeScreenState.onLogout() }
)
}
Box {
if (homeScreenState.isError) {
ErrorText(Modifier.align(Alignment.Center))
}
SwipeRefresh(
state = rememberSwipeRefreshState(isRefreshing = homeScreenState.loading),
onRefresh = {
homeScreenState.onRefresh()
}
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(homeScreenState.content) { item ->
Item(
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
favouriteContent = item,
onFavouriteToggle = { homeScreenState.onFavouriteToggleClicked(item.content.id) }
)
}
}
}
}
}
}
@Composable
private fun Item(
modifier: Modifier = Modifier,
favouriteContent: FavouriteContent,
onFavouriteToggle: () -> Unit,
) {
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Image(
painter = rememberImagePainter(favouriteContent.content.imageUrl.url),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.height(120.dp)
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
)
Column(
Modifier
.weight(1f)
.padding(horizontal = 16.dp)
) {
Text(text = favouriteContent.content.title)
Text(text = favouriteContent.content.description)
}
val favouriteIcon = if (favouriteContent.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24
Image(
painter = painterResource(id = favouriteIcon),
contentDescription = null,
Modifier.clickable { onFavouriteToggle() }
)
}
}
@Composable
private fun Title(modifier: Modifier = Modifier) {
Text(
stringResource(id = R.string.login_title),
modifier = modifier.padding(16.dp),
style = MaterialTheme.typography.h4
)
}
@Composable
private fun ErrorText(modifier: Modifier = Modifier) {
Text(
stringResource(id = R.string.something_went_wrong),
modifier = modifier.padding(16.dp),
style = MaterialTheme.typography.h4,
textAlign = TextAlign.Center
)
}

View file

@ -0,0 +1,118 @@
package org.fnives.test.showcase.compose.screen.home
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase
import org.fnives.test.showcase.core.content.FetchContentUseCase
import org.fnives.test.showcase.core.content.GetAllContentUseCase
import org.fnives.test.showcase.core.content.RemoveContentFromFavouritesUseCase
import org.fnives.test.showcase.core.login.LogoutUseCase
import org.fnives.test.showcase.model.content.ContentId
import org.fnives.test.showcase.model.content.FavouriteContent
import org.fnives.test.showcase.model.shared.Resource
import org.koin.androidx.compose.get
@Suppress("LongParameterList")
@Composable
fun rememberHomeScreenState(
stateScope: CoroutineScope = rememberCoroutineScope(),
getAllContentUseCase: GetAllContentUseCase = get(),
logoutUseCase: LogoutUseCase = get(),
fetchContentUseCase: FetchContentUseCase = get(),
addContentToFavouriteUseCase: AddContentToFavouriteUseCase = get(),
removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase = get(),
onLogout: () -> Unit = {},
): HomeScreenState {
return remember {
HomeScreenState(
stateScope,
getAllContentUseCase,
logoutUseCase,
fetchContentUseCase,
addContentToFavouriteUseCase,
removeContentFromFavouritesUseCase,
onLogout,
)
}
}
@Suppress("LongParameterList")
class HomeScreenState(
private val stateScope: CoroutineScope,
private val getAllContentUseCase: GetAllContentUseCase,
private val logoutUseCase: LogoutUseCase,
private val fetchContentUseCase: FetchContentUseCase,
private val addContentToFavouriteUseCase: AddContentToFavouriteUseCase,
private val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase,
private val logoutEvent: () -> Unit,
) {
var loading by mutableStateOf(false)
private set
var isError by mutableStateOf(false)
private set
var content by mutableStateOf<List<FavouriteContent>>(emptyList())
private set
init {
stateScope.launch {
fetch().collect {
content = it
}
}
}
private fun fetch() = getAllContentUseCase.get()
.mapNotNull {
when (it) {
is Resource.Error -> {
isError = true
loading = false
return@mapNotNull emptyList<FavouriteContent>()
}
is Resource.Loading -> {
isError = false
loading = true
return@mapNotNull null
}
is Resource.Success -> {
isError = false
loading = false
return@mapNotNull it.data
}
}
}
fun onLogout() {
stateScope.launch {
logoutUseCase.invoke()
logoutEvent()
}
}
fun onRefresh() {
if (loading) return
loading = true
stateScope.launch {
fetchContentUseCase.invoke()
}
}
fun onFavouriteToggleClicked(contentId: ContentId) {
stateScope.launch {
val item = content.firstOrNull { it.content.id == contentId } ?: return@launch
if (item.isFavourite) {
removeContentFromFavouritesUseCase.invoke(contentId)
} else {
addContentToFavouriteUseCase.invoke(contentId)
}
}
}
}

View file

@ -0,0 +1,25 @@
package org.fnives.test.showcase.compose.screen.splash
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import org.fnives.test.showcase.R
@Composable
fun SplashScreen() {
Box(Modifier.fillMaxSize().background(colorResource(R.color.purple_700)), contentAlignment = Alignment.Center) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.size(120.dp)
)
}
}

View file

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2016 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="NewApi">
<aapt:attr name="android:drawable">
<vector
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp">
<path
android:name="strike_through"
android:pathData="@string/path_password_strike_through"
android:strokeColor="@android:color/white"
android:strokeLineCap="square"
android:strokeWidth="1.8"
tools:ignore="PrivateResource" />
<group>
<clip-path
android:name="eye_mask"
android:pathData="@string/path_password_eye_mask_strike_through"
tools:ignore="PrivateResource" />
<path
android:fillColor="@android:color/white"
android:name="eye"
android:pathData="@string/path_password_eye"
tools:ignore="PrivateResource" />
</group>
</vector>
</aapt:attr>
<target android:name="eye_mask">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="@integer/show_password_duration"
android:interpolator="@android:interpolator/fast_out_linear_in"
android:propertyName="pathData"
android:valueFrom="@string/path_password_eye_mask_strike_through"
android:valueTo="@string/path_password_eye_mask_visible"
android:valueType="pathType"
tools:ignore="PrivateResource" />
</aapt:attr>
</target>
<target android:name="strike_through">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="@integer/show_password_duration"
android:interpolator="@android:interpolator/fast_out_linear_in"
android:propertyName="trimPathEnd"
android:valueFrom="1"
android:valueTo="0"
tools:ignore="PrivateResource" />
</aapt:attr>
</target>
</animated-vector>

View file

@ -17,7 +17,7 @@ import org.koin.test.KoinTest
*/
class MockServerScenarioSetupResetingTestRule(
private val reloadKoinModulesIfNecessaryTestRule: ReloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule(),
private val networkSynchronizationTestRule: NetworkSynchronizationTestRule = NetworkSynchronizationTestRule()
private val networkSynchronizationTestRule: TestRule = NetworkSynchronizationTestRule()
) : TestRule, KoinTest {
lateinit var mockServerScenarioSetup: MockServerScenarioSetup

View file

@ -0,0 +1,50 @@
package org.fnives.test.showcase.testutils.idling
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import org.fnives.test.showcase.storage.database.DatabaseInitialization
import org.fnives.test.showcase.testutils.runOnUIAwaitOnCurrent
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
@OptIn(ExperimentalCoroutinesApi::class)
class DispatcherTestRule : TestRule {
private lateinit var testDispatcher: TestDispatcher
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
val dispatcher = StandardTestDispatcher()
testDispatcher = dispatcher
DatabaseInitialization.dispatcher = dispatcher
base.evaluate()
}
}
fun advanceUntilIdleWithIdlingResources() = runOnUIAwaitOnCurrent {
testDispatcher.advanceUntilIdleWithIdlingResources()
}
fun advanceUntilIdle() = runOnUIAwaitOnCurrent {
testDispatcher.scheduler.advanceUntilIdle()
}
fun advanceTimeBy(delayInMillis: Long) = runOnUIAwaitOnCurrent {
testDispatcher.scheduler.advanceTimeBy(delayInMillis)
}
companion object {
fun TestDispatcher.advanceUntilIdleWithIdlingResources() {
scheduler.advanceUntilIdle() // advance until a request is sent
while (anyResourceIdling()) { // check if any request is in progress
awaitIdlingResources() // complete all requests and other idling resources
scheduler.advanceUntilIdle() // run coroutines after request is finished
}
scheduler.advanceUntilIdle()
}
}
}

View file

@ -0,0 +1,357 @@
## Login UI Test with Compose
This section is equivalent to the one with ["Login UI Test"](../robolectric.instructionset.md#login-ui-test) from robolectric.instructionset.md.
Make sure to read that one first as this one only focuses on the differences that Compose brings.
We will write the same tests from `AuthActivityInstrumentedTest` so that we see clearly the differences between the two.
### Robot Pattern
We will apply the same Robot Pattern, since the concept applies exactly the same.
The only thing that changes is the implementation details of the Robot class.
Here is a list of actions we want to do:
- we want to be able to type in the username
- we want to be able to type in the password
- we want to be able the username or password is indeed shows on the UI
- we want to be able to click on signin
- we want to be able verify if we are loading or not
- we want to verify if an error is shown or not
- we want to check if we navigated to Main or not
##### So here is the code for our the UI interactions
```kotlin
class ComposeLoginRobot(
composeTestRule: ComposeTestRule,
) : ComposeTestRule by composeTestRule {
fun setUsername(username: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username)
}
fun setPassword(password: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password)
}
fun assertPassword(password: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick()
onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password)
}
fun assertUsername(username: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username)
}
fun clickOnLogin(): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoginButton).performClick()
}
fun assertLoading(): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed()
}
fun assertNotLoading(): ComposeLoginRobot = apply {
onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0)
}
fun assertErrorIsShown(stringId: Int): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoginError)
.assertTextContains(ApplicationProvider.getApplicationContext<Context>().resources.getString(stringId))
}
}
```
While in the View system we're using Espresso to interact with views,
in Compose we need a reference to the `ComposeTestRule` that contains our UI,
which we will pass as a constructor parameter to the robot.
To create a `ComposeTestRule` you simply need to:
```kotlin
@get:Rule
val composeTestRule = createComposeRule()
```
> Note: You need to add a debug dependency for the rule: `debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")`
Since we don't have view ids in Compose we need to search composables by tags, using for example `onNodeWithTag` finder.
To add a tag to a composable use the `testTag` modifier in your UI, for example:
```kotlin
Modifier.testTag(AuthScreenTag.UsernameInput)
```
Once we have a node we can take actions such as `performClick()` or check assertions such as `assertTextContains`.
For a list of finder, actions and assertions see the docs: https://developer.android.com/jetpack/compose/testing#testing-apis
##### Next up, we need to verify if we navigated:
If the navigation is also in compose we don't have an intent to check if we navigated.
So instead, we're simply searching for regular composables that represent our destinations.
This means that we could write a robot for our navigation which will simply check whether the root Composable for destination exists:
```kotlin
fun assertHomeScreen(): ComposeNavigationRobot = apply {
composeTestRule.onNodeWithTag(AppNavigationTag.HomeScreen).assertExists()
}
fun assertAuthScreen(): ComposeNavigationRobot = apply {
composeTestRule.onNodeWithTag(AppNavigationTag.AuthScreen).assertExists()
}
```
##### What about the Snackbar
Since everything in Compose is a composable, our Snackbar doesn't have anything special.
Put a tag on it and use the same finders and assertions.
#### Test class setup
The setup is the mostly the same as for View so for the sake of simplicity let's focus on the differences.
##### Initializing the UI
We don't need an activity scenario. We will use instead `createComposeRule()` which will handle the host activity.
If you need a specific activity, use `createAndroidComposeRule<YourActivity>()`.
```kotlin
@get:Rule
val composeTestRule = createComposeRule()
@Before
fun setup() {
composeTestRule.setContent {
AppNavigation(isUserLogeInUseCase = IsUserLoggedInUseCase(FakeUserDataLocalStorage()))
}
// ...
}
```
In `setContent` we can have any composable no matter how "small" or "big", it could be a single button or the whole app.
Here we are setting AppNavigation as the content, since the tests will be integration tests which will check navigation events.
Notice that we are injecting a fake local storage to control the logged in state.
##### The Robot
For the robot we will use the compose implementation of it.
```kotlin
private lateinit var robot: ComposeLoginRobot
private lateinit var navigationRobot: ComposeNavigationRobot
@Before
fun setup() {
// ...
robot = ComposeLoginRobot(composeTestRule)
navigationRobot = ComposeNavigationRobot(composeTestRule)
}
```
##### Networking and Coroutines
Network synchronization and mocking is the same as for View.
```kotlin
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule()
private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
```
Coroutine setup is the same, except for `Dispatchers.setMain(dispatcher)`, which we don't need.
```kotlin
private val dispatcherTestRule = DispatcherTestRule()
```
Setting the rules:
```kotlin
@Rule
@JvmField
val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule)
.around(dispatcherTestRule)
```
### 1. `properLoginResultsInNavigationToHome`
With this setup our test should be pretty simple.
First we mock our request:
```kotlin
mockServerScenarioSetup.setScenario(
AuthScenario.Success(password = "alma", username = "banan")
)
```
Then we wait for the idling resources, more precisely for the app to navigate us correctly to AuthScreen since we're not logged in:
```kotlin
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
```
> Note: Considering what the docs say this shouldn't be necessarily if the idling resources are setup in Espresso, since the compose test rule is aware of espresso and it waits for idle before every finder. In practice it only works with the line above. Could be a bug somewhere.
We assert that we are indeed on the correct screen
```kotlin
navigationRobot.assertAuthScreen()
```
We insert the credentials into the input field:
```kotlin
robot.setPassword("alma")
.setUsername("banan")
.assertUsername("banan")
.assertPassword("alma")
```
Now thing are getting a little tricky. We want to click on login and assert that loading is displayed before navigating away.
The problem is that, by the time the robot will look for the loading indicator, the app would have already be at the home screen.
To slow things down we will disable clock autoAdvancing:
```kotlin
composeTestRule.mainClock.autoAdvance = false // Stop the clock
robot.clickOnLogin() // Click the button
composeTestRule.mainClock.advanceTimeByFrame() // Advance the clock by one frame
robot.assertLoading() // Assert the loading
composeTestRule.mainClock.autoAdvance = true // Let clock auto advance again
```
Lastly we check the navigation was correct, meaning we should be on the home screen:
```kotlin
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() } // wait for login network call
navigationRobot.assertHomeScreen()
```
### 2. `emptyPasswordShowsProperErrorMessage`
Next up we verify what happens if the user doesn't set their password. We don't need a request in this case.
First we check that we are in the write place:
```kotlin
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.assertAuthScreen()
```
Then we set the username:
```kotlin
robot.setUsername("banan")
.assertUsername("banan")
.clickOnLogin()
```
Finally we let coroutines go and verify the error is shown and we have not navigated:
```kotlin
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
robot.assertErrorIsShown(R.string.password_is_invalid)
.assertNotLoading()
navigationRobot.assertAuthScreen()
```
### 3. `emptyUserNameShowsProperErrorMessage`
This will be really similar as the previous test, so try to do it on your own. The error is `R.string.username_is_invalid`
Still, here is the complete code:
```kotlin
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.assertAuthScreen()
robot
.setPassword("banan")
.assertPassword("banan")
.clickOnLogin()
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
robot.assertErrorIsShown(R.string.username_is_invalid)
.assertNotLoading()
navigationRobot.assertAuthScreen()
```
### 4. `invalidCredentialsGivenShowsProperErrorMessage`
Now we verify network errors. First let's setup the response:
```kotlin
mockServerScenarioSetup.setScenario(
AuthScenario.InvalidCredentials(username = "alma", password = "banan")
)
```
Now input the credentials and fire the event:
```kotlin
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.assertAuthScreen()
robot.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
composeTestRule.mainClock.autoAdvance = false
robot.clickOnLogin()
composeTestRule.mainClock.advanceTimeByFrame()
robot.assertLoading()
composeTestRule.mainClock.autoAdvance = true
```
Now at the end verify the error is shown properly:
```kotlin
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
robot.assertErrorIsShown(R.string.credentials_invalid)
.assertNotLoading()
navigationRobot.assertAuthScreen()
```
### 5. `networkErrorShowsProperErrorMessage`
Finally we verify the `AuthScenario.GenericError`. This will be really similar as the previous, except the error will be `R.string.something_went_wrong`.
You should try to do this on your own.
Here is the code for verification:
```kotlin
mockServerScenarioSetup.setScenario(
AuthScenario.GenericError(username = "alma", password = "banan")
)
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.assertAuthScreen()
robot.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
composeTestRule.mainClock.autoAdvance = false
robot.clickOnLogin()
composeTestRule.mainClock.advanceTimeByFrame()
robot.assertLoading()
composeTestRule.mainClock.autoAdvance = true
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
robot.assertErrorIsShown(R.string.something_went_wrong)
.assertNotLoading()
navigationRobot.assertAuthScreen()
```
### 6. `restoringContentShowPreviousCredentials`
Since we're writing apps for Android, we must handle state restoration so let's write a test for it.
For simulating the recreation of the UI, we first need a `StateRestorationTester`:
```kotlin
private val stateRestorationTester = StateRestorationTester(composeTestRule)
```
Then in `setup()`, we need to `setContent` on `stateRestorationTester` instead of on `composeTestRule`.
Now for the actual test, we first setup the content then we trigger restoration by calling `stateRestorationTester.emulateSavedInstanceStateRestore()`, afterwards we can verify that the content is recreated in the correct way:
```kotlin
composeTestRule.mainClock.advanceTimeUntil { anyResourceIdling() }
navigationRobot.assertAuthScreen()
robot.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
stateRestorationTester.emulateSavedInstanceStateRestore()
navigationRobot.assertAuthScreen()
robot.assertUsername("alma")
.assertPassword("banan")
```

View file

@ -7,11 +7,16 @@ project.ext {
androidx_swiperefreshlayout_version = "1.1.0"
androidx_room_version = "2.4.1"
activity_ktx_version = "1.4.0"
androidx_navigation = "2.4.0"
androidx_compose_version = "1.1.0"
google_accompanist_version = "0.23.1"
androidx_compose_constraintlayout_version = "1.0.0"
coroutines_version = "1.6.0"
turbine_version = "0.7.0"
koin_version = "3.1.2"
coil_version = "1.1.1"
coil_version = "1.4.0"
retrofit_version = "2.9.0"
okhttp_version = "4.9.3"
moshi_version = "1.13.0"