Fixing UI tests

Change-Id: Iaa0ce3ae92e0c736aef5809c1e471c9cbff695a9
pull/2/head
Jolanda Verhoef 3 years ago
parent 53248e6dac
commit 1b06dc97ea

@ -36,7 +36,8 @@ android {
versionCode 1
versionName "0.0.1" // X.Y.Z; X = Major, Y = minor, Z = Patch level
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// Custom test runner to set up Hilt dependency graph
testInstrumentationRunner "com.google.samples.apps.nowinandroid.NiaTestRunner"
vectorDrawables {
useSupportLibrary true
}
@ -179,6 +180,8 @@ dependencies {
androidTestImplementation libs.androidx.test.runner
androidTestImplementation libs.androidx.test.rules
androidTestImplementation libs.androidx.compose.ui.test
androidTestImplementation libs.hilt.android.testing
kaptAndroidTest libs.hilt.compiler
// androidx.test is forcing JUnit, 4.12. This forces it to use 4.13
configurations.configureEach {

@ -0,0 +1,31 @@
/*
* Copyright 2022 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
/**
* A custom runner to set up the instrumented application class for tests.
*/
class NiaTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}

@ -0,0 +1,81 @@
/*
* Copyright 2022 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
*
* https://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.
*/
package com.google.samples.apps.nowinandroid.di
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import com.google.samples.apps.nowinandroid.data.UserPreferences
import com.google.samples.apps.nowinandroid.data.UserPreferencesSerializer
import com.google.samples.apps.nowinandroid.data.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.data.fake.FakeTopicsRepository
import com.google.samples.apps.nowinandroid.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.data.repository.TopicsRepository
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton
import kotlinx.serialization.json.Json
import org.junit.rules.TemporaryFolder
/**
* The [TestAppModule] replaces [AppModule] during instrumentation tests. It creates test doubles
* where necessary. It also includes logic to prevent multiple data stores with the same file name
* from being created during one test execution context.
*/
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [AppModule::class]
)
interface TestAppModule {
// Use a fake repository as a test double, so we don't have a network dependency.
@Binds
fun bindsTopicRepository(fakeTopicsRepository: FakeTopicsRepository): TopicsRepository
// Use a fake repository as a test double, so we don't have a network dependency.
@Binds
fun bindsNewsResourceRepository(fakeNewsRepository: FakeNewsRepository): NewsRepository
// Use the default dispatchers. For the high-level UI tests, we don't want to override these.
@Binds
fun bindsNiaDispatchers(defaultNiaDispatchers: DefaultNiaDispatchers): NiaDispatchers
companion object {
@Provides
@Singleton
fun providesUserPreferencesDataStore(
userPreferencesSerializer: UserPreferencesSerializer,
tmpFolder: TemporaryFolder
): DataStore<UserPreferences> {
return DataStoreFactory.create(
serializer = userPreferencesSerializer,
) {
tmpFolder.newFile("user_preferences_test.pb")
}
}
@Provides
@Singleton
fun providesNetworkJson(): Json = Json {
ignoreUnknownKeys = true
}
}
}

@ -16,155 +16,179 @@
package com.google.samples.apps.nowinandroid.ui
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.espresso.Espresso
import androidx.test.espresso.NoActivityResumedException
import com.google.samples.apps.nowinandroid.MainActivity
import com.google.samples.apps.nowinandroid.R
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
/**
* Tests all the navigation flows that are handled by the navigation library.
*/
@HiltAndroidTest
class NavigationTest {
/**
* Manages the components' state and is used to perform injection on your test
*/
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
/**
* Use the primary activity to initialize the app normally.
*
* TODO: Bind fakes as needed to the Dagger graph to allow for easier testing
*/
@get:Rule
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
/**
* Create a temporary folder used to create a Data Store file. This guarantees that
* the file is removed in between each test, preventing a crash.
*/
@BindValue @get:Rule(order = 2)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
// The strings used for matching in these tests
private lateinit var done: String
private lateinit var navigateUp: String
private lateinit var forYouLoading: String
private lateinit var forYou: String
private lateinit var episodes: String
private lateinit var saved: String
private lateinit var topics: String
@Before
fun setup() {
composeTestRule.activity.apply {
done = getString(R.string.done)
navigateUp = getString(R.string.navigate_up)
forYouLoading = getString(R.string.for_you_loading)
forYou = getString(R.string.for_you)
episodes = getString(R.string.episodes)
saved = getString(R.string.saved)
topics = getString(R.string.following)
}
}
@Test
fun firstScreenIsForYou() {
composeTestRule.forYouDestinationTopMatcher()
.assertExists("Could not find FOR YOU text on first screen after app open")
fun firstScreen_isForYou() {
composeTestRule.apply {
// WAIT for initial content to be shown
waitUntil { onAllNodes(hasText("HEADLINES")).fetchSemanticsNodes().isNotEmpty() }
// VERIFY first topic is displayed
onNodeWithText("HEADLINES").assertExists()
}
}
// TODO: implement tests related to navigation & resetting of destinations (b/213307564)
// Restoring content should be tested with another tab than the For You one, as that will
// still succeed even when restoring state is turned off.
/**
* As per guidelines:
*
* When you select a navigation bar item (one thats not currently selected), the app navigates
* to that destinations screen.
*
* Any prior user interactions and temporary screen states are reset, such as scroll position,
* tab selection, and inline search.
*
* This default behavior can be overridden when needed to improve the user experience. For
* example, an Android app that requires frequent switching between sections can preserve each
* sections state.
* When navigating between the different top level destinations, we should restore the state
* of previously visited destinations.
*/
// @Test
// fun navigateToUnselectedTabResetsContent1() {
// // GIVEN the user was previously on the Following destination
// composeTestRule.followingDestinationTopMatcher().performClick()
// // and scrolled down
// [IMPLEMENT] Match the root scrollable container and scroll down to an item below the fold
// composeTestRule.followingDestinationTopMatcher()
// .assertDoesNotExist() // verify we scrolled beyond the top
// // and then navigated back to the For You destination
// composeTestRule.forYouDestinationTopMatcher().performClick()
// // WHEN the user presses the Topic navigation bar item
// composeTestRule.followingDestinationTopMatcher().performClick()
// // THEN the Following destination shows at the top.
// composeTestRule.followingDestinationTopMatcher()
// .assertExists("Screen did not correctly reset to the top after re-navigating to it")
// }
// @Test
// fun navigateToUnselectedTabResetsContent2() {
// // GIVEN the user was previously on the Following destination
// composeTestRule.followingDestinationTopMatcher().performClick()
// // and navigated to the Topic detail destination
// [IMPLEMENT] Navigate to topic detail destination
// composeTestRule.followingDestinationTopMatcher()
// .assertDoesNotExist() // verify we are not on Following overview destination any more
// // and then navigated back to the For You destination
// composeTestRule.forYouDestinationTopMatcher().performClick()
// // WHEN the user presses the Topic navigation bar item
// composeTestRule.followingDestinationTopMatcher().performClick()
// // THEN the Following destination shows at the top.
// composeTestRule.followingDestinationTopMatcher()
// .assertExists("Screen did not correctly reset to the top after re-navigating to it")
// }
@Test
fun navigationBar_navigateToPreviouslySelectedTab_restoresContent() {
composeTestRule.apply {
// WAIT for initial content to be shown
waitUntil { onAllNodes(hasText("HEADLINES")).fetchSemanticsNodes().isNotEmpty() }
// GIVEN the user follows a topic
onNodeWithText("HEADLINES").performClick()
// WHEN the user navigates to the Topics destination
onNodeWithText(topics).performClick()
// AND the user navigates to the For You destination
onNodeWithText(forYou).performClick()
// THEN the state of the For You destination is restored
onNodeWithText("HEADLINES").assertIsOn()
}
}
// @Test
// fun reselectingTabResetsContent1() {
// // GIVEN the user is on the For You destination
// // and has scrolled down
// // WHEN the user taps the For You navigation bar item
// // THEN the For You destination shows at the top of the destination
// }
/**
* When reselecting a tab, it should show that tab's start destination and restore its state.
*/
@Test
fun navigationBar_reselectTab_keepsState() {
composeTestRule.apply {
// WAIT for initial content to be shown
waitUntil { onAllNodes(hasText("HEADLINES")).fetchSemanticsNodes().isNotEmpty() }
// GIVEN the user follows a topic
onNodeWithText("HEADLINES").performClick()
// WHEN the user taps the For You navigation bar item
onNodeWithText(forYou).performClick()
// THEN the state of the For You destination is restored
onNodeWithText("HEADLINES").assertIsOn()
}
}
// @Test
// fun reselectingTabResetsContent2() {
// // GIVEN the user is on the Following destination
// fun navigationBar_reselectTab_resetsToStartDestination() {
// // GIVEN the user is on the Topics destination and scrolls
// // and navigates to the Topic Detail destination
// // WHEN the user taps the Following navigation bar item
// // THEN the Following destination shows at the top of the destination
// // WHEN the user taps the Topics navigation bar item
// // THEN the Topics destination shows in the same scrolled state
// }
/*
* Top level destinations should never show an up affordance.
*/
@Test
fun topLevelDestinationsDoNotShowUpArrow() {
fun topLevelDestinations_doNotShowUpArrow() {
composeTestRule.apply {
// GIVEN the user is on any of the top level destinations, THEN the Up arrow is not shown.
composeTestRule.onNodeWithContentDescription("Navigate up").assertDoesNotExist()
composeTestRule.onNodeWithText("Episodes").performClick()
composeTestRule.onNodeWithContentDescription("Navigate up").assertDoesNotExist()
composeTestRule.onNodeWithText("Saved").performClick()
composeTestRule.onNodeWithContentDescription("Navigate up").assertDoesNotExist()
composeTestRule.onNodeWithText("Following").performClick()
composeTestRule.onNodeWithContentDescription("Navigate up").assertDoesNotExist()
onNodeWithContentDescription(navigateUp).assertDoesNotExist()
onNodeWithText(episodes).performClick()
onNodeWithContentDescription(navigateUp).assertDoesNotExist()
onNodeWithText(saved).performClick()
onNodeWithContentDescription(navigateUp).assertDoesNotExist()
onNodeWithText(topics).performClick()
onNodeWithContentDescription(navigateUp).assertDoesNotExist()
}
}
/*
* There should always be at most one instance of a top-level destination at the same time.
*/
@Test(expected = NoActivityResumedException::class)
fun backFromHomeDestinationQuitsApp() {
fun homeDestination_back_quitsApp() {
composeTestRule.apply {
// GIVEN the user navigates to the Episodes destination
composeTestRule.onNodeWithText("Episodes").performClick()
onNodeWithText(episodes).performClick()
// and then navigates to the For you destination
composeTestRule.onNodeWithText("For you").performClick()
onNodeWithText(forYou).performClick()
// WHEN the user uses the system button/gesture to go back
Espresso.pressBack()
// THEN the app quits
}
}
/*
* When pressing back from any top level destination except "For you", the app navigates back
* to the "For you" destination, no matter which destinations you visited in between.
*/
@Test
fun backFromDestinationReturnsToForYou() {
fun navigationBar_backFromAnyDestination_returnsToForYou() {
composeTestRule.apply {
// WAIT for initial content to be shown
waitUntil { onAllNodes(hasText("HEADLINES")).fetchSemanticsNodes().isNotEmpty() }
// GIVEN the user navigated to the Episodes destination
composeTestRule.onNodeWithText("Episodes").performClick()
// and then navigated to the Following destination
composeTestRule.onNodeWithText("Following").performClick()
onNodeWithText(episodes).performClick()
// and then navigated to the Topics destination
onNodeWithText(topics).performClick()
// WHEN the user uses the system button/gesture to go back,
Espresso.pressBack()
// THEN the app shows the For You destination
composeTestRule.forYouDestinationTopMatcher().assertExists()
onNodeWithText("HEADLINES").assertExists()
}
}
/*
* Matches an element at the top of the For You destination. Should be updated when the
* destination is implemented.
*/
private fun ComposeTestRule.forYouDestinationTopMatcher() = onNodeWithTag("FOR YOU")
/*
* Matches an element at the top of the Following destination. Should be updated when the
* destination is implemented.
*/
private fun ComposeTestRule.followingDestinationTopMatcher() = onNodeWithText("FOLLOWING")
}

@ -21,6 +21,8 @@ import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
@ -82,19 +84,19 @@ class ForYouScreenTest {
composeTestRule
.onNodeWithText("HEADLINES")
.assertIsDisplayed()
// .assertIsOff()
.assertIsOff()
.assertHasClickAction()
composeTestRule
.onNodeWithText("UI")
.assertIsDisplayed()
// .assertIsOff()
.assertIsOff()
.assertHasClickAction()
composeTestRule
.onNodeWithText("TOOLS")
.assertIsDisplayed()
// .assertIsOff()
.assertIsOff()
.assertHasClickAction()
composeTestRule
@ -136,19 +138,19 @@ class ForYouScreenTest {
composeTestRule
.onNodeWithText("HEADLINES")
.assertIsDisplayed()
// .assertIsOff()
.assertIsOff()
.assertHasClickAction()
composeTestRule
.onNodeWithText("UI")
.assertIsDisplayed()
// .assertIsOn()
.assertIsOn()
.assertHasClickAction()
composeTestRule
.onNodeWithText("TOOLS")
.assertIsDisplayed()
// .assertIsOff()
.assertIsOff()
.assertHasClickAction()
composeTestRule

@ -27,7 +27,7 @@ object FakeDataSource {
val sampleTopic = NetworkTopic(
id = 0,
name = "Headlines",
description = "",
description = "At vero eos et accusamus et iusto odio dignissimos ducimus qui.",
)
val sampleResource = NetworkNewsResource(
id = 1,

@ -25,9 +25,11 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.serialization.json.Json
/**
* [NewsRepository] implementation that provides static news resources to aid development
* Fake implementation of the [NewsRepository] that retrieves the news resources from a JSON String.
*
* This allows us to run the app with fake data, without needing an internet connection or working
* backend.
*/
class FakeNewsRepository @Inject constructor(
private val dispatchers: NiaDispatchers,
private val networkJson: Json

@ -28,6 +28,13 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
/**
* Fake implementation of the [TopicsRepository] that retrieves the topics from a JSON String, and
* uses a local DataStore instance to save and retrieve followed topic ids.
*
* This allows us to run the app with fake data, without needing an internet connection or working
* backend.
*/
class FakeTopicsRepository @Inject constructor(
private val dispatchers: NiaDispatchers,
private val networkJson: Json,

@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid.ui
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -45,7 +44,7 @@ fun NiaNavGraph(
startDestination = startDestination,
) {
composable(NiaDestinations.FOR_YOU_ROUTE) {
ForYouRoute(modifier = modifier.testTag("FOR YOU"))
ForYouRoute(modifier)
}
composable(NiaDestinations.EPISODES_ROUTE) {
Text("EPISODES", modifier)
@ -56,7 +55,7 @@ fun NiaNavGraph(
composable(NiaDestinations.FOLLOWING_ROUTE) {
FollowingRoute(
navigateToTopic = { navController.navigate(NiaDestinations.TOPIC_ROUTE) },
modifier = modifier.testTag(NiaDestinations.FOLLOWING_ROUTE),
modifier = modifier
)
}
composable(NiaDestinations.TOPIC_ROUTE) {

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.ui
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
/**
@ -37,8 +38,17 @@ object NiaDestinations {
class NiaNavigationActions(private val navController: NavHostController) {
fun navigateToTopLevelDestination(route: String) {
navController.navigate(route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
navController.graph.startDestinationRoute?.let { popUpTo(it) }
// Restore state when reselecting a previously selected item
restoreState = true
}
}
}

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -34,6 +35,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -117,7 +119,10 @@ private fun LazyListScope.TopicSelection(
ButtonDefaults.buttonColors()
} else {
ButtonDefaults.outlinedButtonColors()
}
},
modifier = Modifier.toggleable(
value = isSelected, role = Role.Button, onValueChange = {}
)
) {
Text(
text = topic.name.uppercase(),

@ -20,6 +20,7 @@
<string name="saved">Saved</string>
<string name="done">Done</string>
<string name="for_you_loading">Loading for you…</string>
<string name="navigate_up">Navigate up</string>
<!-- NewsResource Card -->
<string name="bookmark">Bookmark</string>

@ -58,7 +58,8 @@ androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espres
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTest" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTest" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
hilt-gradlePlugin = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" }
junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }

Loading…
Cancel
Save