ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Follow publication

Photo by János Venczák on Unsplash

Write Testable Time-Dependent Coroutine Code in Kotlin: Avoid System.currentTimeMillis

Omar Sahl
ProAndroidDev
Published in
7 min readMar 31, 2025

Whenever we need to access the current time in Kotlin, one of the simplest approaches that comes to mind is using the System.currentTimeMillis() function. It’s straightforward, it returns the number of milliseconds since the Unix epoch (1970–01–01T00:00:00Z). However, when it comes to writing testable code, relying on this function can be problematic.

A key aspect of testable code is the ability to run it in a fully controlled environment — and time is no exception. If your code depends on the passage of time, you need a way to manage and control it during tests.

So, what’s the problem with System.currentTimeMillis()? The issue is that it retrieves the current time directly from the operating system, a value you can’t easily control during testing. You can't just manipulate the OS clock to suit your test scenarios. On top of that, you also can't guarantee that the system clock won't change while your test is running, causing flaky or inconsistent results. That’s why System.currentTimeMillis() isn’t the best choice for code that you want to test reliably.

To explain what I mean, let’s walk through a simple example.

Let’s assume we have the following Cache interface:

interface Cache<Key> {

suspend fun put(key: Key, value: Any, ttl: Duration)

suspend fun <Value : Any> get(key: Key): Value?
}

This interface defines two suspend functions: put, which caches a value with a supplied key and a time-to-live (ttl) that specifies when the cache entry expires, and get, which retrieves the value for a given key. If the cached value exists and is still valid (i.e. the ttl hasn’t expired), it returns that value, otherwise, it returns null.

Now, let’s add a simple in-memory cache implementation, which we’ll call SimpleCache:

class SimpleCache<Key> : Cache<Key> {

private val mutex = Mutex()
private val entries = mutableMapOf<Key, CacheEntry<*>>()

override suspend fun put(key: Key, value: Any, ttl: Duration) = mutex.withLock {
val expiryTime = System.currentTimeMillis() + ttl.inWholeMilliseconds
entries[key] = CacheEntry(value, expiryTime)
}

override suspend fun <Value : Any> get(key: Key): Value? = mutex.withLock {
val entry = entries[key]
if (entry != null && System.currentTimeMillis() < entry.expiryTime) {
@Suppress("UNCHECKED_CAST")
return entry.value as Value
}

invalidate(key)
return null
}

private fun invalidate(key: Key) {
entries.remove(key)
}

private data class CacheEntry<Value>(val value: Value, val expiryTime: Long)
}

In this implementation, SimpleCache uses a mutable map to store cached entries. The put function adds a value to the map by calculating its expiration time as the sum of System.currentTimeMillis() and the specified ttl. Similarly, the get function retrieves the value only if the current time—again retrieved via System.currentTimeMillis()—is still before the expiry time. If the ttl has expired, the cache entry is invalidated (removed from the map) and null is returned.

The key takeaway here is how System.currentTimeMillis() is used both to determine when a cache entry should expire and to verify its validity upon retrieval.

With that out of the way, let’s now write a unit test to verify that our cache implementation behaves as expected.

import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.delay
import org.junit.Test
import kotlin.test.assertNull
import kotlin.test.assertNotNull
import kotlin.time.Duration.Companion.minutes

class SimpleCacheTest {

@Test
fun `cache returns expected values based on ttl`() = runTest {
val key = "my_key"
val cache = SimpleCache<String>()

// At the start, the cache should be empty.
assertNull(cache.get<String>(key))

// Add a value with a ttl of 5 minutes.
cache.put(key, "My Value", 5.minutes)

// Wait for 2 minutes and verify the cached value is still present.
delay(2.minutes)
assertNotNull(cache.get<String>(key))

// Wait for another 4 minutes and verify the cached value is invalidated.
delay(4.minutes)
assertNull(cache.get<String>(key))
}
}

So here we use runTest() from the kotlinx-coroutines-test library, it's the go-to function for testing suspend functions and coroutines.

At first glance, this test seems to correctly cover the intended scenario:

  1. The cache is initially empty.
  2. A value is added with a ttl of 5 minutes.
  3. After a 2-minute delay, we expect the cache to return the value.
  4. After a further 4-minute delay (making it 6 minutes in total), the cache should have invalidated the entry and return null.

However, if we run this test as-is, it will fail. The reason is that runTest skips all calls to delay, which is exactly why the test finishes immediately.

When delay is called within runTest(), it gets skipped, but it also advances what's known as the virtual time. This virtual time is completely separate from the OS time that's returned by calls to System.currentTimeMillis().

In other words, after calling delay(2.minutes) or delay(4.minutes), the OS time remains unchanged from the perspective of System.currentTimeMillis() (and in turn, from the perspective of SimpleCache), even though the test code is moving forward in virtual time.

To illustrate this, consider the following snippet, which will print 0:

runTest {
val start = System.currentTimeMillis()
delay(5.minutes)
val end = System.currentTimeMillis()
print(end - start) // This will effectively print 0.
}

So, to fix our unit test, we need to make SimpleCache depend on an abstraction of a time source. By swapping out this time source during tests, we can use virtual time instead of system time.

Let’s start by introducing a new Clock interface that represents a time source (more on this in the note at the end):

fun interface Clock {

/**
* The number of milliseconds from the Unix epoch `1970-01-01T00:00:00Z`.
*/

fun now(): Long
}

Now, let’s define the default implementation of Clock, which returns the current system time. We’ll call it SystemClock:

object SystemClock : Clock {
override fun now(): Long = System.currentTimeMillis()
}

As you can see, this implementation relies on System.currentTimeMillis().

The next step is to refactor SimpleCache so that it depends on the Clock interface instead of directly using the system time. Here’s how that looks:

class SimpleCache<Key>(private val clock: Clock = SystemClock) : Cache<Key> {
...
override suspend fun put(key: Key, value: Any, ttl: Duration) = mutex.withLock {
val expiryTime = clock.now() + ttl.inWholeMilliseconds // 1
entries[key] = CacheEntry(value, expiryTime)
}

override suspend fun <Value : Any> get(key: Key): Value? = mutex.withLock {
val entry = entries[key]
if (entry != null && clock.now() < entry.expiryTime) { // 2
@Suppress("UNCHECKED_CAST")
return entry.value as Value
}

invalidate(key)
return null
}
...
}

Now, SimpleCache depends on the Clock interface, so we can provide any time source we want. By default, it uses system time. But in our tests, we can provide a different implementation that uses the virtual time that we can control.

So, how do we access the virtual time?

Well, runTest provides a special coroutine scope for testing called TestScope. This scope uses a TestDispatcher (a CoroutineDispatcher implementation), which internally relies on a TestCoroutineScheduler. That scheduler is what provides the delay-skipping behavior and manages the virtual time. It’s exactly what we need to base our test time source on.

We can retrieve the current virtual time from the TestCoroutineScheduler using its currentTime property. With that, let’s create a Clock implementation that uses the virtual time. We’ll call it TestClock:

class TestClock(private val scheduler: TestCoroutineScheduler) : Clock {
override fun now(): Long = scheduler.currentTime
}

To make things a bit more idiomatic, let’s also create a convenience extension function on TestScope that returns a TestClock:

fun TestScope.createTestClock() = TestClock(testScheduler)

With that ready, let’s go back to our failing test and update it to use an instance of TestClock:

@Test
fun `cache returns expected values based on ttl`() = runTest {
val key = "my_key"
val cache = SimpleCache<String>(createTestClock()) // We now use a TestClock
assertNull(cache.get<String>(key))

cache.put(key, "My Value", 5.minutes)

delay(2.minutes) // This advances virtual time by 2 minutes.
assertNotNull(cache.get<String>(key))

delay(4.minutes) // This advances virtual time by 4 more minutes.
assertNull(cache.get<String>(key))
}

The only change is that SimpleCache now takes an instance of TestClock, which uses virtual time instead of the system time. So when we call delay, it gets skipped, but it also advances the test clock under the hood. This allows the test to finish immediately while still simulating the correct passage of time. And because SimpleCache now uses that same virtual time, everything stays in sync and works exactly as expected.

If we run the test now — it will pass!

Now, you might have noticed that we didn’t need to interact directly with TestCoroutineScheduler in our test. That’s because delay already works well for our use case. However, in more complex scenarios, you may need finer control over coroutine dispatching and time progression. That’s where the functions provided by TestCoroutineScheduler come into play.

One such function is advanceTimeBy, which we can also use in our test. Here’s how the same test would look using it:

@Test
fun `cache returns expected values based on ttl`() = runTest {
val key = "my_key"
val cache = SimpleCache<String>(createTestClock())
assertNull(cache.get<String>(key))

cache.put(key, "My Value", 5.minutes)

advanceTimeBy(2.minutes)
assertNotNull(cache.get<String>(key))

advanceTimeBy(4.minutes)
assertNull(cache.get<String>(key))
}

And with that, we now have a controllable time source that makes testing time-dependent coroutine code simple, reliable, and approachable.

About the Clock Interface

One thing worth mentioning is that if you’re already using the kotlinx-datetime library, you can use the Clock interface it provides, so there's no need to define your own.

And if you’re using Kotlin version 2.1.20 or later, the Clock interface has been moved into the Kotlin standard library, so you don't even need to depend on kotlinx-datetime at all. However, it’s still experimental. To opt in, use the @OptIn(ExperimentalTime::class) annotation.

Closing Remark:

While the example used here was intentionally kept simple to demonstrate coroutine testing tools like runTest and TestCoroutineScheduler, the SimpleCache class could have been tested using a mocking framework like MockK, without needing runTest or any coroutine-specific setup at all.

However, knowing these tools makes testing more complex coroutine-based code, such as when working with flows and channels, much simpler and approachable, as they offer fine-grained control over coroutine dispatching and time progression, which can be a huge help when writing reliable tests.

That’s it, thank you for reading! I hope this article has been helpful. If you have any questions or suggestions, feel free to share them in the comments below.

Happy coding!

If you enjoyed this article, consider giving it a clap (or 50 😉) and follow me for more content on Android development. See you!

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

No responses yet

Write a response