Rewrite UI tests to use robots

This commit is contained in:
William Brawner 2024-08-22 19:09:39 -06:00
parent 307e7642b9
commit 040777b99a
8 changed files with 310 additions and 181 deletions

View file

@ -0,0 +1,27 @@
package com.wbrawner.simplemarkdown
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import com.wbrawner.simplemarkdown.robot.onMainScreen
import org.junit.Rule
import org.junit.Test
class HelpTest {
@get:Rule
val composeRule = createEmptyComposeRule()
@Test
fun openHelpPageTest() {
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
checkMarkdownEquals("")
openDrawer()
} onNavigationDrawer {
openHelpPage()
} onHelpScreen {
checkTitleEquals("Help")
verifyH1("Headings/Titles")
}
}
}

View file

@ -5,27 +5,7 @@ import android.app.Instrumentation
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.webkit.WebView
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.hasAnyDescendant
import androidx.compose.ui.test.hasAnySibling
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.printToLog
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider.getApplicationContext import androidx.test.core.app.ApplicationProvider.getApplicationContext
@ -34,14 +14,9 @@ import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.rule.IntentsRule import androidx.test.espresso.intent.rule.IntentsRule
import androidx.test.espresso.matcher.ViewMatchers.* import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches
import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
import androidx.test.espresso.web.webdriver.DriverAtoms.getText
import androidx.test.espresso.web.webdriver.Locator
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.wbrawner.simplemarkdown.robot.onMainScreen
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.hamcrest.CoreMatchers.containsString
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
@ -79,13 +54,13 @@ class MarkdownTests {
@Test @Test
fun editAndPreviewMarkdownTest() { fun editAndPreviewMarkdownTest() {
ActivityScenario.launch(MainActivity::class.java) ActivityScenario.launch(MainActivity::class.java)
composeRule.typeMarkdown("# Header test") onMainScreen(composeRule) {
composeRule.checkMarkdownEquals("# Header test") typeMarkdown("# Header test")
composeRule.openPreview() checkMarkdownEquals("# Header test")
onWebView(isAssignableFrom(WebView::class.java)) openPreview()
.forceJavascriptEnabled() } onPreview {
.withElement(findElement(Locator.TAG_NAME, "h1")) verifyH1("Header test")
.check(webMatches(getText(), containsString("Header test"))) }
} }
@Test @Test
@ -98,102 +73,113 @@ class MarkdownTests {
}) })
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult) intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
ActivityScenario.launch(MainActivity::class.java) ActivityScenario.launch(MainActivity::class.java)
composeRule.openMenu() onMainScreen(composeRule) {
composeRule.clickOpenMenuItem() openMenu()
composeRule.checkMarkdownEquals(markdownText) clickOpenMenuItem()
composeRule.openMenu() checkMarkdownEquals(markdownText)
composeRule.clickNewMenuItem() openMenu()
composeRule.verifyDialogIsNotShown() clickNewMenuItem()
composeRule.checkMarkdownEquals("") verifyDialogIsNotShown()
checkMarkdownEquals("")
}
} }
@Test @Test
fun editThenNewMarkdownTest() { fun editThenNewMarkdownTest() {
ActivityScenario.launch(MainActivity::class.java) ActivityScenario.launch(MainActivity::class.java)
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog." val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
composeRule.typeMarkdown(markdownText) onMainScreen(composeRule) {
composeRule.openMenu() typeMarkdown(markdownText)
composeRule.clickNewMenuItem() openMenu()
composeRule.onNode(isDialog()).printToLog("TestDebugging") clickNewMenuItem()
composeRule.verifyDialogIsShown("Would you like to save your changes?") verifyDialogIsShown("Would you like to save your changes?")
composeRule.discardChanges() discardChanges()
composeRule.checkMarkdownEquals("") checkMarkdownEquals("")
}
} }
@Test @Test
fun saveMarkdownWithFileUriTest() = runTest { fun saveMarkdownWithFileUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java) ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md") onMainScreen(composeRule) {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog." checkTitleEquals("Untitled.md")
composeRule.typeMarkdown(markdownText) val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply { typeMarkdown(markdownText)
data = Uri.fromFile(file) val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
}) data = Uri.fromFile(file)
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult) })
composeRule.openMenu() intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
composeRule.clickSaveMenuItem() openMenu()
composeRule.awaitIdle() clickSaveMenuItem()
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText)) awaitIdle()
composeRule.checkTitleEquals("temp.md") assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
checkTitleEquals("temp.md")
}
} }
@Test @Test
fun saveMarkdownWithContentUriTest() = runTest { fun saveMarkdownWithContentUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java) ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md") onMainScreen(composeRule) {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog." checkTitleEquals("Untitled.md")
composeRule.typeMarkdown(markdownText) val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply { typeMarkdown(markdownText)
data = FileProvider.getUriForFile( val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
getApplicationContext(), data = FileProvider.getUriForFile(
"${BuildConfig.APPLICATION_ID}.fileprovider", getApplicationContext(),
file "${BuildConfig.APPLICATION_ID}.fileprovider",
) file
}) )
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult) })
composeRule.openMenu() intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
composeRule.clickSaveMenuItem() openMenu()
composeRule.awaitIdle() clickSaveMenuItem()
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText)) awaitIdle()
composeRule.checkTitleEquals("temp.md") assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
checkTitleEquals("temp.md")
}
} }
@Test @Test
fun loadMarkdownWithFileUriTest() = runTest { fun loadMarkdownWithFileUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java) ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md") onMainScreen(composeRule) {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog." checkTitleEquals("Untitled.md")
file.outputStream().writer().use { it.write(markdownText) } val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply { file.outputStream().writer().use { it.write(markdownText) }
data = Uri.fromFile(file) val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
}) data = Uri.fromFile(file)
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult) })
composeRule.openMenu() intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
composeRule.clickOpenMenuItem() openMenu()
composeRule.awaitIdle() clickOpenMenuItem()
composeRule.checkMarkdownEquals(markdownText) awaitIdle()
composeRule.checkTitleEquals("temp.md") checkMarkdownEquals(markdownText)
checkTitleEquals("temp.md")
}
} }
@Test @Test
fun loadMarkdownWithContentUriTest() = runTest { fun loadMarkdownWithContentUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java) ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md") onMainScreen(composeRule) {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog." checkTitleEquals("Untitled.md")
file.outputStream().writer().use { it.write(markdownText) } val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply { file.outputStream().writer().use { it.write(markdownText) }
data = FileProvider.getUriForFile( val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
getApplicationContext(), data = FileProvider.getUriForFile(
"${BuildConfig.APPLICATION_ID}.fileprovider", getApplicationContext(),
file "${BuildConfig.APPLICATION_ID}.fileprovider",
) file
}) )
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult) })
composeRule.openMenu() intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
composeRule.clickOpenMenuItem() openMenu()
composeRule.awaitIdle() clickOpenMenuItem()
composeRule.checkMarkdownEquals(markdownText) awaitIdle()
composeRule.checkTitleEquals("temp.md") checkMarkdownEquals(markdownText)
checkTitleEquals("temp.md")
}
} }
@ -206,84 +192,22 @@ class MarkdownTests {
}) })
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult) intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
ActivityScenario.launch(MainActivity::class.java) ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md") onMainScreen(composeRule) {
composeRule.openMenu() checkTitleEquals("Untitled.md")
composeRule.clickOpenMenuItem() openMenu()
composeRule.awaitIdle() clickOpenMenuItem()
composeRule.verifyTextIsShown("Successfully loaded temp.md") awaitIdle()
composeRule.checkMarkdownEquals(markdownText) verifyTextIsShown("Successfully loaded temp.md")
composeRule.checkTitleEquals("temp.md") checkMarkdownEquals(markdownText)
val additionalText = "# More info\n\nThis is some additional text" checkTitleEquals("temp.md")
composeRule.typeMarkdown(additionalText) val additionalText = "# More info\n\nThis is some additional text"
composeRule.openMenu() typeMarkdown(additionalText)
composeRule.clickSaveMenuItem() openMenu()
composeRule.awaitIdle() clickSaveMenuItem()
composeRule.verifyTextIsShown("Successfully saved temp.md") awaitIdle()
assertEquals(additionalText, file.inputStream().reader().use(Reader::readText)) verifyTextIsShown("Successfully saved temp.md")
composeRule.checkTitleEquals("temp.md") assertEquals(additionalText, file.inputStream().reader().use(Reader::readText))
} checkTitleEquals("temp.md")
private fun ComposeTestRule.checkTitleEquals(title: String) =
onNode(hasAnySibling(hasContentDescription("Main Menu")).and(hasText(title)))
.waitUntilIsDisplayed()
private fun ComposeTestRule.typeMarkdown(markdown: String) =
onNode(hasSetTextAction()).performTextReplacement(markdown)
private fun ComposeTestRule.checkMarkdownEquals(markdown: String) {
val markdownMatcher = SemanticsMatcher("Markdown = [$markdown]") {
it.config.getOrNull(SemanticsProperties.EditableText)?.text == markdown
} }
onNode(hasSetTextAction()).waitUntil {
assert(markdownMatcher)
}
}
private fun ComposeTestRule.openPreview() = onNodeWithText("Preview").performClick()
private fun ComposeTestRule.openMenu() =
onNodeWithContentDescription("Editor Actions").performClick()
private fun ComposeTestRule.clickOpenMenuItem() = onNodeWithText("Open").performClick()
private fun ComposeTestRule.clickNewMenuItem() = onNodeWithText("New").performClick()
private fun ComposeTestRule.clickSaveMenuItem() = onNodeWithText("Save").performClick()
private fun ComposeTestRule.verifyDialogIsShown(text: String) =
onNode(isDialog().and(hasAnyDescendant(hasText(text)))).waitUntilIsDisplayed()
private fun ComposeTestRule.verifyDialogIsNotShown() =
onNode(isDialog()).waitUntilIsNotDisplayed()
private fun ComposeTestRule.discardChanges() = onNodeWithText("No").performClick()
private fun ComposeTestRule.verifyTextIsShown(text: String) =
onNodeWithText(text).waitUntilIsDisplayed()
private val ASSERTION_TIMEOUT = 5_000L
private fun SemanticsNodeInteraction.waitUntil(assertion: SemanticsNodeInteraction.() -> Unit) {
val start = System.currentTimeMillis()
lateinit var assertionError: AssertionError
while (System.currentTimeMillis() - start < ASSERTION_TIMEOUT) {
try {
assertion()
return
} catch (e: AssertionError) {
assertionError = e
Thread.sleep(10)
}
}
throw assertionError
}
private fun SemanticsNodeInteraction.waitUntilIsDisplayed() = waitUntil {
assertIsDisplayed()
}
private fun SemanticsNodeInteraction.waitUntilIsNotDisplayed() = waitUntil {
assertIsNotDisplayed()
} }
} }

View file

@ -0,0 +1,29 @@
package com.wbrawner.simplemarkdown
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
private const val ASSERTION_TIMEOUT = 5_000L
fun SemanticsNodeInteraction.waitUntilIsDisplayed() = waitUntil {
assertIsDisplayed()
}
fun SemanticsNodeInteraction.waitUntilIsNotDisplayed() = waitUntil {
assertIsNotDisplayed()
}
fun <T> SemanticsNodeInteraction.waitUntil(assertion: SemanticsNodeInteraction.() -> T): T {
val start = System.currentTimeMillis()
lateinit var assertionError: AssertionError
while (System.currentTimeMillis() - start < ASSERTION_TIMEOUT) {
try {
return assertion()
} catch (e: AssertionError) {
assertionError = e
Thread.sleep(10)
}
}
throw assertionError
}

View file

@ -0,0 +1,78 @@
package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.hasAnyDescendant
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextReplacement
import com.wbrawner.simplemarkdown.waitUntil
import com.wbrawner.simplemarkdown.waitUntilIsDisplayed
import com.wbrawner.simplemarkdown.waitUntilIsNotDisplayed
import kotlinx.coroutines.CoroutineScope
fun onMainScreen(composeRule: ComposeTestRule, block: MainScreenRobot.() -> Unit) =
MainScreenRobot(composeRule).apply(block)
@Suppress("UnusedReceiverParameter") // Used to avoid import ambiguity for tests
suspend fun CoroutineScope.onMainScreen(
composeRule: ComposeTestRule,
block: suspend MainScreenRobot.() -> Unit
) {
block.invoke(MainScreenRobot(composeRule))
}
class MainScreenRobot(private val composeRule: ComposeTestRule) :
TopAppBarRobot by ComposeTopAppBarRobot(composeRule) {
fun typeMarkdown(markdown: String) = composeRule.onNode(hasSetTextAction())
.performTextReplacement(markdown)
fun checkMarkdownEquals(markdown: String) {
val markdownMatcher = SemanticsMatcher("Markdown = [$markdown]") {
it.config.getOrNull(SemanticsProperties.EditableText)?.text == markdown
}
composeRule.onNode(hasSetTextAction()).waitUntil {
assert(markdownMatcher)
}
}
fun openPreview() = composeRule.onNodeWithText("Preview").performClick()
fun openMenu() = composeRule.onNodeWithContentDescription("Editor Actions").performClick()
fun clickOpenMenuItem() = composeRule.onNodeWithText("Open").performClick()
fun clickNewMenuItem() = composeRule.onNodeWithText("New").performClick()
fun clickSaveMenuItem() = composeRule.onNodeWithText("Save").performClick()
fun verifyDialogIsShown(text: String) =
composeRule.onNode(isDialog().and(hasAnyDescendant(hasText(text)))).waitUntilIsDisplayed()
fun verifyDialogIsNotShown() = composeRule.onNode(isDialog()).waitUntilIsNotDisplayed()
fun discardChanges() = composeRule.onNodeWithText("No").performClick()
fun verifyTextIsShown(text: String) = composeRule.onNodeWithText(text).waitUntilIsDisplayed()
fun openDrawer() = composeRule.onNode(hasClickAction() and hasContentDescription("Main Menu"))
.waitUntilIsDisplayed()
.performClick()
suspend fun awaitIdle() = composeRule.awaitIdle()
infix fun onPreview(block: WebViewRobot.() -> Unit) = EspressoWebViewRobot().apply(block)
infix fun onNavigationDrawer(block: NavigationDrawerRobot.() -> Unit): NavigationDrawerRobot =
NavigationDrawerRobot(composeRule).apply(block = block)
}

View file

@ -0,0 +1,7 @@
package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.test.junit4.ComposeTestRule
class MarkdownInfoScreenRobot(private val composeTestRule: ComposeTestRule) :
TopAppBarRobot by ComposeTopAppBarRobot(composeTestRule),
WebViewRobot by EspressoWebViewRobot()

View file

@ -0,0 +1,16 @@
package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.performClick
import com.wbrawner.simplemarkdown.waitUntilIsDisplayed
class NavigationDrawerRobot(private val composeTestRule: ComposeTestRule) {
fun openHelpPage() = composeTestRule.onNode(hasClickAction() and hasText("Help"))
.waitUntilIsDisplayed()
.performClick()
infix fun onHelpScreen(block: MarkdownInfoScreenRobot.() -> Unit) =
MarkdownInfoScreenRobot(composeTestRule).apply(block)
}

View file

@ -0,0 +1,24 @@
package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.hasAnySibling
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeTestRule
import com.wbrawner.simplemarkdown.waitUntilIsDisplayed
interface TopAppBarRobot {
fun checkTitleEquals(title: String): SemanticsNodeInteraction
}
class ComposeTopAppBarRobot(private val composeTestRule: ComposeTestRule) : TopAppBarRobot {
override fun checkTitleEquals(title: String) =
composeTestRule.onNode(
hasAnySibling(
hasContentDescription("Main Menu") or hasContentDescription(
"Back"
)
).and(hasText(title))
)
.waitUntilIsDisplayed()
}

View file

@ -0,0 +1,24 @@
package com.wbrawner.simplemarkdown.robot
import android.webkit.WebView
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches
import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
import androidx.test.espresso.web.webdriver.DriverAtoms.getText
import androidx.test.espresso.web.webdriver.Locator
import org.hamcrest.CoreMatchers.containsString
interface WebViewRobot {
fun verifyH1(text: String)
}
class EspressoWebViewRobot : WebViewRobot {
private fun findWebView() = onWebView(isAssignableFrom(WebView::class.java))
.forceJavascriptEnabled()
override fun verifyH1(text: String) {
findWebView().withElement(findElement(Locator.TAG_NAME, "h1"))
.check(webMatches(getText(), containsString(text)))
}
}