Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,3 @@
## Core - UI - Compose - Testing
Uses [`:core:ui:compose:theme`](../theme/README.md)

View 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)
}

View file

@ -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 }
}
}

View file

@ -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()

View file

@ -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)
}

View file

@ -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()
}