Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

♻️ Enhanced ContributorsScreen testing. #503

Merged
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ public data class Contributor(
public companion object
}

public fun Contributor.Companion.fakes(): PersistentList<Contributor> = (0..20)
public fun Contributor.Companion.fakes(): PersistentList<Contributor> = (1..20)
.map {
Contributor(
id = it,
username = it.toString(),
username = "username $it",
profileUrl = "https://developer.android.com/",
iconUrl = "https://placehold.jp/150x150.png",
)
Expand Down
1 change: 1 addition & 0 deletions core/testing/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dependencies {
implementation(projects.feature.sponsors)
implementation(projects.feature.favorites)
implementation(projects.feature.eventmap)
implementation(projects.feature.contributors)
implementation(libs.daggerHiltAndroidTesting)
implementation(libs.roborazzi)
implementation(libs.kermit)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package io.github.droidkaigi.confsched.testing.robot

import androidx.compose.ui.test.assertContentDescriptionEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.performScrollToIndex
import io.github.droidkaigi.confsched.contributors.ContributorsItemTestTagPrefix
import io.github.droidkaigi.confsched.contributors.ContributorsScreen
import io.github.droidkaigi.confsched.contributors.ContributorsTestTag
import io.github.droidkaigi.confsched.contributors.component.ContributorsItemImageTestTagPrefix
import io.github.droidkaigi.confsched.contributors.component.ContributorsUserNameTextTestTagPrefix
import io.github.droidkaigi.confsched.model.Contributor
import io.github.droidkaigi.confsched.model.fakes
import io.github.droidkaigi.confsched.testing.utils.assertCountAtLeast
import io.github.droidkaigi.confsched.testing.utils.hasTestTag
import io.github.droidkaigi.confsched.ui.Inject

class ContributorsScreenRobot @Inject constructor(
screenRobot: DefaultScreenRobot,
contributorsServerRobot: DefaultContributorsServerRobot,
) : ScreenRobot by screenRobot,
ContributorsServerRobot by contributorsServerRobot {
fun setupScreenContent() {
robotTestRule.setContent {
ContributorsScreen(
onNavigationIconClick = { },
onContributorsItemClick = { },
)
}
}

fun scrollToIndex10() {
composeTestRule
.onNode(hasTestTag(ContributorsTestTag))
.performScrollToIndex(10)
}
Comment on lines +34 to +38
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, in this case the scrolling method using Index was better. ✍️


fun checkRangeContributorItemsDisplayed(
fromTo: IntRange,
) {
val contributorsList = Contributor.fakes().subList(fromTo.first, fromTo.last)
contributorsList.forEach { contributor ->
composeTestRule
.onNode(hasTestTag(ContributorsItemTestTagPrefix.plus(contributor.id)))
.assertExists()
.assertIsDisplayed()

composeTestRule
.onNode(
matcher = hasTestTag(ContributorsItemImageTestTagPrefix.plus(contributor.username)),
useUnmergedTree = true,
)
.assertExists()
.assertIsDisplayed()
.assertContentDescriptionEquals(contributor.username)

composeTestRule
.onNode(
matcher = hasTestTag(ContributorsUserNameTextTestTagPrefix.plus(contributor.username)),
useUnmergedTree = true,
)
.assertExists()
.assertIsDisplayed()
.assertTextEquals(contributor.username)
}
}

fun checkContributorItemsDisplayed() {
// Check there are two contributors
composeTestRule
.onAllNodes(hasTestTag(ContributorsItemTestTagPrefix, substring = true))
.assertCountAtLeast(2)
}

fun checkDoesNotFirstContributorItemDisplayed() {
val contributor = Contributor.fakes().first()
composeTestRule
.onNode(hasTestTag(ContributorsItemTestTagPrefix.plus(contributor.id)))
.assertDoesNotExist()

composeTestRule
.onNode(
matcher = hasTestTag(ContributorsItemImageTestTagPrefix.plus(contributor.username)),
useUnmergedTree = true,
)
.assertDoesNotExist()

composeTestRule
.onNode(
matcher = hasTestTag(ContributorsUserNameTextTestTagPrefix.plus(contributor.username)),
useUnmergedTree = true,
)
.assertDoesNotExist()
}

fun checkErrorSnackbarDisplayed() {
composeTestRule
.onNode(
hasText("Fake IO Exception"),
useUnmergedTree = true,
).assertIsDisplayed()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package io.github.droidkaigi.confsched.testing.utils
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These Utils are very helpful as I am sure they will be used frequently in other test cases! 🙇‍♂️


import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionCollection

fun hasTestTag(
testTag: String,
substring: Boolean = false,
ignoreCase: Boolean = false,
): SemanticsMatcher {
return SemanticsMatcher(
"TestTag ${if (substring) "contains" else "is"} '$testTag' (ignoreCase: $ignoreCase)",
) { node ->
val nodeTestTag: String? = node.config.getOrNull(SemanticsProperties.TestTag)
when {
nodeTestTag == null -> false
substring -> nodeTestTag.contains(testTag, ignoreCase)
else -> nodeTestTag.equals(testTag, ignoreCase)
}
}
}

fun SemanticsNodeInteractionCollection.assertCountAtLeast(
minimumExpectedSize: Int,
): SemanticsNodeInteractionCollection {
val errorOnFail = "Failed to assert minimum count of nodes."
val matchedNodes = fetchSemanticsNodes(
atLeastOneRootRequired = minimumExpectedSize > 0,
errorOnFail,
)
if (matchedNodes.size < minimumExpectedSize) {
throw AssertionError(
buildErrorMessageForMinimumCountMismatch(
errorMessage = errorOnFail,
foundNodes = matchedNodes,
minimumExpectedCount = minimumExpectedSize,
),
)
}
return this
}

private fun buildErrorMessageForMinimumCountMismatch(
errorMessage: String,
foundNodes: List<SemanticsNode>,
minimumExpectedCount: Int,
): String {
return buildString {
appendLine(errorMessage)
appendLine("Expected at least: $minimumExpectedCount")
appendLine("Found: ${foundNodes.size}")
appendLine("Matched nodes:")
foundNodes.forEachIndexed { index, node ->
appendLine("$index: $node")
}
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
package io.github.droidkaigi.confsched.contributors

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.droidkaigi.confsched.testing.DescribedBehavior
import io.github.droidkaigi.confsched.testing.describeBehaviors
import io.github.droidkaigi.confsched.testing.execute
import io.github.droidkaigi.confsched.testing.robot.ContributorsScreenRobot
import io.github.droidkaigi.confsched.testing.robot.ContributorsServerRobot
import io.github.droidkaigi.confsched.testing.robot.DefaultContributorsServerRobot
import io.github.droidkaigi.confsched.testing.robot.DefaultScreenRobot
import io.github.droidkaigi.confsched.testing.robot.ScreenRobot
import io.github.droidkaigi.confsched.testing.robot.runRobot
import io.github.droidkaigi.confsched.testing.rules.RobotTestRule
import org.junit.Rule
Expand Down Expand Up @@ -42,58 +37,54 @@ class ContributorsScreenTest(private val testCase: DescribedBehavior<Contributor
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun behaviors(): List<DescribedBehavior<ContributorsScreenRobot>> {
return describeBehaviors<ContributorsScreenRobot>(name = "ContributorsScreen") {
describe("when launch") {
describe("when server is operational") {
run {
setupContributorServer(ContributorsServerRobot.ServerStatus.Operational)
setupScreenContent()
}
itShould("show contributors list") {
captureScreenWithChecks(
checks = { checkContributorsDisplayed() },
)
}
}
describe("when launch with error") {
run {
setupContributorServer(ContributorsServerRobot.ServerStatus.Error)
setupScreenContent()
describe("when launch") {
run {
setupScreenContent()
}
itShould("show first and second contributors") {
captureScreenWithChecks {
checkRangeContributorItemsDisplayed(
fromTo = 0..2,
)
}
}

describe("when scroll to index 10") {
run {
scrollToIndex10()
}
itShould("show contributors") {
captureScreenWithChecks {
checkContributorItemsDisplayed()
}
}
}
}
itShould("show error message") {
captureScreenWithChecks(
checks = { checkErrorSnackbarDisplayed() },
)

describe("when server is down") {
run {
setupContributorServer(ContributorsServerRobot.ServerStatus.Error)
}
describe("when launch") {
run {
setupScreenContent()
}
itShould("does not show contributor and show snackbar") {
captureScreenWithChecks(
checks = {
checkDoesNotFirstContributorItemDisplayed()
checkErrorSnackbarDisplayed()
},
)
}
}
}
}
}
}
}
}

class ContributorsScreenRobot @Inject constructor(
screenRobot: DefaultScreenRobot,
contributorsServerRobot: DefaultContributorsServerRobot,
) : ScreenRobot by screenRobot,
ContributorsServerRobot by contributorsServerRobot {
fun setupScreenContent() {
robotTestRule.setContent {
ContributorsScreen(
onNavigationIconClick = { },
onContributorsItemClick = { },
)
}
}

fun checkContributorsDisplayed() {
composeTestRule
.onNode(hasTestTag(ContributorsScreenTestTag))
.assertIsDisplayed()
}

fun checkErrorSnackbarDisplayed() {
composeTestRule
.onNode(
hasText("Fake IO Exception"),
useUnmergedTree = true,
).assertIsDisplayed()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import kotlinx.collections.immutable.PersistentList

const val contributorsScreenRoute = "contributors"
const val ContributorsScreenTestTag = "ContributorsScreenTestTag"
const val ContributorsTestTag = "ContributorsTestTag"
const val ContributorsItemTestTagPrefix = "ContributorsItemTestTag:"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surely this is a prefix.
I think this name is better! 👍


fun NavGraphBuilder.contributorsScreens(
onNavigationIconClick: () -> Unit,
Expand Down Expand Up @@ -153,14 +155,16 @@ private fun Contributors(
contentPadding: PaddingValues = PaddingValues(),
) {
LazyColumn(
modifier = modifier,
modifier = modifier.testTag(ContributorsTestTag),
contentPadding = contentPadding,
) {
items(contributors) {
ContributorsItem(
contributor = it,
onClick = onContributorsItemClick,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.testTag(ContributorsItemTestTagPrefix.plus(it.id)),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.github.droidkaigi.confsched.model.Contributor
import io.github.droidkaigi.confsched.ui.previewOverride
import io.github.droidkaigi.confsched.ui.rememberAsyncImagePainter

const val ContributorsItemImageTestTagPrefix = "ContributorsItemImageTestTag:"
const val ContributorsUserNameTextTestTagPrefix = "ContributorsUserNameTextTestTag:"
Comment on lines +27 to +28
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@takahirom
I have modified the name of this test tag since it is used as a Prefix as well. 👍


private val contributorIconShape = CircleShape

@Composable
Expand All @@ -44,21 +48,23 @@ fun ContributorsItem(
painter = previewOverride(previewPainter = { rememberVectorPainter(image = Icons.Default.Person) }) {
rememberAsyncImagePainter(contributor.iconUrl)
},
contentDescription = null,
contentDescription = contributor.username,
modifier = Modifier
.size(52.dp)
.clip(contributorIconShape)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = contributorIconShape,
),
)
.testTag(ContributorsItemImageTestTagPrefix.plus(contributor.username)),
)
Text(
text = contributor.username,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(ContributorsUserNameTextTestTagPrefix.plus(contributor.username)),
)
}
}
Loading