Baeldung Pro – Kotlin – NPI EA (cat = Baeldung on Kotlin)
announcement - icon

Learn through the super-clean Baeldung Pro experience:

>> Membership and Baeldung Pro.

No ads, dark-mode and 6 months free of IntelliJ Idea Ultimate to start with.

1. Introduction

StateFlows are an integral part of modern Kotlin applications. They are part of the Kotlin Coroutines library and provide a way to manage and observe state changes reactively. Testing these flows ensures that our applications behave as expected under various conditions.

In this article, we’ll explore multiple approaches and best practices to effectively unit test StateFlows.

2. Understanding StateFlow Fundamentals

When working with state management in Kotlin, we often encounter various approaches. This might include simple variables to reactive streams. StateFlow sits at a sweet spot between these approaches. It combines the reactive nature of Flows with state-holding abilities.

Let’s look at a basic implementation of StateFlow:

class StateFlowExample {
    private val _state = MutableStateFlow("Initial State")
    private val state: StateFlow<String> = _state.asStateFlow()

    fun getCurrentState() = state.value

    fun updateState(newValue: String) {
        _state.value = newValue
    }
}

In this example, we create a MutableStateFlow with an initial value. The state is exposed as an immutable StateFlow to prevent external modifications.

StateFlow maintains a current value that can be accessed synchronously while providing a way to observe changes over time. Also, unlike regular Flows, StateFlow is “hot”. This means it’s always active and maintains its current state regardless of collectors.

These characteristics make it suitable for managing the application state, where we need both the current value and updates to that value. When a new collector starts observing a StateFlow, it immediately receives the current state and subsequently receives any new state updates.

3. Setting Up for StateFlow Testing

Testing StateFlows requires careful setup of the testing environment. The key is creating a controlled environment where we can predictably emit states and verify our flow’s behavior.

The kotlinx-coroutines-test library provides essential tools for testing coroutines and flows. It offers utilities to control coroutine execution and manage test scopes.

Let’s build on our previous StateExample class and create a proper test environment. First, we’ll set up a basic test class:

class StateFlowUnitTest {
    private lateinit var stateFlowExample: StateFlowExample

    @Test
    fun `initial state is correct`() = runTest {
        stateFlowExample = StateFlowExample()

        // Initial state verification
        assertEquals("Initial State", stateFlowExample.getCurrentState())
    }
}

In our example, we create a new instance of the StateFlowExample class and verify that the initial state is as expected.

To test this state holder effectively, we need a controlled environment. The runTest builder from the kotlinx-coroutines-test library is crucial for that.

The runTest builder creates a coroutine scope specifically designed for testing. This scope uses a special test dispatcher that gives us control over virtual time, which helps make our tests both reliable and fast.

With this testing environment in place, let’s explore how to test different StateFlow scenarios, starting with the simplest case: single-state emissions.

4. Testing Single Emissions

Let’s start with the simplest case: testing what happens when we update our state once. We want to verify that when we update the state, the new value is correctly reflected in our StateFlow.

Let’s expand our previous test class to include state change verification:

@Test
fun `state updates properly`() = runTest {
    stateFlowExample = StateFlowExample()

    stateFlowExample.updateState("New State")

    assertEquals("New State", stateFlowExample.getCurrentState())
}

In this test, we’re verifying two important aspects of StateFlow behavior. First, we verify that the update correctly overrides the initial value. Second, we confirm that updating the state changes the current value.

While testing single updates is straightforward, real-world applications often need to handle a stream of updates. Let’s explore how to test these continuous state changes.

5. Testing Continuous State Updates

When working with StateFlow in real applications, we rarely deal with a single state update.

Let’s think about a typical user session: a user logs in, performs various actions, updates their profile, and logs out. Each of these actions might trigger state changes our application needs to handle. We need to verify not just that each state update works, but that our StateFlow correctly manages this entire sequence of changes. This becomes especially important when multiple parts of our application are observing these state changes and need to react to them reliably.

The following test demonstrates how to verify a sequence of state updates by collecting and asserting multiple state changes:

@Test
fun `collects all state updates`() = runTest {
    stateFlowExample = StateFlowExample()
    val collectedStates = mutableListOf<String>()

    val collectJob = launch {
        stateFlowExample.state.collect { state ->
          collectedStates.add(state)
        }
    }
    // Advance the time to make the initial state available for collection
    advanceUntilIdle()

    stateFlowExample.updateState("First Update")
    advanceUntilIdle() // Advance the time to make the first update available

    stateFlowExample.updateState("Second Update")
    advanceUntilIdle() // Advance the time to make the second update available

    stateFlowExample.updateState("Third Update")
    advanceUntilIdle() // Advance the time to make the third update available

    assertEquals(4, collectedStates.size) // Initial state + 3 updates
    assertEquals("Initial State", collectedStates[0])
    assertEquals("First Update", collectedStates[1])
    assertEquals("Second Update", collectedStates[2])
    assertEquals("Third Update", collectedStates[3])

    // Cancel the job to stop collecting states and let the test finish
    collectJob.cancel()
}

Let’s break down what’s happening in this test. We start by collecting states from our StateFlow into a list – this lets us verify both the sequence and content of state changes.

The launch coroutine builder creates a new coroutine that collects states throughout our test. The collect function observes all state changes and adds each new state to our list as it is collected.

Each time we update the state, we need to ensure the update has been processed before moving forward. The testing library gives us a few ways to handle this timing. In this example, we’re using advanceUntilIdle(), but we could also use yield() or advanceTimeBy(timeMillis). Each approach has its use cases, but advanceUntilIdle() is often the simplest choice as it ensures all pending work is completed before continuing.

Now that we understand how to test both single updates and continuous state changes, let’s explore how to test StateFlows created using the stateIn operator, which introduces its own set of testing considerations.

6. Testing stateIn Transformations

The stateIn operator converts a regular Flow into a StateFlow. We can use this transformation when we need to combine the reactive nature of Flows with the state-holding capabilities of StateFlows. However, testing these transformations requires some additional considerations.

Let’s look at a simple example that mirrors how we might transform data from a repository or database in a real application:

class DataTransformer(scope: CoroutineScope) {
    private val _dataFlow = MutableSharedFlow<String>()  // Simulates a repository stream

    val transformedData = _dataFlow
      .map { it.uppercase() }
      .stateIn(
        scope = scope,
        started = SharingStarted.Eagerly,
        initialValue = "INITIAL"
      )

    suspend fun emit(value: String) {
        _dataFlow.emit(value)
    }
}

Here’s how we can test these transformations:

@Test
fun `transforms to uppercase states`() = runTest {
    val testScope = TestScope(UnconfinedTestDispatcher())
    val transformer = DataTransformer(testScope)
    val collectedStates = mutableListOf<String>()

    val collectJob = launch {
        transformer.transformedData.collect { state ->
          collectedStates.add(state)
        }
    }
    advanceUntilIdle() // Wait for initial collection

    transformer.emit("first")
    advanceUntilIdle() // Wait for first emission

    transformer.emit("second")
    advanceUntilIdle() // Wait for second emission

    assertEquals(3, collectedStates.size)
    assertEquals("INITIAL", collectedStates[0])
    assertEquals("FIRST", collectedStates[1])
    assertEquals("SECOND", collectedStates[2])

    collectJob.cancel()
    testScope.cancel()
}

Each sharing strategy serves different use cases, and our tests need to reflect these differences. The key is understanding when transformations begin and how they respond to collectors.

The use of UnconfinedTestDispatcher here is notable. It executes coroutines eagerly and immediately in the same thread, rather than dispatching or queuing them. This means that when we emit a value, its transformation (uppercase) and collection happen immediately one after the other. This makes our tests more predictable.

7. Conclusion

In this tutorial, we’ve explored different testing scenarios in this article. These range from simple state updates to continuous emissions and StateFlow transformations.

We also explored some tools from the kotlinx-coroutines-test library. We can use them to write reliable tests for our StateFlow implementations.

The code backing this article is available on GitHub. Once you're logged in as a Baeldung Pro Member, start learning and coding on the project.