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.
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
}
}
Marcin Moskala is a highly experienced developer and Kotlin instructor as the founder of Kt. Academy, an official JetBrains partner specializing in Kotlin training, Google Developers Expert, known for his significant contributions to the Kotlin community. Moskala is the author of several widely recognized books, including "Effective Kotlin," "Kotlin Coroutines," "Functional Kotlin," "Advanced Kotlin," "Kotlin Essentials," and "Android Development with Kotlin."
Beyond his literary achievements, Moskala is the author of the largest Medium publication dedicated to Kotlin. As a respected speaker, he has been invited to share his insights at numerous programming conferences, including events such as Droidcon and the prestigious Kotlin Conf, the premier conference dedicated to the Kotlin programming language.