commit
1abd50468f
18 changed files with 1458 additions and 2 deletions
|
|
@ -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.
|
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)
|
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.
|
#### Robolectric and Android Tests.
|
||||||
Open the [shared tests instruction set](./codekata/sharedtests.instructionset.md).
|
Open the [shared tests instruction set](./codekata/sharedtests.instructionset.md).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ android {
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
viewBinding true
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = project.androidx_compose_version
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
|
|
@ -68,18 +72,32 @@ dependencies {
|
||||||
implementation "androidx.appcompat:appcompat:$androidx_appcompat_version"
|
implementation "androidx.appcompat:appcompat:$androidx_appcompat_version"
|
||||||
implementation "com.google.android.material:material:$androidx_material_version"
|
implementation "com.google.android.material:material:$androidx_material_version"
|
||||||
implementation "androidx.constraintlayout:constraintlayout:$androidx_constraintlayout_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-core-ktx:$androidx_livedata_version"
|
||||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidx_livedata_version"
|
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidx_livedata_version"
|
||||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$androidx_livedata_version"
|
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$androidx_livedata_version"
|
||||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_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-android:$koin_version"
|
||||||
|
implementation "io.insert-koin:koin-androidx-compose:$koin_version"
|
||||||
|
|
||||||
implementation "androidx.room:room-runtime:$androidx_room_version"
|
implementation "androidx.room:room-runtime:$androidx_room_version"
|
||||||
kapt "androidx.room:room-compiler:$androidx_room_version"
|
kapt "androidx.room:room-compiler:$androidx_room_version"
|
||||||
implementation "androidx.room:room-ktx:$androidx_room_version"
|
implementation "androidx.room:room-ktx:$androidx_room_version"
|
||||||
|
|
||||||
implementation "io.coil-kt:coil:$coil_version"
|
implementation "io.coil-kt:coil:$coil_version"
|
||||||
|
implementation "io.coil-kt:coil-compose:$coil_version"
|
||||||
|
|
||||||
implementation project(":core")
|
implementation project(":core")
|
||||||
|
|
||||||
|
|
@ -115,6 +133,9 @@ dependencies {
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$testing_espresso_version"
|
androidTestImplementation "androidx.test.espresso:espresso-core:$testing_espresso_version"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-intents:$testing_espresso_version"
|
androidTestImplementation "androidx.test.espresso:espresso-intents:$testing_espresso_version"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-contrib:$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 project(':mockserver')
|
||||||
androidTestImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version"
|
androidTestImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version"
|
||||||
androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version"
|
androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,16 @@
|
||||||
</activity>
|
</activity>
|
||||||
<activity android:name=".ui.home.MainActivity" />
|
<activity android:name=".ui.home.MainActivity" />
|
||||||
<activity android:name=".ui.auth.AuthActivity" />
|
<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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/src/main/res/drawable/show_password.xml
Normal file
92
app/src/main/res/drawable/show_password.xml
Normal 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>
|
||||||
|
|
@ -17,7 +17,7 @@ import org.koin.test.KoinTest
|
||||||
*/
|
*/
|
||||||
class MockServerScenarioSetupResetingTestRule(
|
class MockServerScenarioSetupResetingTestRule(
|
||||||
private val reloadKoinModulesIfNecessaryTestRule: ReloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule(),
|
private val reloadKoinModulesIfNecessaryTestRule: ReloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule(),
|
||||||
private val networkSynchronizationTestRule: NetworkSynchronizationTestRule = NetworkSynchronizationTestRule()
|
private val networkSynchronizationTestRule: TestRule = NetworkSynchronizationTestRule()
|
||||||
) : TestRule, KoinTest {
|
) : TestRule, KoinTest {
|
||||||
|
|
||||||
lateinit var mockServerScenarioSetup: MockServerScenarioSetup
|
lateinit var mockServerScenarioSetup: MockServerScenarioSetup
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
357
codekata/compose.instructionset.md
Normal file
357
codekata/compose.instructionset.md
Normal 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")
|
||||||
|
```
|
||||||
|
|
@ -7,11 +7,16 @@ project.ext {
|
||||||
androidx_swiperefreshlayout_version = "1.1.0"
|
androidx_swiperefreshlayout_version = "1.1.0"
|
||||||
androidx_room_version = "2.4.1"
|
androidx_room_version = "2.4.1"
|
||||||
activity_ktx_version = "1.4.0"
|
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"
|
coroutines_version = "1.6.0"
|
||||||
turbine_version = "0.7.0"
|
turbine_version = "0.7.0"
|
||||||
koin_version = "3.1.2"
|
koin_version = "3.1.2"
|
||||||
coil_version = "1.1.1"
|
coil_version = "1.4.0"
|
||||||
retrofit_version = "2.9.0"
|
retrofit_version = "2.9.0"
|
||||||
okhttp_version = "4.9.3"
|
okhttp_version = "4.9.3"
|
||||||
moshi_version = "1.13.0"
|
moshi_version = "1.13.0"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue