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.
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:
Coroutine Scopes and Dispatchers: With scopes and dispatchers implemented directly within classes, testing became difficult because the coroutine behavior couldn't be controlled externally.
System Time Dependencies: Using System.currentTimeMillis()
directly meant time couldn't be controlled in test scenarios, making it impossible to verify time-based logic.
By passing dependencies as constructor parameters, I decoupled my controllers from their implementation details.
class StopwatchController {
private val scope = CoroutineScope(Dispatchers.Main)
fun start() {
scope.launch {
val startTime = System.currentTimeMillis()
// Logic using real system time and uncontrollable coroutines
}
}
}
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
}
}
}
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.
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.
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)
}
}
}
}
}
}
// ...
}
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
.
Using dependency injection for both time and coroutine-related components has enabled several testing advantages:
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.
Controlled Coroutine Execution: Using TestDispatcherProvider
and TestScope
, I can control when coroutines execute and advance time without waiting for real-time delays.
State Verification: I can check that state transitions occur correctly in response to time changes and user actions.
Edge Case Testing: I can easily test boundary conditions and race conditions by precisely controlling time advances.
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.