/** * Complete Repository Test Example * * Key patterns demonstrated: * - Fake for disk sources, Mock for network services * - Using FakeDispatcherManager for deterministic coroutines * - Using fixed Clock for deterministic time * - Testing Result types with .asSuccess() / .asFailure() * - Asserting actual objects (not isSuccess/isFailure) for better diagnostics * - Testing Flow emissions with Turbine */ package com.bitwarden.example.data.repository import app.cash.turbine.test import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.core.data.util.asFailure import com.bitwarden.core.data.util.asSuccess import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.time.Clock import java.time.Instant import java.time.ZoneOffset class ExampleRepositoryTest { // Fixed clock for deterministic time-based tests private val fixedClock: Clock = Clock.fixed( Instant.parse("2023-10-27T12:00:00Z"), ZoneOffset.UTC, ) // Use FakeDispatcherManager for deterministic coroutine execution private val dispatcherManager = FakeDispatcherManager() // Mock service (network layer is always mocked) private val mockService: ExampleService = mockk() /** * PATTERN: Use Fake for disk source in happy path tests. * This is the Bitwarden convention for repository testing. */ private val fakeDiskSource = FakeExampleDiskSource() private lateinit var repository: ExampleRepositoryImpl @BeforeEach fun setup() { repository = ExampleRepositoryImpl( clock = fixedClock, service = mockService, diskSource = fakeDiskSource, dispatcherManager = dispatcherManager, ) } // ==================== HAPPY PATH TESTS (use Fake) ==================== /** * Test: Successful fetch returns data and saves to disk */ @Test fun `fetchData should return success and save to disk when service succeeds`() = runTest { val expectedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant()) coEvery { mockService.getData() } returns expectedData.asSuccess() val result = repository.fetchData() assertEquals(expectedData, result.getOrThrow()) // Fake automatically stores the data - verify it's there assertEquals(expectedData, fakeDiskSource.storedData) } /** * Test: Service failure returns failure without saving */ @Test fun `fetchData should return failure when service fails`() = runTest { val exception = Exception("Network error") coEvery { mockService.getData() } returns exception.asFailure() val result = repository.fetchData() assertEquals(exception, result.exceptionOrNull()) // Fake was not updated assertNull(fakeDiskSource.storedData) } /** * Test: Repository flow emits when disk source updates */ @Test fun `dataFlow should emit when disk source updates`() = runTest { val data1 = ExampleData(id = "1", name = "First", updatedAt = fixedClock.instant()) val data2 = ExampleData(id = "2", name = "Second", updatedAt = fixedClock.instant()) repository.dataFlow.test { // Initial null value from Fake assertNull(awaitItem()) // Update via Fake property setter (triggers emission) fakeDiskSource.storedData = data1 assertEquals(data1, awaitItem()) // Another update fakeDiskSource.storedData = data2 assertEquals(data2, awaitItem()) } } /** * Test: Refresh fetches and saves new data */ @Test fun `refresh should fetch new data and update disk source`() = runTest { val newData = ExampleData(id = "new", name = "Fresh", updatedAt = fixedClock.instant()) coEvery { mockService.getData() } returns newData.asSuccess() val result = repository.refresh() assertEquals(Unit, result.getOrThrow()) coVerify { mockService.getData() } assertEquals(newData, fakeDiskSource.storedData) } /** * Test: Delete clears data from disk */ @Test fun `deleteData should clear disk source`() = runTest { // Pre-populate the fake fakeDiskSource.storedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant()) repository.deleteData() assertNull(fakeDiskSource.storedData) } /** * Test: Cached data returns from disk when available */ @Test fun `getCachedData should return disk data without network call`() = runTest { val cachedData = ExampleData( id = "cached", name = "Cached", updatedAt = fixedClock.instant(), ) fakeDiskSource.storedData = cachedData val result = repository.getCachedData() assertEquals(cachedData, result) coVerify(exactly = 0) { mockService.getData() } } // ==================== ERROR PATH TESTS ==================== /** * PATTERN: For error paths, reconfigure the class-level mock per-test. * Use coEvery to change mock behavior for each specific test case. */ @Test fun `fetchData should return failure when service returns error`() = runTest { val exception = Exception("Server unavailable") coEvery { mockService.getData() } returns exception.asFailure() val result = repository.fetchData() assertEquals(exception, result.exceptionOrNull()) // Fake state unchanged on failure assertNull(fakeDiskSource.storedData) } @Test fun `refresh should return failure and preserve cached data when service fails`() = runTest { // Pre-populate cache via Fake val cachedData = ExampleData(id = "cached", name = "Old", updatedAt = fixedClock.instant()) fakeDiskSource.storedData = cachedData // Reconfigure mock to return failure coEvery { mockService.getData() } returns Exception("Network error").asFailure() val result = repository.refresh() assertTrue(result.isFailure) // Cached data preserved on failure assertEquals(cachedData, fakeDiskSource.storedData) } } // Example types (normally in separate files) data class ExampleData( val id: String, val name: String, val updatedAt: Instant, ) interface ExampleService { suspend fun getData(): Result } interface ExampleDiskSource { val dataFlow: kotlinx.coroutines.flow.Flow fun getData(): ExampleData? fun saveData(data: ExampleData) fun clearData() } /** * PATTERN: Fake implementation for happy path testing. * * Key characteristics: * - Uses bufferedMutableSharedFlow(replay = 1) for proper replay behavior * - Uses .onSubscription { emit(state) } for immediate state emission * - Private storage with override property setter that emits to flow * - Test assertions done via the override property getter */ class FakeExampleDiskSource : ExampleDiskSource { private var storedDataValue: ExampleData? = null private val mutableDataFlow = bufferedMutableSharedFlow(replay = 1) /** * Override property with getter/setter. Setter emits to flow automatically. * Tests can read this property for assertions and write to trigger emissions. */ var storedData: ExampleData? get() = storedDataValue set(value) { storedDataValue = value mutableDataFlow.tryEmit(value) } override val dataFlow: Flow get() = mutableDataFlow.onSubscription { emit(storedData) } override fun getData(): ExampleData? = storedData override fun saveData(data: ExampleData) { storedData = data } override fun clearData() { storedData = null } }