每当我们需要在 Kotlin 中访问当前时间时, 最简单的方法之一就是使用System.currentTimeMillis()
函数. 它简单明了, 返回自 Unix epoch(1970-01-01T00:00:00Z)以来的毫秒数. 然而, 在编写可测试代码时, 依赖这个函数可能会出现问题.
可测试代码的一个关键方面是能够在完全受控的环境中运行, 时间也不例外. 如果你的代码依赖于时间的流逝, 你就需要一种方法来管理和控制测试过程中的时间.
那么, System.currentTimeMillis()
有什么问题呢?问题在于它直接从操作系统中获取当前时间, 而这个值在测试过程中并不容易控制. 你不能随意操纵操作系统时钟来适应你的测试场景. 此外, 你也无法保证测试运行时系统时钟不会发生变化, 从而导致测试结果不稳定或不一致. 这就是为什么System.currentTimeMillis()
不是你想要可靠测试的代码的最佳选择.
为了解释我的意思, 让我们举一个简单的例子.
假设我们有以下 Cache
接口:
kotlin
interface Cache<Key> {
suspend fun put(key: Key, value: Any, ttl: Duration)
suspend fun <Value : Any> get(key: Key): Value?
}
该接口定义了两个suspend函数: put
(使用提供的键和指定缓存条目过期时间的生存时间(ttl), 缓存一个值)和get
(为给定键检索值). 如果缓存值存在且仍然有效(即 ttl 未过期), 则返回该值, 否则返回null
.
现在, 让我们添加一个简单的内存中缓存实现, 我们将其称为 SimpleCache
:
kotlin
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)
}
在这个实现中, SimpleCache
使用可变映射来存储缓存条目. put
函数通过计算System.currentTimeMillis()
与指定的ttl
之和的过期时间, 将一个值添加到映射中. 同样, 只有当当前时间(同样通过 System.currentTimeMillis()
获取)仍在过期时间之前时, get
函数才会获取值. 如果 ttl
已过期, 缓存条目就会失效(从映射中删除)并返回 null
.
这里的关键之处在于, System.currentTimeMillis()
是如何用于确定缓存条目的过期时间以及在检索时验证其有效性的.
说完这些, 现在让我们编写一个单元测试来验证我们的缓存实现是否符合预期.
kotlin
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))
}
}
在这里, 我们使用 kotlinx-coroutines-test
库中的 runTest()
, 它是测试suspend函数和协程的常用函数.
乍一看, 这个测试似乎正确地涵盖了预期的场景:
- 缓存最初为空.
- 添加一个 ttl 为 5 分钟的值.
- 延迟 2 分钟后, 我们期待缓存返回值.
-
- 再延迟 4 分钟(总共 6 分钟)后, 缓存应使条目失效并返回
null
.
- 再延迟 4 分钟(总共 6 分钟)后, 缓存应使条目失效并返回
但是, 如果我们按原样运行这个测试, 它就会失败. 原因是 runTest
跳过了 对 delay
的所有调用, 这正是测试立即结束的原因.
当在 runTest()
中调用 delay
时, 它会被跳过, 但同时也会推进所谓的虚拟时间 . 虚拟时间与调用 System.currentTimeMillis()
时返回的操作系统时间完全不同.
换句话说, 在调用 delay(2.minutes)
或 delay(4.minutes)
之后, 从 System.currentTimeMillis()
(以及反过来从 SimpleCache
)的角度来看, 操作系统时间保持不变, 尽管测试代码是在虚拟时间中前进的.
为了说明这一点, 请看下面的代码段, 它将打印 0
:
scss
runTest {
val start = System.currentTimeMillis()
delay(5.minutes)
val end = System.currentTimeMillis()
print(end - start) // This will effectively print 0.
}
因此, 要修复单元测试, 我们需要让 SimpleCache
依赖于时间源的抽象. 通过在测试过程中交换时间源, 我们可以使用虚拟时间而不是系统时间.
让我们先引入一个新的 Clock
接口来表示时间源(更多内容请参见最后的注释):
kotlin
fun interface Clock {
/**
* The number of milliseconds from the Unix epoch `1970-01-01T00:00:00Z`.
*/
fun now(): Long
}
现在, 让我们定义返回当前系统时间的 Clock
的默认实现. 我们称之为 SystemClock
:
kotlin
object SystemClock : Clock {
override fun now(): Long = System.currentTimeMillis()
}
正如你所看到的, 这个实现依赖于 System.currentTimeMillis()
.
下一步是重构 SimpleCache
, 使其依赖于 Clock
接口, 而不是直接使用系统时间. 如下所示:
kotlin
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
}
...
}
现在, SimpleCache
依赖于Clock
接口, 因此我们可以提供任何想要的时间源. 默认情况下, 它使用系统时间. 但在我们的测试中, 我们可以提供不同的实现, 使用我们可以控制的虚拟时间.
那么, 我们如何访问虚拟时间呢?
那么, runTest
为测试提供了一个名为 TestScope
的特殊协程作用域. 该作用域使用一个 TestDispatcher
(一个 CoroutineDispatcher
实现), 它在内部依赖于一个 TestCoroutineScheduler
. Dispatcher提供延迟跳转行为并管理虚拟时间. 这正是我们需要的测试时间源基础.
我们可以使用 TestCoroutineScheduler
的 currentTime
属性从它获取当前的虚拟时间. 这样, 我们就可以创建一个使用虚拟时间的 Clock
实现. 我们将其命名为 TestClock
:
kotlin
class TestClock(private val scheduler: TestCoroutineScheduler) : Clock {
override fun now(): Long = scheduler.currentTime
}
为了让事情更容易理解, 让我们在 TestScope
上创建一个返回 TestClock
的方便扩展函数:
kotlin
fun TestScope.createTestClock() = TestClock(testScheduler)
准备就绪后, 让我们回到失败的测试, 更新它以使用 TestClock
实例:
kotlin
@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))
}
唯一的变化是 SimpleCache
现在使用了 TestClock
的实例, 它使用虚拟时间而不是系统时间. 因此, 当我们调用 delay
时, 它会被跳过, 但同时也会在引擎盖下推进测试时钟. 这样, 测试就能立即完成, 同时还能模拟正确的时间流逝. 由于 SimpleCache
现在使用的是相同的虚拟时间, 因此一切都保持同步, 并完全按照预期运行.
如果我们现在运行测试, 它就会通过!
现在, 你可能已经注意到, 我们在测试中不需要直接与 TestCoroutineScheduler
交互. 这是因为 delay
已经很好地满足了我们的用例. 然而, 在更复杂的场景中, 你可能需要对协程调度和时间进程进行更精细的控制. 这正是 TestCoroutineScheduler
提供的函数发挥作用的地方.
其中一个函数是 advanceTimeBy
, 我们也可以在测试中使用它. 下面是使用该函数后的测试结果:
vbnet
@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))
}
就这样, 我们现在有了一个可控的时间源, 可以让依赖时间的协程代码测试变得简单, 可靠和易于使用.
关于Clock
接口
值得一提的是, 如果你已经在使用 kotlinx-datetime
库, 就可以使用它提供的 Clock
接口, 因此无需定义自己的接口.
如果你使用的是2.1.20 或更高版本的Kotlin, Clock
接口已被移入Kotlin标准库, 因此你甚至无需依赖kotlinx-datetime
. 不过, 它仍处于试验阶段. 要选择加入, 请使用 @OptIn(ExperimentalTime::class)
注解.
结束语:
虽然这里使用的示例有意保持简单, 以演示 runTest
和 TestCoroutineScheduler
等协程测试工具, 但 SimpleCache
类本可以使用 MockK 等模拟框架进行测试, 根本不需要 runTest
或任何协程特定的设置.
不过, 了解了这些工具后, 测试更复杂的基于协程的代码(如使用Flow和Channel时)就变得简单易行多了, 因为它们提供了对协程高度和时间进程的细粒度控制, 这对编写可靠的测试大有帮助.
好了, 今天的内容就分享到这里啦!
一家之言, 欢迎拍砖!
Happy Coding! Stay GOLDEN!