Making Android Code Testable : Coroutine & Time

Skele·2025년 5월 14일
0

Android

목록 보기
15/15

Testing Android applications can be challenging, especially when dealing with components that rely on system resources, time functions, or coroutines. In this article, I'll share how dependency injection can transform hard-to-test code into testable components, drawing from actual codes from my project.

The Testing Challenge

When I first started writing tests for my Android application, I encountered a common roadblock: classes with hard-coded dependencies were nearly impossible to test effectively. Two major pain points emerged:

  1. Coroutine Scopes and Dispatchers: With scopes and dispatchers implemented directly within classes, testing became difficult because the coroutine behavior couldn't be controlled externally.

  2. System Time Dependencies: Using System.currentTimeMillis() directly meant time couldn't be controlled in test scenarios, making it impossible to verify time-based logic.

Solution: Dependency Injection

By passing dependencies as constructor parameters, I decoupled my controllers from their implementation details.

Direct Implementation (Hard to Test)

class StopwatchController {
    private val scope = CoroutineScope(Dispatchers.Main)
    
    fun start() {
        scope.launch {
            val startTime = System.currentTimeMillis()
            // Logic using real system time and uncontrollable coroutines
        }
    }
}

Dependency Injection through constructor (Testable)

class DefaultStopwatchController @Inject constructor(
    private val scope: CoroutineScope,
    private val dispatchers: DispatchersProvider,
    private val time: TimeProvider
) : StopwatchController {
    
    override fun start() {
        var timeMillis = time.currentTimeMillis()
        stopwatchJob = scope.launch(dispatchers.default) {
            // Logic with injected dependencies that can be mocked
        }
    }
}

Time Provider Pattern

One of the most effective patterns I implemented was a TimeProvider interface:

interface TimeProvider {
    fun currentTimeMillis(): Long
}

// Default implementation for production
class DefaultTimeProvider : TimeProvider {
    override fun currentTimeMillis(): Long = System.currentTimeMillis()
}

// Test implementation
class TestTimeProvider : TimeProvider {
    private var currentTime = 0L
    
    override fun currentTimeMillis(): Long = currentTime
    
    fun setCurrentTime(time: Long) {
        currentTime = time
    }
    
    fun advanceTimeBy(milliseconds: Long) {
        currentTime += milliseconds
    }
}

This pattern allowed me to precisely control time in tests, simulating various scenarios without waiting for actual time to pass.

Dispatchers Provider Pattern

Similarly, I created a DispatchersProvider interface to control coroutine dispatchers:

interface DispatchersProvider {
    val main: CoroutineDispatcher
    val io: CoroutineDispatcher
    val default: CoroutineDispatcher
}

// Default implementation for production
class DefaultDispatchersProvider : DispatchersProvider {
    override val main: CoroutineDispatcher = Dispatchers.Main
    override val io: CoroutineDispatcher = Dispatchers.IO
    override val default: CoroutineDispatcher = Dispatchers.Default
}

// Test implementation
class TestDispatcherProvider : DispatchersProvider {
    val testDispatcher = StandardTestDispatcher()

    override val main: CoroutineDispatcher = testDispatcher
    override val io: CoroutineDispatcher = testDispatcher
    override val default: CoroutineDispatcher = testDispatcher
}

This abstraction allowed me to replace real dispatchers with test dispatchers, giving complete control over coroutine execution in tests.

Real-World Implementation: Timer Controller

Let's look at my actual implementation of a TimerController.
The implementation uses dependency injection to make testing possible.

class DefaultTimerController @Inject constructor(
    private val scope: CoroutineScope,
    private val dispatchers: DispatchersProvider,
    private val time: TimeProvider
) : TimerController {
    private val _timerState = MutableStateFlow<TimerState>(TimerState.Idle(TimerData()))
    override val timerState: StateFlow<TimerState>
        get() = _timerState.asStateFlow()

    private var timerJob: Job? = null

    override fun start() {
		// ...

		// Use injected TimeProvider.
        var timeMillis = time.currentTimeMillis()
        // Use external scope and dispatchers.
        timerJob = scope.launch(dispatchers.default) {
            supervisorScope {
                _timerState.value = TimerState.Running(_timerState.value.data)
                while (_timerState.value is TimerState.Running && _timerState.value.remainingTime > 0) {
                    delay(if (_timerState.value.remainingTime > 100L) 100L else _timerState.value.remainingTime)
                    val now = time.currentTimeMillis()
                    val delta = now - timeMillis
                    timeMillis = now
                    _timerState.update { state ->
                        val remainingTime = state.data.remainingTime - delta

                        if (remainingTime <= 0) {
                            state.toState<TimerState.Finished>(0)
                        } else {
                            state.toState<TimerState.Running>(remainingTime)
                        }
                    }
                }
            }
        }
    }
    
    // ...
}

Testing

With this design, I was able to write test code for the timer controller.

@Test
fun `remaining timer is updated correctly`() =
    testScope.runTest {
        timerController.setTimer(3000L)
        timerController.start()

		// Time can be manipulated for testing.
        testTimeProvider.advanceTime(2000L)
        // Test scope can be used to advance time in an instant.
        advanceTimeBy(2000L)
        runCurrent()

        timerController.pause()
        val pausedTime = timerController.timerState.value
        assertTrue(pausedTime is TimerState.Paused)
        assertEquals(1000L, pausedTime.data.remainingTime)
        timerController.resume()

        testTimeProvider.advanceTime(3000L)
        advanceTimeBy(3000L)
        runCurrent()

        val state = timerController.timerState.value
        assertTrue(state is TimerState.Finished)
        assertEquals(0L, state.data.remainingTime)
    }

The test above verifies that when a timer is paused, the remaining time stays constant even as the system time advances. This kind of verification would be impossible without the ability to control time through the TestTimeProvider.

Summary

Using dependency injection for both time and coroutine-related components has enabled several testing advantages:

  1. Precise Time Control: With TestTimeProvider, I can set specific moments in time and advance time by exact amounts, allowing me to verify time-dependent logic.

  2. Controlled Coroutine Execution: Using TestDispatcherProvider and TestScope, I can control when coroutines execute and advance time without waiting for real-time delays.

  3. State Verification: I can check that state transitions occur correctly in response to time changes and user actions.

  4. Edge Case Testing: I can easily test boundary conditions and race conditions by precisely controlling time advances.

  5. Fast and Reliable Tests: Tests run instantly and consistently, without flakiness from real-time dependencies.

Making code testable through dependency injection has changed my development process. By decoupling classes from their dependencies and providing testable implementations, I was able to verify behavior that was previously untestable.

There must be well-developed libraries that solve these testing challenges, but I wanted to solve the problem by implementing the solutions in my own code. The approach wasn't overly complicated, and building it myself gave me a deeper understanding of the testing challenges and how to address them.

profile
Tireless And Restless Debugging In Source : TARDIS

0개의 댓글