Skip to main content
The Real Clean Architecture project includes several reusable test utilities that simplify testing and reduce boilerplate. These utilities are extracted into dedicated modules.

Test Utility Modules

cache-test

In-memory cache implementations for testing

coroutines-test-dispatcher

JUnit rule for coroutine testing

flow-test-observer

Flow observer for collecting emitted values

Cache Test Utilities

TestCacheProvider

The TestCacheProvider is a test implementation of CacheProvider that uses in-memory storage.
class TestCacheProvider(
    private val expectedFileName: String,
    private val expectedDefaultValue: Any
) : CacheProvider {

    lateinit var providedCachedObject: InMemoryCachedObject<*>

    override fun <T : Any> getCachedObject(
        fileName: String,
        serializer: KSerializer<T>,
        defaultValue: T
    ): CachedObject<T> {
        return if (expectedFileName == fileName && expectedDefaultValue == defaultValue) {
            InMemoryCachedObject(defaultValue).also {
                providedCachedObject = it
            }
        } else {
            throw IllegalStateException("getCachedObject not stubbed")
        }
    }

    override fun <T : Any> getFlowCachedObject(
        fileName: String,
        serializer: KSerializer<T>,
        defaultValue: T
    ): FlowCachedObject<T> {
        return RealFlowCachedObject(getCachedObject(fileName, serializer, defaultValue))
    }
}
The TestCacheProvider validates that repositories request cache with expected parameters, catching configuration errors.

Usage Example

class RealCartRepositoryTest {
    private val cacheProvider = TestCacheProvider(
        "cart-cache",
        JsonCartCacheDto(emptyMap())
    )
    private val sut = RealCartRepository(cacheProvider)

    @Test
    fun `EXPECT data saved and cart updates emitted`() {
        val cartObserver = sut.observeCart(USER_ID).test()

        sut.updateCartItem(USER_ID, CART_ITEM)

        val finalCart = Cart(listOf(CART_ITEM))
        assertEquals(finalCart, sut.getCart(USER_ID))
        // Access the cached object directly for verification
        assertEquals(EXPECTED_DTO, cacheProvider.providedCachedObject.get())
    }
}

InMemoryCachedObject

The InMemoryCachedObject provides an in-memory implementation of CachedObject for testing.
class InMemoryCachedObject<T : Any>(
    defaultValue: T
) : CachedObject<T> {

    private var cachedValue = defaultValue

    override fun put(value: T) {
        cachedValue = value
    }

    override fun get(): T {
        return cachedValue
    }
}
InMemoryCachedObject is created automatically by TestCacheProvider, but you can also use it directly for testing custom cache logic.

Coroutines Test Dispatcher

MainCoroutineRule

The MainCoroutineRule is a JUnit rule that replaces the main dispatcher with a test dispatcher for deterministic coroutine testing.
class MainCoroutineRule : TestWatcher() {

    private val testDispatcher = UnconfinedTestDispatcher()

    override fun starting(description: Description?) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description?) {
        Dispatchers.resetMain()
    }
}
Always include MainCoroutineRule when testing ViewModels or other classes that use Dispatchers.Main.

Usage Example

class RealCartViewModelTest {
    @get:Rule
    val rule = MainCoroutineRule()

    private lateinit var sut: RealCartViewModel

    @Before
    fun setUp() {
        sut = RealCartViewModel(observeUserCart, updateCartItem, StateDelegate())
    }

    @Test
    fun `EXPECT cart updates`() = runTest {
        // Coroutines run on the test dispatcher
        observeUserCart.cartUpdates.emit(CART)
        
        // State changes happen immediately
        assertEquals(expectedState, stateObserver.getValues().last())
    }
}
1

Apply the Rule

Use @get:Rule to apply the MainCoroutineRule to your test class.
2

Write Tests Normally

Use runTest for suspend functions. Coroutines execute immediately on the test dispatcher.
3

Automatic Cleanup

The rule automatically resets the main dispatcher after each test.

Flow Test Observer

FlowTestObserver

The FlowTestObserver collects all values emitted by a Flow for easy assertion in tests.
class FlowTestObserver<T : Any>(
    private val flow: Flow<T>,
    coroutineScope: CoroutineScope
) {
    private val emittedValues = mutableListOf<T>()
    private val job: Job = flow.onEach {
        emittedValues.add(it)
    }.launchIn(coroutineScope)

    fun getValues() = emittedValues

    fun stopObserving() {
        job.cancel()
    }

    fun getFlow() = flow
}

fun <T : Any> Flow<T>.test(
    coroutineScope: CoroutineScope = CoroutineScope(UnconfinedTestDispatcher())
) = FlowTestObserver(this, coroutineScope)

Usage Examples

@Test
fun `EXPECT data saved and cart updates emitted`() {
    val cartObserver = sut.observeCart(USER_ID).test()

    sut.updateCartItem(USER_ID, CART_ITEM)

    assertEquals(
        listOf(Cart(emptyList()), Cart(listOf(CART_ITEM))),
        cartObserver.getValues()
    )
}
@Test
fun `EXPECT cart updates`() {
    testCartRepository.cartUpdates[USER_ID] = flowOf(
        Cart(emptyList()), 
        Cart(CART_ITEMS)
    )

    val testObserver = sut().test()

    assertEquals(
        listOf(Cart(emptyList()), Cart(CART_ITEMS)),
        testObserver.getValues()
    )
}
@Test
fun `EXPECT cart updates`() = runTest {
    val stateObserver = sut.state.test()
    
    observeUserCart.cartUpdates.emit(CART)

    assertEquals(
        listOf(
            CartScreenState(Cart(emptyList())),
            CartScreenState(CART)
        ),
        stateObserver.getValues()
    )
}
The .test() extension function creates a FlowTestObserver with sensible defaults. You can provide a custom coroutine scope if needed.

Managing Observer Lifecycle

You can control when to stop observing:
@Test
fun `EXPECT updates only while observing`() {
    val observer = sut.observeCart(USER_ID).test()
    
    sut.updateCartItem(USER_ID, CART_ITEM_1)
    observer.stopObserving()
    sut.updateCartItem(USER_ID, CART_ITEM_2) // Not collected
    
    assertEquals(
        listOf(Cart(emptyList()), Cart(listOf(CART_ITEM_1))),
        observer.getValues()
    )
}

Test Repository Implementations

Each component includes test implementations of its repositories for use in use case tests.

Example: TestCartRepository

class TestCartRepository : CartRepository {
    val updateCartItemInvocations: MutableList<Pair<String, CartItem>> = mutableListOf()
    val cartUpdates = mutableMapOf<String, Flow<Cart>>()
    val carts = mutableMapOf<String, Cart>()

    override fun updateCartItem(userId: String, cartItem: CartItem) {
        updateCartItemInvocations.add(userId to cartItem)
    }

    override fun observeCart(userId: String): Flow<Cart> {
        return cartUpdates[userId] ?: throw IllegalStateException("no stubbing for userId")
    }

    override fun getCart(userId: String): Cart {
        return carts[userId] ?: throw IllegalStateException("no stubbing for userId")
    }
}
Test repositories track invocations for verification and allow stubbing return values through public properties.

Usage Pattern

class ObserveUserCartUseCaseTest {
    private val testCartRepository = TestCartRepository()
    private val sut = ObserveUserCartUseCase({ USER }, testCartRepository)

    @Test
    fun `EXPECT cart updates`() {
        // Stub the return value
        testCartRepository.cartUpdates[USER_ID] = flowOf(Cart(emptyList()), Cart(CART_ITEMS))

        // Execute
        val testObserver = sut().test()

        // Verify
        assertEquals(
            listOf(Cart(emptyList()), Cart(CART_ITEMS)),
            testObserver.getValues()
        )
    }
}

Test Fixtures

Test fixtures provide reusable test data creation.

Example: CartItemFixture

fun makeCartItem(
    price: Double = 99.99,
    quantity: Int = 1,
    id: String = "1",
    name: String = "Wireless Headphones",
    imageUrl: String = "https://m.media-amazon.com/images/I/61fU3njgzZL._AC_SL1500_.jpg",
    currency: String = "$"
) = CartItem(
    id = id,
    name = name,
    price = Money(price, currency),
    imageUrl = imageUrl,
    quantity = quantity
)
Fixture functions use default parameters so tests only specify values relevant to the test case.

Usage

@Test
fun `EXPECT sum of product per their quantity WHEN cart is not empty`() {
    val sut = Cart(
        listOf(
            makeCartItem(price = 0.99, quantity = 1),
            makeCartItem(price = 25.00, quantity = 3),
            makeCartItem(price = 30.00, quantity = 2),
            makeCartItem(price = 15.00, quantity = 1)
        )
    )

    val result = sut.getSubtotal()

    assertEquals(Money(150.99, "$"), result)
}

Network Testing with NetMock

While not a custom utility, the project uses NetMock for deterministic HTTP testing.
class RealUserRepositoryTest {
    private val config = MockEngineConfig()
    private val netMock = NetMockEngine(config)
    private val client = createClient(netMock)

    @Test
    fun `EXPECT success and data cached WHEN response is successful`() = runTest {
        netMock.addMock(
            request = NetMockRequest(
                requestUrl = "https://api.json-generator.com/templates/Q7s_NUVpyBND/data",
                method = Method.Post,
                mandatoryHeaders = REQUEST_HEADERS,
                body = REQUEST_BODY
            ),
            response = {
                code = 200
                mandatoryHeaders = RESPONSE_HEADERS
                body = RESPONSE_BODY
            }
        )

        val result = sut.login(LOGIN_REQUEST)

        assertEquals(Answer.Success(Unit), result)
    }
}

Deterministic

Mock responses are configured per test for predictable behavior

Type-Safe

Request and response matching with compile-time safety

Best Practices

1

Use Test Utilities Consistently

Apply the same test utilities across all tests for consistency and maintainability.
2

Create Test Doubles per Component

Each component provides test implementations of its public interfaces for use by other components.
3

Minimize Test Boilerplate

Use fixture functions and test utilities to keep tests focused on behavior, not setup.
4

Verify All Emitted Values

When testing Flows, verify the complete sequence of emitted values, not just the final state.

Summary

Cache Testing

TestCacheProvider and InMemoryCachedObject for in-memory cache testing

Coroutine Testing

MainCoroutineRule for deterministic coroutine execution in tests

Flow Testing

FlowTestObserver for collecting and asserting Flow emissions

Test Doubles

Component-specific test repositories and fixtures for reusable test data

Unit Testing

See these utilities in action in unit tests

Testing Overview

Understand the overall testing strategy

Build docs developers (and LLMs) love