Skip to content

Commit

Permalink
Merge pull request #70 from DroidKaigi/takahirom/add-describe-tests-b…
Browse files Browse the repository at this point in the history
…ased-on-describe-spec/2024-06-25

Add describe based Robolectric test
  • Loading branch information
takahirom authored Jul 6, 2024
2 parents 60cf476 + 30cab5f commit 5744c48
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 101 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package io.github.droidkaigi.confsched.testing

inline fun <reified T> describeTests(block: TestCaseTreeBuilder<T>.() -> Unit): List<DescribedTestCase<T>> {
val builder = TestCaseTreeBuilder<T>()
builder.block()
val root = builder.build()
return generateTestCases(root)
}

fun <T> DescribedTestCase<T>.execute(robot: T) {
for ((index, step) in steps.withIndex()) {
println("Executing step: $index ($description)")
when (step) {
is TestNode.Run -> step.action(robot)
is TestNode.Check -> {
if (step.description == targetCheckDescription) {
step.action(robot)
}
}

is TestNode.Describe -> {}
}
println("Step executed: $index")
}
}

sealed class TestNode<T> {
data class Describe<T>(val description: String, val children: List<TestNode<T>>) : TestNode<T>()
data class Run<T>(val action: T.() -> Unit) : TestNode<T>()
data class Check<T>(val description: String, val action: T.() -> Unit) : TestNode<T>()
}

data class DescribedTestCase<T>(
val description: String,
val steps: List<TestNode<T>>,
val targetCheckDescription: String,
) {
override fun toString(): String = description
}

data class AncestryNode<T>(
val node: TestNode<T>,
val childIndex: Int,
)

data class CheckNode<T>(
val description: String,
val fullDescription: String,
val node: TestNode.Check<T>,
val ancestry: List<AncestryNode<T>>,
)

class TestCaseTreeBuilder<T> {
private val children = mutableListOf<TestNode<T>>()

fun describe(description: String, block: TestCaseTreeBuilder<T>.() -> Unit) {
val builder = TestCaseTreeBuilder<T>()
builder.block()
children.add(TestNode.Describe(description, builder.children))
}

fun run(action: T.() -> Unit) {
children.add(TestNode.Run { action() })
}

fun check(description: String, action: T.() -> Unit) {
children.add(TestNode.Check(description) { action() })
}

fun build(): TestNode.Describe<T> = TestNode.Describe("", children)
}

fun <T> generateTestCases(root: TestNode.Describe<T>): List<DescribedTestCase<T>> {
val checkNodes = collectCheckNodes(root)
return checkNodes.map { createTestCase(it) }
}

/**
* Collect all check nodes from the test tree
* it will be O(N)
*/
private fun <T> collectCheckNodes(root: TestNode.Describe<T>): List<CheckNode<T>> {
val checkNodes = mutableListOf<CheckNode<T>>()

fun traverse(node: TestNode<T>, parentDescription: String, ancestry: List<AncestryNode<T>>) {
when (node) {
is TestNode.Describe -> {
val currentDescription =
if (parentDescription.isEmpty()) node.description else "$parentDescription - ${node.description}"
node.children.forEachIndexed { index, child ->
val currentAncestry = ancestry + AncestryNode(node, index)
traverse(child, currentDescription, currentAncestry)
}
}

is TestNode.Check -> {
val fullDescription = if (parentDescription.isNotBlank()) {
"$parentDescription - ${node.description}"
} else {
node.description
}
checkNodes.add(CheckNode(node.description, fullDescription, node, ancestry))
}

is TestNode.Run -> {}
}
}

traverse(root, "", emptyList())
return checkNodes
}

/**
* Create a test case from a check node
* We only run the steps that are necessary to reach the check node
* so the time complexity might be O(logN)
*/
private fun <T> createTestCase(checkNode: CheckNode<T>): DescribedTestCase<T> {
val steps = mutableListOf<TestNode<T>>()

fun processNode(node: TestNode<T>, ancestry: List<TestNode<T>>, depth: Int) {
when (node) {
is TestNode.Describe -> {
for (child in node.children) {
if (depth + 1 < checkNode.ancestry.size && child == checkNode.ancestry[depth + 1].node) {
processNode(child, ancestry + node, depth + 1)
} else if (child is TestNode.Run) {
steps.add(child)
} else if (child == checkNode.node) {
steps.add(child)
}
}
}

is TestNode.Run -> {
steps.add(node)
}

is TestNode.Check -> {
if (node == checkNode.node) {
steps.add(node)
}
}
}
}

processNode(checkNode.ancestry.first().node, emptyList(), 0)

return DescribedTestCase(checkNode.fullDescription, steps, checkNode.description)
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.github.takahirom.roborazzi.Dump
import com.github.takahirom.roborazzi.RoborazziOptions
import com.github.takahirom.roborazzi.captureRoboImage
import io.github.droidkaigi.confsched.designsystem.theme.KaigiTheme
import io.github.droidkaigi.confsched.model.TimetableItem
import io.github.droidkaigi.confsched.sessions.TimetableListItemBookmarkIconTestTag
import io.github.droidkaigi.confsched.sessions.TimetableListItemTestTag
import io.github.droidkaigi.confsched.sessions.TimetableScreen
Expand All @@ -31,12 +32,16 @@ class TimetableScreenRobot @Inject constructor(
private val timetableServerRobot: DefaultTimetableServerRobot,
) : ScreenRobot by screenRobot,
TimetableServerRobot by timetableServerRobot {
val clickedItems = mutableSetOf<TimetableItem>()

fun setupTimetableScreenContent() {
robotTestRule.setContent {
CompositionLocalProvider(LocalClock provides FakeClock) {
KaigiTheme {
TimetableScreen(
onTimetableItemClick = { },
onTimetableItemClick = {
clickedItems.add(it)
},
)
}
}
Expand Down Expand Up @@ -64,6 +69,7 @@ class TimetableScreenRobot @Inject constructor(
composeTestRule
.onNode(hasTestTag(TimetableUiTypeChangeButtonTestTag))
.performClick()
waitUntilIdle()
}

fun clickTimetableTab(
Expand All @@ -86,6 +92,10 @@ class TimetableScreenRobot @Inject constructor(
}
}

fun checkClickedItemsExists() {
assert(clickedItems.isNotEmpty())
}

fun checkTimetableItemsDisplayed() {
composeTestRule
.onAllNodes(hasTestTag(TimetableListItemTestTag))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
package io.github.droidkaigi.confsched.sessions

import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.droidkaigi.confsched.testing.DescribedTestCase
import io.github.droidkaigi.confsched.testing.RobotTestRule
import io.github.droidkaigi.confsched.testing.TimetableServerRobot.ServerStatus
import io.github.droidkaigi.confsched.testing.describeTests
import io.github.droidkaigi.confsched.testing.execute
import io.github.droidkaigi.confsched.testing.robot.TimetableScreenRobot
import io.github.droidkaigi.confsched.testing.runRobot
import io.github.droidkaigi.confsched.testing.todoChecks
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.ParameterizedRobolectricTestRunner
import javax.inject.Inject

@RunWith(AndroidJUnit4::class)
@RunWith(ParameterizedRobolectricTestRunner::class)
@HiltAndroidTest
class TimetableScreenTest {
class TimetableScreenTest(private val testCase: DescribedTestCase<TimetableScreenRobot>) {

@get:Rule
@BindValue val robotTestRule: RobotTestRule = RobotTestRule(this)
Expand All @@ -25,101 +26,65 @@ class TimetableScreenTest {
lateinit var timetableScreenRobot: TimetableScreenRobot

@Test
fun checkLaunchShot() {
fun runTest() {
runRobot(timetableScreenRobot) {
setupTimetableScreenContent()
captureScreenWithChecks(checks = todoChecks("TODO: Please add some checks!"))
testCase.execute(timetableScreenRobot)
}
}

@Test
fun checkLaunchServerErrorShot() {
runRobot(timetableScreenRobot) {
setupTimetableServer(ServerStatus.Error)
setupTimetableScreenContent()
captureScreenWithChecks(checks = todoChecks("TODO: Please add some checks!"))
}
}

@Test
fun checkLaunch() {
runRobot(timetableScreenRobot) {
setupTimetableScreenContent()
checkTimetableItemsDisplayed()
}
}

@Test
fun checkLaunchAccessibilityShot() {
runRobot(timetableScreenRobot) {
setupTimetableScreenContent()
checkAccessibilityCapture()
}
}

@Test
fun checkBookmarkToggleShot() {
runRobot(timetableScreenRobot) {
setupTimetableScreenContent()
clickFirstSessionBookmark()
captureScreenWithChecks()
clickFirstSessionBookmark()
captureScreenWithChecks()
}
}

@Test
fun checkScrollShot() {
runRobot(timetableScreenRobot) {
setupTimetableScreenContent()
scrollTimetable()
captureScreenWithChecks()
}
}

@Test
fun checkGridShot() {
runRobot(timetableScreenRobot) {
setupTimetableScreenContent()
clickTimetableUiTypeChangeButton()
captureScreenWithChecks()
}
}

@Test
fun checkGridScrollShot() {
runRobot(timetableScreenRobot) {
setupTimetableScreenContent()
clickTimetableUiTypeChangeButton()
scrollTimetable()
captureScreenWithChecks()
}
}

@Test
@Config(fontScale = 0.5f)
fun checkSmallFontScaleShot() {
runRobot(timetableScreenRobot) {
setupTimetableScreenContent()
captureScreenWithChecks(checks = todoChecks("TODO: Please add some checks!"))
}
}

@Test
@Config(fontScale = 1.5f)
fun checkLargeFontScaleShot() {
runRobot(timetableScreenRobot) {
setupTimetableScreenContent()
captureScreenWithChecks(checks = todoChecks("TODO: Please add some checks!"))
}
}

@Test
@Config(fontScale = 2.0f)
fun checkHugeFontScaleShot() {
runRobot(timetableScreenRobot) {
setupTimetableScreenContent()
captureScreenWithChecks(checks = todoChecks("TODO: Please add some checks!"))
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun testCases(): List<DescribedTestCase<TimetableScreenRobot>> {
return describeTests<TimetableScreenRobot> {
describe("when server is operational") {
run {
setupTimetableServer(ServerStatus.Operational)
setupTimetableScreenContent()
}
check("should show timetable items") {
captureScreenWithChecks(checks = {
checkTimetableItemsDisplayed()
})
}
describe("click first session bookmark") {
run {
clickFirstSessionBookmark()
}
check("should show bookmarked session") {
// FIXME: Add check for bookmarked session
captureScreenWithChecks()
}
}
describe("click first session") {
run {
clickFirstSession()
}
check("should show session detail") {
checkClickedItemsExists()
}
}
describe("click timetable ui type change button") {
run {
clickTimetableUiTypeChangeButton()
}
check("should change timetable ui type") {
// FIXME: Add check for timetable ui type change
captureScreenWithChecks()
}
}
}
describe("when server is down") {
run {
setupTimetableServer(ServerStatus.Error)
setupTimetableScreenContent()
}
check("should show error message") {
// FIXME: Add check for error message
captureScreenWithChecks()
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,6 @@ private fun TimetableScreen(
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
// TimetableTopArea(
// timetableUiType = uiState.timetableUiType,
// onTimetableUiChangeClick = onTimetableUiChangeClick,
// )
Row {
Text(text = "UiType: ${uiState.timetableUiType}")
Button(
Expand Down

0 comments on commit 5744c48

Please sign in to comment.