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 ())
}
}
Apply the Rule
Use @get:Rule to apply the MainCoroutineRule to your test class.
Write Tests Normally
Use runTest for suspend functions. Coroutines execute immediately on the test dispatcher.
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
Testing Repository Flow Emissions
@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
Use Test Utilities Consistently
Apply the same test utilities across all tests for consistency and maintainability.
Create Test Doubles per Component
Each component provides test implementations of its public interfaces for use by other components.
Minimize Test Boilerplate
Use fixture functions and test utilities to keep tests focused on behavior, not setup.
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