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

Implement UI testing for Mentions and Settings pages #50

Merged
merged 7 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ package io.spine.internal.dependency

// https://github.com/spine-examples/Pingh
public object Pingh {
private const val version = "1.0.0-SNAPSHOT.10"
private const val version = "1.0.0-SNAPSHOT.11"
private const val group = "io.spine.examples.pingh"

public const val client: String = "$group:client:$version"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.spine.examples.pingh.client.MentionsFlow
Expand Down Expand Up @@ -123,7 +124,7 @@ private fun ToolBar(
IconButton(
icon = Icons.pingh,
onClick = toSettingsPage,
modifier = Modifier.size(40.dp),
modifier = Modifier.size(40.dp).testTag("settings-button"),
colors = IconButtonDefaults.iconButtonColors(
contentColor = contentColor
)
Expand Down Expand Up @@ -166,7 +167,8 @@ private fun MentionCards(
.fillMaxSize()
.padding(horizontal = 5.dp)
.verticalScroll(scrollState)
.background(MaterialTheme.colorScheme.background),
.background(MaterialTheme.colorScheme.background)
.testTag("mention-cards"),
) {
mentions.sorted()
.forEach { mention ->
Expand Down Expand Up @@ -213,6 +215,7 @@ private fun MentionCard(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.testTag("mention-card-${mention.id}")
.height(50.dp),
interactionSource = interactionSource,
colors = CardDefaults.elevatedCardColors(
Expand Down Expand Up @@ -295,7 +298,7 @@ private fun SnoozeButton(
onClick = {
flow.snooze(mention.id)
},
modifier = Modifier.size(40.dp),
modifier = Modifier.size(40.dp).testTag("snooze-button"),
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.onSecondary
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
Expand Down Expand Up @@ -245,7 +246,7 @@ private fun LogOutButton(
) {
OutlinedButton(
onClick = onClick,
modifier = Modifier.height(20.dp),
modifier = Modifier.height(20.dp).testTag("logout-button"),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary
Expand Down Expand Up @@ -302,7 +303,8 @@ private fun DndOption(
modifier = Modifier
.scale(switchScale)
.width(36.dp)
.height(20.dp),
.height(20.dp)
.testTag("dnd-option"),
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.secondary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
Expand Down
25 changes: 18 additions & 7 deletions desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Window.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,14 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.Window as ComposeWindow
Expand All @@ -47,21 +50,29 @@ import io.spine.examples.pingh.client.PinghApplication
*
* @param state The state of the application window.
* @param app Manages the logic for the Pingh app.
* @param uriHandler The `CompositionLocal` that provides functionality for handling URL,
* such as opening a URI.
* @see [PlatformWindow]
*/
@Composable
internal fun Window(state: WindowState, app: PinghApplication) {
internal fun Window(
state: WindowState,
app: PinghApplication,
uriHandler: UriHandler = LocalUriHandler.current
) {
PlatformWindow(
title = state.title,
isShown = state.isShown,
onClose = state::hide
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.small)
) {
CurrentPage(app)
CompositionLocalProvider(LocalUriHandler provides uriHandler) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.small)
) {
CurrentPage(app)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ import org.junit.jupiter.api.DisplayName
@OptIn(ExperimentalTestApi::class)
internal class LoginPageUiTest : UiTest() {

private val username = "MykytaPimonovTD"

private val SemanticsNodeInteractionsProvider.loginButton
get() = onNodeWithTag("login-button")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* Copyright 2024, TeamDev. All rights reserved.
*
* 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
*
* Redistribution and use in source and/or binary forms, with or without
* modification, must retain the above copyright notice and the following
* disclaimer.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

package io.spine.examples.pingh.desktop

import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.filter
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import io.kotest.matchers.floats.shouldBeLessThan
import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual
import io.kotest.matchers.shouldBe
import io.spine.examples.pingh.desktop.given.DelayedFactAssertion.Companion.awaitFact
import io.spine.examples.pingh.desktop.given.testTag
import kotlin.test.Test
import org.junit.jupiter.api.DisplayName

@DisplayName("Mentions page should")
@OptIn(ExperimentalTestApi::class)
internal class MentionsPageUiTest : UiTest() {

@Test
internal fun `allow users to open a mentions URL even after it has been read`() =
runComposeUiTest {
runApp()
logIn()
awaitFact { mentionCards().size shouldBeGreaterThanOrEqual 1 }
val tag = mentionCards().random().testTag
onNodeWithTag(tag).performClick()
awaitFact { openedUrlCount() shouldBe 1 }
onNodeWithTag(tag).performClick()
awaitFact { openedUrlCount() shouldBe 2 }
}

@Test
internal fun `have snooze button disabled after it is clicked`() =
runComposeUiTest {
runApp()
logIn()
awaitFact { mentionCards().size shouldBeGreaterThanOrEqual 1 }
val tag = mentionCards().random().testTag
onSnoozeButtonWithParentTag(tag).performClick()
awaitFact { onSnoozeButtonWithParentTag(tag).assertDoesNotExist() }
}

@Test
internal fun `have snooze button disabled if mention has been read`() =
runComposeUiTest {
runApp()
logIn()
awaitFact { mentionCards().size shouldBeGreaterThanOrEqual 1 }
val tag = mentionCards().random().testTag
onNodeWithTag(tag).performClick()
awaitFact { onSnoozeButtonWithParentTag(tag).assertDoesNotExist() }
}

@Test
internal fun `have mentions sorted after their states have been changed`() =
runComposeUiTest {
runApp()
logIn()
awaitFact { mentionCards().size shouldBeGreaterThanOrEqual 2 }
val mentionsCards = mentionCards().sortedBy { it.positionInRoot.y }
val readMentionTag = mentionsCards[0].testTag
val snoozedMentionTag = mentionsCards[1].testTag
onNodeWithTag(readMentionTag).performClick()
onSnoozeButtonWithParentTag(snoozedMentionTag).performClick()
awaitFact {
val mentions = mentionCards()
val readMention = mentions.first { it.testTag == readMentionTag }
val snoozedMention = mentions.first { it.testTag == snoozedMentionTag }
snoozedMention.positionInRoot.y shouldBeLessThan readMention.positionInRoot.y
mentions.forEach { mention ->
if (mention != readMention && mention != snoozedMention) {
mention.positionInRoot.y shouldBeLessThan readMention.positionInRoot.y
mention.positionInRoot.y shouldBeLessThan snoozedMention.positionInRoot.y
}
}
}
}

private fun SemanticsNodeInteractionsProvider.mentionCards(): List<SemanticsNode> =
onNodeWithTag("mention-cards")
.onChildren()
.filter(hasClickAction())
.fetchSemanticsNodes()

private fun SemanticsNodeInteractionsProvider.onSnoozeButtonWithParentTag(tag: String):
SemanticsNodeInteraction =
onNodeWithTag(tag)
.onChildren()
.filterToOne(hasTestTag("snooze-button"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright 2024, TeamDev. All rights reserved.
*
* 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
*
* Redistribution and use in source and/or binary forms, with or without
* modification, must retain the above copyright notice and the following
* disclaimer.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

package io.spine.examples.pingh.desktop

import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import io.spine.examples.pingh.desktop.given.DelayedFactAssertion.Companion.awaitFact
import kotlin.test.Test
import org.junit.jupiter.api.DisplayName

@DisplayName("Settings page should")
@OptIn(ExperimentalTestApi::class)
internal class SettingsPageUiTest : UiTest() {

private val SemanticsNodeInteractionsProvider.settingsButton
get() = onNodeWithTag("settings-button")

private val SemanticsNodeInteractionsProvider.logoutButton
get() = onNodeWithTag("logout-button")

private val SemanticsNodeInteractionsProvider.dndOption
get() = onNodeWithTag("dnd-option")

@Test
internal fun `have settings retained after the user logs in again`() =
runComposeUiTest {
runApp()
logIn()
settingsButton.performClick()
awaitFact { dndOption.assertExists() }
dndOption.performClick()
awaitFact { dndOption.assertIsOn() }
logoutButton.performClick()
awaitFact { logoutButton.assertDoesNotExist() }
logIn()
settingsButton.performClick()
awaitFact { dndOption.assertIsOn() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ package io.spine.examples.pingh.desktop
import androidx.compose.runtime.remember
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import io.spine.examples.pingh.desktop.given.DelayedFactAssertion.Companion.awaitFact
import io.spine.examples.pingh.desktop.given.MemoizingUriHandler
import io.spine.examples.pingh.testing.client.IntegrationTest
import org.junit.jupiter.api.AfterEach

Expand All @@ -38,11 +44,14 @@ import org.junit.jupiter.api.AfterEach
*/
internal abstract class UiTest : IntegrationTest() {

protected val username = "MykytaPimonovTD"
private var state: AppState? = null
private val uriHandler = MemoizingUriHandler()

@AfterEach
internal fun shutdownChannel() {
state?.app?.close()
uriHandler.reset()
}

/**
Expand All @@ -54,8 +63,31 @@ internal abstract class UiTest : IntegrationTest() {
Theme {
val settings = retrieveSystemSettings()
state = remember { AppState(settings) }
Window(state!!.window, state!!.app)
Window(state!!.window, state!!.app, uriHandler)
}
}
}

/**
* Returns the number of opened URLs.
*
* To correctly count opened URLs, the test application
* must be run using [runApp()][ComposeUiTest.runApp] method.
*/
protected fun openedUrlCount(): Int = uriHandler.urlCount

/**
* Logs the user in the application with the specified username and
* navigates to the Mentions page.
*/
@OptIn(ExperimentalTestApi::class)
protected fun ComposeUiTest.logIn() {
onNodeWithTag("username-input").performTextInput(username)
awaitFact { onNodeWithTag("login-button").assertIsEnabled() }
onNodeWithTag("login-button").performClick()
awaitFact { onNodeWithTag("submit-button").assertExists() }
enterUserCode()
onNodeWithTag("submit-button").performClick()
awaitFact { onNodeWithTag("submit-button").assertDoesNotExist() }
}
}
Loading
Loading