在Kotlin中编写依赖于时间的可测试协程代码

每当我们需要在 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函数和协程的常用函数.

乍一看, 这个测试似乎正确地涵盖了预期的场景:

  1. 缓存最初为空.
  2. 添加一个 ttl 为 5 分钟的值.
  3. 延迟 2 分钟后, 我们期待缓存返回值.
    1. 再延迟 4 分钟(总共 6 分钟)后, 缓存应使条目失效并返回null.

但是, 如果我们按原样运行这个测试, 它就会失败. 原因是 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提供延迟跳转行为并管理虚拟时间. 这正是我们需要的测试时间源基础.

我们可以使用 TestCoroutineSchedulercurrentTime 属性从它获取当前的虚拟时间. 这样, 我们就可以创建一个使用虚拟时间的 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) 注解.

结束语:

虽然这里使用的示例有意保持简单, 以演示 runTestTestCoroutineScheduler 等协程测试工具, 但 SimpleCache 类本可以使用 MockK 等模拟框架进行测试, 根本不需要 runTest 或任何协程特定的设置.

不过, 了解了这些工具后, 测试更复杂的基于协程的代码(如使用Flow和Channel时)就变得简单易行多了, 因为它们提供了对协程高度和时间进程的细粒度控制, 这对编写可靠的测试大有帮助.

好了, 今天的内容就分享到这里啦!

一家之言, 欢迎拍砖!

Happy Coding! Stay GOLDEN!

相关推荐
划水哥~5 小时前
Kotlin作用域函数
开发语言·kotlin
AD钙奶-lalala5 小时前
某车企面试备忘
android
我爱拉臭臭6 小时前
kotlin音乐app之自定义点击缩放组件Shrink Layout
android·java·kotlin
匹马夕阳7 小时前
(二十五)安卓开发一个完整的登录页面-支持密码登录和手机验证码登录
android·智能手机
吃饭了呀呀呀7 小时前
🐳 深度解析:Android 下拉选择控件优化方案——NiceSpinner 实践指南
android·java
吃饭了呀呀呀8 小时前
🐳 《Android》 安卓开发教程 - 三级地区联动
android·java·后端
_祝你今天愉快9 小时前
深入剖析Java中ThreadLocal原理
android
张力尹10 小时前
谈谈 kotlin 和 java 中的锁!你是不是在协程中使用 synchronized?
android
流浪汉kylin10 小时前
Android 斜切图片
android
PuddingSama11 小时前
Android 视图转换工具 Matrix
android·前端·面试