使用 Lincheck 编写你的第一个测试
最终更新: 2025/02/06本教程演示如何编写你的第一个 Lincheck 测试, 设置 Lincheck 框架, 并使用它的基本 API. 你将会创建一个新的 IntelliJ IDEA 项目, 其中包含不正确的并发计数器实现, 为它编写一个测试, 然后查找并分析 bug.
创建一个项目
在 IntelliJ IDEA 中, 打开一个既有的 Kotlin 项目, 或 创建一个新项目. 创建项目时, 使用 Gradle 构建系统.
添加需要的依赖项
打开
build.gradle(.kts)
文件, 确认参考列表中添加了mavenCentral()
.在 Gradle 配置中添加以下依赖项:
KotlinGroovyrepositories { mavenCentral() } dependencies { // Lincheck 依赖项 testImplementation("org.jetbrains.kotlinx:lincheck:2.34") // 这个依赖项允许你使用 kotlin.test 和 JUnit: testImplementation("junit:junit:4.13") }
repositories { mavenCentral() } dependencies { // Lincheck 依赖项 testImplementation "org.jetbrains.kotlinx:lincheck:2.34" // 这个依赖项允许你使用 kotlin.test 和 JUnit: testImplementation "junit:junit:4.13" }
编写一个并发的计数器, 并运行测试
在
src/test/kotlin
目录中, 创建BasicCounterTest.kt
文件, 并添加以下代码, 这是一个有 bug 的并发计数器, 然后为它编写一个 Lincheck 测试:import org.jetbrains.kotlinx.lincheck.annotations.* import org.jetbrains.kotlinx.lincheck.* import org.jetbrains.kotlinx.lincheck.strategy.stress.* import org.junit.* class Counter { @Volatile private var value = 0 fun inc(): Int = ++value fun get() = value } class BasicCounterTest { private val c = Counter() // 初始状态 // 对计数器的操作 @Operation fun inc() = c.inc() @Operation fun get() = c.get() @Test // JUnit fun stressTest() = StressOptions().check(this::class) // 奇迹发生在这里 }
Lincheck 测试会自动完成以下工作:
使用指定的
inc()
和get()
操作生成一些随机的并发场景.对生成的每个场景执行一系列调用.
验证每个调用的结果是否正确.
运行上面的测试, 你将会看到以下错误:
= Invalid execution results = | ------------------- | | Thread 1 | Thread 2 | | ------------------- | | inc(): 1 | inc(): 1 | | ------------------- |
这里, Lincheck 发现了测试运行结果违反了计数器的原子性 – 两个并发的增加操作返回了相同的结果
1
. 这代表其中一个增加操作丢失了, 计数器的行为不正确.
追踪错误的运行结果
除了显示错误的运行结果之外, Lincheck 还提供了一种追踪错误原因的方法. 可以通过 模型检查 测试策略来使用这个功能, 这个测试策略使用有限次数的上下文切换来对多次执行进行检验.
要切换测试策略, 请
options
类型从StressOptions()
替换为ModelCheckingOptions()
. 修改后的BasicCounterTest
类大致如下:import org.jetbrains.kotlinx.lincheck.annotations.* import org.jetbrains.kotlinx.lincheck.check import org.jetbrains.kotlinx.lincheck.strategy.managed.modelchecking.* import org.junit.* class Counter { @Volatile private var value = 0 fun inc(): Int = ++value fun get() = value } class BasicCounterTest { private val c = Counter() @Operation fun inc() = c.inc() @Operation fun get() = c.get() @Test fun modelCheckingTest() = ModelCheckingOptions().check(this::class) }
再次运行测试. 你将会得到测试运行中导致错误结果的追踪信息:
= Invalid execution results = | ------------------- | | Thread 1 | Thread 2 | | ------------------- | | inc(): 1 | inc(): 1 | | ------------------- | The following interleaving leads to the error: | --------------------------------------------------------------------- | | Thread 1 | Thread 2 | | --------------------------------------------------------------------- | | | inc() | | | inc(): 1 at BasicCounterTest.inc(BasicCounterTest.kt:18) | | | value.READ: 0 at Counter.inc(BasicCounterTest.kt:10) | | | switch | | inc(): 1 | | | | value.WRITE(1) at Counter.inc(BasicCounterTest.kt:10) | | | value.READ: 1 at Counter.inc(BasicCounterTest.kt:10) | | | result: 1 | | --------------------------------------------------------------------- |
根据这个追踪信息信息, 发生了以下事件:
T2: 第 2 个线程开始了
inc()
操作, 读取当前的计数器值 (value.READ: 0
), 然后暂停.T1: 第 1 个线程执行
inc()
, 返回1
, 然后结束.T2: 第 2 个线程恢复运行, 对前面得到的计数器值加 1, 错误的将计数器更新为
1
.
note
测试 Java 标准库
下面我们来发现一个 Java 标准库的 ConcurrentLinkedDeque
中的 bug. 下面的 Lincheck 测试会发现向双向队列头部删除和添加一个元素时发生的竞争情况:
import org.jetbrains.kotlinx.lincheck.*
import org.jetbrains.kotlinx.lincheck.annotations.*
import org.jetbrains.kotlinx.lincheck.strategy.managed.modelchecking.*
import org.junit.*
import java.util.concurrent.*
class ConcurrentDequeTest {
private val deque = ConcurrentLinkedDeque<Int>()
@Operation
fun addFirst(e: Int) = deque.addFirst(e)
@Operation
fun addLast(e: Int) = deque.addLast(e)
@Operation
fun pollFirst() = deque.pollFirst()
@Operation
fun pollLast() = deque.pollLast()
@Operation
fun peekFirst() = deque.peekFirst()
@Operation
fun peekLast() = deque.peekLast()
@Test
fun modelCheckingTest() = ModelCheckingOptions().check(this::class)
}
运行 modelCheckingTest()
. 测试将会失败, 输出信息如下:
= Invalid execution results =
| ---------------------------------------- |
| Thread 1 | Thread 2 |
| ---------------------------------------- |
| addLast(22): void | |
| ---------------------------------------- |
| pollFirst(): 22 | addFirst(8): void |
| | peekLast(): 22 [-,1] |
| ---------------------------------------- |
---
All operations above the horizontal line | ----- | happen before those below the line
---
Values in "[..]" brackets indicate the number of completed operations
in each of the parallel threads seen at the beginning of the current operation
---
The following interleaving leads to the error:
| --------------------------------------------------------------------------------------------------------------------------------- |
| Thread 1 | Thread 2 |
| --------------------------------------------------------------------------------------------------------------------------------- |
| pollFirst() | |
| pollFirst(): 22 at ConcurrentDequeTest.pollFirst(ConcurrentDequeTest.kt:17) | |
| first(): Node@1 at ConcurrentLinkedDeque.pollFirst(ConcurrentLinkedDeque.java:915) | |
| item.READ: null at ConcurrentLinkedDeque.pollFirst(ConcurrentLinkedDeque.java:917) | |
| next.READ: Node@2 at ConcurrentLinkedDeque.pollFirst(ConcurrentLinkedDeque.java:925) | |
| item.READ: 22 at ConcurrentLinkedDeque.pollFirst(ConcurrentLinkedDeque.java:917) | |
| prev.READ: null at ConcurrentLinkedDeque.pollFirst(ConcurrentLinkedDeque.java:919) | |
| switch | |
| | addFirst(8): void |
| | peekLast(): 22 |
| compareAndSet(Node@2,22,null): true at ConcurrentLinkedDeque.pollFirst(ConcurrentLinkedDeque.java:920) | |
| unlink(Node@2) at ConcurrentLinkedDeque.pollFirst(ConcurrentLinkedDeque.java:921) | |
| result: 22 | |
| --------------------------------------------------------------------------------------------------------------------------------- |
note