Solution: Test UserDetailsRepository

This is a possible solution:

@Test fun `should fetch details asynchronously`() = runTest { // given val client = object : UserDataClient { override suspend fun getName(): String { delay(100) return "Ben" } override suspend fun getFriends(): List<Friend> { delay(200) return listOf(Friend("friend-id-1")) } override suspend fun getProfile(): Profile { delay(300) return Profile("Example description") } } var savedDetails: UserDetails? = null val database = object : UserDetailsDatabase { override suspend fun load(): UserDetails? { delay(500) return savedDetails } override suspend fun save(user: UserDetails) { delay(400) savedDetails = user } } val repo = UserDetailsRepository( client = client, userDatabase = database, backgroundScope = backgroundScope ) // when val details = repo.getUserDetails() // then data are fetched asynchronously assertEquals("Ben", details.name) assertEquals(listOf(Friend("friend-id-1")), details.friends) assertEquals(Profile("Example description"), details.profile) assertEquals(350, currentTime) // max(100, 200, 300) + 500 assertEquals(null, savedDetails) // when all children are finished backgroundScope.coroutineContext.job.children .forEach { it.join() } // then data are saved to the database assertEquals(750, currentTime) // prev + 400 assertEquals(details, savedDetails) // when getting details again val details2 = repo.getUserDetails() // then data are loaded from the database assertEquals(details, details2) assertEquals(1250, currentTime) // prev + 500 }

Notice that we assert that:

  • the name, friends, and profile are fetched asynchronously by delaying the response, and verify that the overall time is the maximum of the delays,
  • the fetched details are returned by asserting the result,
  • the details are saved to the database asynchronously by delaying this operation, verifying that after the getUserDetails call, the details are not saved yet, and then awaiting the background scope to finish the operation, and checking that the details are saved, and the time is greater by the save delay,
  • the details are loaded from the database if they are already there by delaying the load operation and verifying that the details are returned, and that it took only the load delay time.

It would be a better practice to divide this complex test into smaller ones, but the task was to test multiple behaviors in one test.

Example solution in playground

import kotlinx.coroutines.* import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals class UserDetailsRepository( private val client: UserDataClient, private val userDatabase: UserDetailsDatabase, private val backgroundScope: CoroutineScope, ) { suspend fun getUserDetails(): UserDetails = coroutineScope { val stored = userDatabase.load() if (stored != null) { return@coroutineScope stored } val name = async { client.getName() } val friends = async { client.getFriends() } val profile = async { client.getProfile() } val details = UserDetails( name = name.await(), friends = friends.await(), profile = profile.await(), ) backgroundScope.launch { userDatabase.save(details) } details } } interface UserDataClient { suspend fun getName(): String suspend fun getFriends(): List<Friend> suspend fun getProfile(): Profile } interface UserDetailsDatabase { suspend fun load(): UserDetails? suspend fun save(user: UserDetails) } data class UserDetails( val name: String, val friends: List<Friend>, val profile: Profile ) data class Friend(val id: String) data class Profile(val description: String) @Suppress("FunctionName") class UserDetailsRepositoryTest { @Test fun `should fetch details asynchronously`() = runTest { // given val client = object : UserDataClient { override suspend fun getName(): String { delay(100) return "Ben" } override suspend fun getFriends(): List<Friend> { delay(200) return listOf(Friend("friend-id-1")) } override suspend fun getProfile(): Profile { delay(300) return Profile("Example description") } } var savedDetails: UserDetails? = null val database = object : UserDetailsDatabase { override suspend fun load(): UserDetails? { delay(500) return savedDetails } override suspend fun save(user: UserDetails) { delay(400) savedDetails = user } } val repo = UserDetailsRepository( client = client, userDatabase = database, backgroundScope = backgroundScope ) // when val details = repo.getUserDetails() // then data are fetched asynchronously assertEquals("Ben", details.name) assertEquals(listOf(Friend("friend-id-1")), details.friends) assertEquals(Profile("Example description"), details.profile) assertEquals(350, currentTime) // max(100, 200, 300) + 500 assertEquals(null, savedDetails) // when all children are finished backgroundScope.coroutineContext.job.children .forEach { it.join() } // then data are saved to the database assertEquals(750, currentTime) // prev + 400 assertEquals(details, savedDetails) // when getting details again val details2 = repo.getUserDetails() // then data are loaded from the database assertEquals(details, details2) assertEquals(1250, currentTime) // prev + 500 } }