Real World Example

Testing a Wallet Transfer Use Case

A UPI wallet must never debit money without crediting it, and must reject transfers above the balance. Business rules like these are tested with fast unit tests using fakes, on every pull request in CI. The tiny test helper below runs without JUnit so you can see the idea; in a real project the same checks are JUnit @Test functions with runTest.

Example

import kotlinx.coroutines.*

class InsufficientBalance(msg: String) : Exception(msg)

interface WalletStore {
    suspend fun balance(user: String): Int
    suspend fun set(user: String, amount: Int)
}

class FakeWalletStore(vararg start: Pair<String, Int>) : WalletStore {
    val data = sortedMapOf(*start)
    override suspend fun balance(user: String) = data[user] ?: 0
    override suspend fun set(user: String, amount: Int) { data[user] = amount }
}

class TransferUseCase(private val store: WalletStore) {
    suspend operator fun invoke(from: String, to: String, amount: Int) {
        require(amount > 0) { "amount must be positive" }
        val fromBal = store.balance(from)
        if (fromBal < amount) throw InsufficientBalance("balance $fromBal < $amount")
        store.set(from, fromBal - amount)
        store.set(to, store.balance(to) + amount)
    }
}

var passed = 0
fun test(name: String, body: suspend () -> Unit) = runBlocking {
    try { body(); passed++; println("PASS  $name") } catch (e: Throwable) { println("FAIL  $name: ${e.message}") }
}
inline fun <reified E : Throwable> assertThrows(block: () -> Unit) {
    try { block() } catch (e: Throwable) { if (e is E) return; throw AssertionError("wrong exception ${e::class.simpleName}") }
    throw AssertionError("expected ${E::class.simpleName}")
}

fun main() {
    test("moves money between wallets") {
        val store = FakeWalletStore("asha" to 500, "ravi" to 100)
        TransferUseCase(store)("asha", "ravi", 200)
        check(store.data == sortedMapOf("asha" to 300, "ravi" to 300)) { "got ${store.data}" }
    }
    test("rejects transfer above balance and changes nothing") {
        val store = FakeWalletStore("asha" to 50)
        assertThrows<InsufficientBalance> { runBlocking { TransferUseCase(store)("asha", "ravi", 200) } }
        check(store.data == sortedMapOf("asha" to 50)) { "got ${store.data}" }
    }
    test("rejects zero amount") {
        assertThrows<IllegalArgumentException> { runBlocking { TransferUseCase(FakeWalletStore())("a", "b", 0) } }
    }
    println("$passed of 3 tests passed")
}

Output

PASS  moves money between wallets
PASS  rejects transfer above balance and changes nothing
PASS  rejects zero amount
3 of 3 tests passed

The JUnit Version

class TransferUseCaseTest {
    @Test
    fun `rejects transfer above balance and changes nothing`() = runTest {
        val store = FakeWalletStore("asha" to 50)
        assertThrows<InsufficientBalance> { TransferUseCase(store)("asha", "ravi", 200) }
        assertEquals(mapOf("asha" to 50), store.data)
    }
}

Example

In CI (GitHub Actions) the tests run on every push:

./gradlew test --continue

Example output

> Task :wallet:test
TransferUseCaseTest > moves money between wallets() PASSED
TransferUseCaseTest > rejects transfer above balance and changes nothing() PASSED
TransferUseCaseTest > rejects zero amount() PASSED

BUILD SUCCESSFUL in 6s