Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
3
core/ui/compose/testing/README.md
Normal file
3
core/ui/compose/testing/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## Core - UI - Compose - Testing
|
||||
|
||||
Uses [`:core:ui:compose:theme`](../theme/README.md)
|
||||
17
core/ui/compose/testing/build.gradle.kts
Normal file
17
core/ui/compose/testing/build.gradle.kts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.androidCompose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.k9mail.core.ui.compose.testing"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.core.testing)
|
||||
api(libs.turbine)
|
||||
api(libs.assertk)
|
||||
|
||||
implementation(projects.core.ui.compose.theme2.thunderbird)
|
||||
|
||||
implementation(libs.bundles.shared.jvm.test.compose)
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package app.k9mail.core.ui.compose.testing
|
||||
|
||||
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
|
||||
|
||||
/**
|
||||
* Base class for providing fake MVI ViewModels for testing.
|
||||
*
|
||||
* This class provides a way to capture events and emit effects on a fake ViewModel.
|
||||
* The state can be set directly using [applyState].
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* ```
|
||||
* class FakeViewModel(
|
||||
* initialState: State = State(),
|
||||
* ) : BaseFakeViewModel<State, Event, Effect>(initialState), ViewModel
|
||||
* ```
|
||||
*/
|
||||
abstract class BaseFakeViewModel<STATE, EVENT, EFFECT>(
|
||||
initialState: STATE,
|
||||
) : BaseViewModel<STATE, EVENT, EFFECT>(initialState = initialState) {
|
||||
|
||||
val events = mutableListOf<EVENT>()
|
||||
|
||||
override fun event(event: EVENT) {
|
||||
events.add(event)
|
||||
}
|
||||
|
||||
fun effect(effect: EFFECT) {
|
||||
emitEffect(effect)
|
||||
}
|
||||
|
||||
fun applyState(state: STATE) {
|
||||
updateState { state }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
@file:Suppress("TooManyFunctions")
|
||||
|
||||
package app.k9mail.core.ui.compose.testing
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.test.espresso.Espresso
|
||||
import app.k9mail.core.ui.compose.theme2.thunderbird.ThunderbirdTheme2
|
||||
import org.junit.Rule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
open class ComposeTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
fun getString(@StringRes resourceId: Int): String = RuntimeEnvironment.getApplication().getString(resourceId)
|
||||
|
||||
fun runComposeTest(testContent: ComposeContentTestRule.() -> Unit) = with(composeTestRule) {
|
||||
testContent()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the content of the test
|
||||
*/
|
||||
fun ComposeTest.setContent(content: @Composable () -> Unit) = composeTestRule.setContent(content)
|
||||
|
||||
/**
|
||||
* Set the content of the test and wrap it in the default theme.
|
||||
*/
|
||||
fun ComposeTest.setContentWithTheme(content: @Composable () -> Unit) = composeTestRule.setContent {
|
||||
ThunderbirdTheme2 {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
fun ComposeTest.onNodeWithTag(
|
||||
tag: String,
|
||||
useUnmergedTree: Boolean = false,
|
||||
) = composeTestRule.onNodeWithTag(tag, useUnmergedTree)
|
||||
|
||||
fun ComposeTest.onAllNodesWithTag(
|
||||
tag: String,
|
||||
useUnmergedTree: Boolean = false,
|
||||
) = composeTestRule.onAllNodesWithTag(tag, useUnmergedTree)
|
||||
|
||||
fun ComposeTest.onNodeWithContentDescription(
|
||||
label: String,
|
||||
substring: Boolean = false,
|
||||
ignoreCase: Boolean = false,
|
||||
useUnmergedTree: Boolean = false,
|
||||
) = composeTestRule.onNodeWithContentDescription(label, substring, ignoreCase, useUnmergedTree)
|
||||
|
||||
fun ComposeTest.onNodeWithText(
|
||||
text: String,
|
||||
substring: Boolean = false,
|
||||
ignoreCase: Boolean = false,
|
||||
useUnmergedTree: Boolean = false,
|
||||
) = composeTestRule.onNodeWithText(text, substring, ignoreCase, useUnmergedTree)
|
||||
|
||||
fun ComposeTest.onNodeWithText(
|
||||
@StringRes resourceId: Int,
|
||||
substring: Boolean = false,
|
||||
ignoreCase: Boolean = false,
|
||||
useUnmergedTree: Boolean = false,
|
||||
) = composeTestRule.onNodeWithText(getString(resourceId), substring, ignoreCase, useUnmergedTree)
|
||||
|
||||
fun ComposeTest.onNodeWithTextIgnoreCase(
|
||||
text: String,
|
||||
substring: Boolean = false,
|
||||
useUnmergedTree: Boolean = false,
|
||||
) = composeTestRule.onNodeWithText(text, substring, true, useUnmergedTree)
|
||||
|
||||
fun ComposeTest.onNodeWithTextIgnoreCase(
|
||||
@StringRes resourceId: Int,
|
||||
substring: Boolean = false,
|
||||
useUnmergedTree: Boolean = false,
|
||||
) = composeTestRule.onNodeWithText(getString(resourceId), substring, true, useUnmergedTree)
|
||||
|
||||
fun ComposeTest.onAllNodesWithText(
|
||||
text: String,
|
||||
substring: Boolean = false,
|
||||
ignoreCase: Boolean = false,
|
||||
useUnmergedTree: Boolean = false,
|
||||
) = composeTestRule.onAllNodesWithText(text, substring, ignoreCase, useUnmergedTree)
|
||||
|
||||
fun ComposeTest.onAllNodesWithContentDescription(
|
||||
label: String,
|
||||
substring: Boolean = false,
|
||||
ignoreCase: Boolean = false,
|
||||
useUnmergedTree: Boolean = false,
|
||||
) = composeTestRule.onAllNodesWithContentDescription(label, substring, ignoreCase, useUnmergedTree)
|
||||
|
||||
fun ComposeTest.onRoot(useUnmergedTree: Boolean = false) = composeTestRule.onRoot(useUnmergedTree)
|
||||
|
||||
fun ComposeTest.pressBack() = Espresso.pressBack()
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package app.k9mail.core.ui.compose.testing.mvi
|
||||
|
||||
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
|
||||
/**
|
||||
* Tests that the state of the [viewModel] changes as expected when the [event] is sent.
|
||||
*/
|
||||
suspend inline fun <reified STATE, EVENT, EFFECT> MviContext.eventStateTest(
|
||||
viewModel: UnidirectionalViewModel<STATE, EVENT, EFFECT>,
|
||||
initialState: STATE,
|
||||
event: EVENT,
|
||||
expectedState: STATE,
|
||||
) {
|
||||
val turbines = turbinesWithInitialStateCheck(viewModel, initialState)
|
||||
|
||||
viewModel.event(event)
|
||||
|
||||
assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(expectedState)
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
package app.k9mail.core.ui.compose.testing.mvi
|
||||
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.TurbineContext
|
||||
import app.cash.turbine.turbineScope
|
||||
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
|
||||
import assertk.Assert
|
||||
import assertk.all
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
||||
/**
|
||||
* The `runMviTest` function is a wrapper around `runTest` and `turbineScope`
|
||||
* that provides a MviContext to the test body.
|
||||
*/
|
||||
fun runMviTest(
|
||||
testBody: suspend MviContext.() -> Unit,
|
||||
) {
|
||||
runTest {
|
||||
val testScope = this
|
||||
turbineScope {
|
||||
val turbineContext = this
|
||||
testBody(
|
||||
DefaultMviContext(
|
||||
testScope = testScope,
|
||||
turbineContext = turbineContext,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface MviContext {
|
||||
val testScope: TestScope
|
||||
val turbineContext: TurbineContext
|
||||
}
|
||||
|
||||
class DefaultMviContext(
|
||||
override val testScope: TestScope,
|
||||
override val turbineContext: TurbineContext,
|
||||
) : MviContext
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun MviContext.advanceUntilIdle() {
|
||||
testScope.advanceUntilIdle()
|
||||
}
|
||||
|
||||
/**
|
||||
* The `turbines` extension function creates a MviTurbines instance for the given MVI ViewModel.
|
||||
*/
|
||||
inline fun <reified STATE, EVENT, EFFECT> MviContext.turbines(
|
||||
viewModel: UnidirectionalViewModel<STATE, EVENT, EFFECT>,
|
||||
): MviTurbines<STATE, EFFECT> {
|
||||
with(turbineContext) {
|
||||
return MviTurbines(
|
||||
stateTurbine = viewModel.state.testIn(testScope.backgroundScope),
|
||||
effectTurbine = viewModel.effect.testIn(testScope.backgroundScope),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `turbinesWithInitialStateCheck` extension function creates a MviTurbines instance for the given MVI ViewModel
|
||||
* and ensures that the initial state is emitted.
|
||||
*/
|
||||
suspend inline fun <reified STATE, EVENT, EFFECT> MviContext.turbinesWithInitialStateCheck(
|
||||
viewModel: UnidirectionalViewModel<STATE, EVENT, EFFECT>,
|
||||
initialState: STATE,
|
||||
): MviTurbines<STATE, EFFECT> {
|
||||
val turbines = turbines(viewModel)
|
||||
|
||||
assertThatAndMviTurbinesConsumed(
|
||||
actual = turbines.stateTurbine.awaitItem(),
|
||||
turbines = turbines,
|
||||
) {
|
||||
isEqualTo(initialState)
|
||||
}
|
||||
|
||||
return turbines
|
||||
}
|
||||
|
||||
/**
|
||||
* The `assertThatAndMviTurbinesConsumed` function ensures that the assertion passed and
|
||||
* all events in the given MviTurbines have been consumed.
|
||||
*
|
||||
* Usage:
|
||||
* val actualValue: T = getActualValue()
|
||||
* val turbines = viewModel.turbines(coroutineScope)
|
||||
* assertThatAndMviTurbinesConsumed(actualValue, turbines) {
|
||||
* // your assertion here
|
||||
* }
|
||||
*
|
||||
* @param T The type of the actual value.
|
||||
* @param STATE The type of the state.
|
||||
* @param EFFECT The type of the effect.
|
||||
* @param actual The actual value being asserted.
|
||||
* @param turbines The MviTurbines instance to check if all events are consumed.
|
||||
* @param assertion An extension function on `Assert<T>`, which is used to define assertions on the actual value.
|
||||
*/
|
||||
fun <T, STATE, EFFECT> assertThatAndMviTurbinesConsumed(
|
||||
actual: T,
|
||||
turbines: MviTurbines<STATE, EFFECT>,
|
||||
assertion: Assert<T>.() -> Unit,
|
||||
) {
|
||||
assertThat(actual).all {
|
||||
assertion()
|
||||
}
|
||||
|
||||
turbines.stateTurbine.ensureAllEventsConsumed()
|
||||
turbines.effectTurbine.ensureAllEventsConsumed()
|
||||
}
|
||||
|
||||
/**
|
||||
* The `assertThatAndStateTurbineConsumed` function ensures that the assertion passed and
|
||||
* all events in the state turbine have been consumed.
|
||||
*/
|
||||
suspend fun <STATE, EFFECT> MviTurbines<STATE, EFFECT>.assertThatAndStateTurbineConsumed(
|
||||
assertion: Assert<STATE>.() -> Unit,
|
||||
) {
|
||||
assertThat(stateTurbine.awaitItem()).all {
|
||||
assertion()
|
||||
}
|
||||
|
||||
stateTurbine.ensureAllEventsConsumed()
|
||||
effectTurbine.ensureAllEventsConsumed()
|
||||
}
|
||||
|
||||
/**
|
||||
* The `assertThatAndEffectTurbineConsumed` function ensures that the assertion passed and
|
||||
* all events in the effect turbine have been consumed.
|
||||
*/
|
||||
suspend fun <STATE, EFFECT> MviTurbines<STATE, EFFECT>.assertThatAndEffectTurbineConsumed(
|
||||
assertion: Assert<EFFECT>.() -> Unit,
|
||||
) {
|
||||
assertThat(effectTurbine.awaitItem()).all {
|
||||
assertion()
|
||||
}
|
||||
|
||||
stateTurbine.ensureAllEventsConsumed()
|
||||
effectTurbine.ensureAllEventsConsumed()
|
||||
}
|
||||
|
||||
/**
|
||||
* A container class for the state and effect turbines of an MVI ViewModel.
|
||||
*/
|
||||
data class MviTurbines<STATE, EFFECT>(
|
||||
val stateTurbine: ReceiveTurbine<STATE>,
|
||||
val effectTurbine: ReceiveTurbine<EFFECT>,
|
||||
) {
|
||||
suspend fun awaitStateItem() = stateTurbine.awaitItem()
|
||||
|
||||
suspend fun awaitEffectItem() = effectTurbine.awaitItem()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue