mcpField
W
writing-tests-with-kotlin-test

writing-tests-with-kotlin-test

github.com/skydoves测试与质量33961 次安装22 周前更新
代码仓库

关于 writing-tests-with-kotlin-test

面向 Kotlin 测试编写者的技能指南,介绍 kotlin.test 的断言、注解及 Gradle 配置。它强调在 JVM、Android、JS 和 Kotlin Multiplatform 测试中使用统一且与测试框架无关的 API。

主要功能

  1. 01跨目标一致的断言与注解
  2. 02异常与类型断言模式
  3. 03@OnlyInputTypes 类型检查
  4. 04Asserter 扩展点说明
  5. 05Gradle 依赖配置指南

适用场景

  1. 01在 commonTest 编写共享测试
  2. 02为不同平台复用测试源码
  3. 03选择适合意图的断言并检查异常

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/skydoves/android-testing-skills --skill writing-tests-with-kotlin-test

安装后在 Claude Code 中输入/writing-tests-with-kotlin-test确认已加载。

详细介绍

使用 kotlin.test 编写测试 — 在所有目标平台上使用同一套断言 API

kotlin.test 是 Kotlin 自带的轻量级测试库:它提供与测试框架无关的 assert* 函数,以及 @Test/@BeforeTest/@AfterTest 注解;这些注解会通过类型别名映射到类路径上的测试运行器(JUnit4、JUnit5、TestNG、JS/Native 测试运行器)。只需编写一次 import kotlin.test.*,同一份测试源码就能在 commonTest、JVM 以及每个 Kotlin Multiplatform 目标平台上编译。本指南涵盖 API 范围、@OnlyInputTypes 编译时保护、Asserter 扩展点,以及 Gradle 配置。这是断言层;Android instrumentation 的运行器层请参阅 ../../../jvm-tests/runner/configuring-junit4-on-android/SKILL.md。

适用场景

  • 模块使用 Kotlin Multiplatform,测试位于 commonTest 中——那里无法使用 JUnit 的 org.junit.*,但可以使用 kotlin.test。
  • 用户希望使用一套在 JVM、Android、JS 和 Native 上读起来都相同的断言词汇。
  • 用户使用 assertFailsWith<IllegalStateException> { … }、assertContentEquals(...)、assertIs<Foo>(value)、assertContains(list, x)、expect(3) { compute() } 或 @BeforeTest/@AfterTest。
  • JVM 模块使用 testImplementation(kotlin("test")),测试却“在 JUnit 上运行,但我从未添加过 JUnit”——需要解释基于能力的依赖解析机制。
  • kotlin.test.assertEquals 失败时只显示普通的 AssertionError,没有差异对比,而用户希望恢复 JUnit 风格的比较失败信息。

不适用场景

  • 用户需要丰富的流式断言(assertThat(x).isEqualTo(...)、软断言、集合匹配器)——这些属于 AssertJ / Google Truth / Kotest 断言,有意不纳入 kotlin.test 的精简范围。
  • 用户正在编写 Android instrumentation 测试,并询问运行器(@RunWith(AndroidJUnit4::class)、AndroidJUnitRunner、androidx.test:*)——请使用 ../../../jvm-tests/runner/configuring-junit4-on-android/SKILL.md 和 ../../../instrumentation/runner/running-instrumented-tests-with-androidjunit4/SKILL.md。(这些测试中仍可使用 kotlin.test 断言。)
  • 用户正在测试协程 / Flow——runTest、TestScope、Dispatchers.setMain 请参阅 ../../../jvm-tests/coroutines/testing-coroutines-with-runtest/SKILL.md;Turbine 请参阅 ../../../jvm-tests/coroutines/testing-flows-with-turbine/SKILL.md。(这些测试内部仍可使用 kotlin.test 提供的 assert* 调用。)
  • 用户需要 Compose UI 断言(assertIsDisplayed、assertTextEquals)——请参阅 compose/assertions/ 下的技能,这与 kotlin.test 无关。

前置条件

  • Gradle:对于 JVM/Android 模块,使用 testImplementation(kotlin("test"))。对于 Kotlin Multiplatform 模块,将 kotlin.test 添加到 commonTest 源集:sourceSets { commonTest.dependencies { implementation(kotlin("test")) } }。
  • kotlin("test") 简写是一种基于能力的依赖(Kotlin 1.4+)。在 JVM 上,它默认解析为 JUnit4 变体(kotlin-test-junit);如果 Test 任务配置了 useJUnitPlatform(),则会解析为 JUnit5 变体(kotlin-test-junit5)。也可以显式依赖特定适配器:org.jetbrains.kotlin:kotlin-test-junit、:kotlin-test-junit5、:kotlin-test-testng、:kotlin-test-js。
  • 所选目标平台的类路径上必须有测试运行器——kotlin.test 的 @Test 只是一个注解;必须由某个组件执行它(JUnit、TestNG、Kotlin/JS 或 Kotlin/Native 测试基础设施)。适配器工件会引入相应运行器。

工作流

  • 1. 将 kotlin("test") 添加到测试源集,而不是写死 JUnit 依赖。 在多平台模块中将其放入 commonTest;在 JVM/Android 模块中使用 testImplementation(kotlin("test"))。除非有特定于框架的需求,否则不要再直接添加 junit:junit 或 org.junit.jupiter:*——适配器会引入正确的依赖。
  • 2. 导入与框架无关的符号。 import kotlin.test.*(或导入特定符号)。注解来自 kotlin.test,而不是 org.junit:
import kotlin.test.Test
import kotlin.test.BeforeTest
import kotlin.test.AfterTest
import kotlin.test.Ignore
import kotlin.test.assertEquals

class CartTest {
    private lateinit var cart: Cart

    @BeforeTest fun setUp() { cart = Cart() }      // == @Before (JUnit4) / @BeforeEach (JUnit5)
    @AfterTest  fun tearDown() { cart.clear() }    // == @After  (JUnit4) / @AfterEach  (JUnit5)

    @Test fun emptyCartTotalsZero() {
        assertEquals(Money.ZERO, cart.total())
    }

    @Ignore @Test fun flaky() { /* skipped — == @Disabled on JUnit5 */ }
}

在 JVM-JUnit4 上,kotlin.test.Test 是指向 org.junit.Test 的 typealias,BeforeTest 指向 org.junit.Before,AfterTest 指向 org.junit.After,Ignore 指向 org.junit.Ignore。在 JUnit5 上,它们分别映射到 org.junit.jupiter.api.Test/BeforeEach/AfterEach/Disabled。因此,同一份源码既可在任一引擎上运行,也可在 commonTest 中运行。

  • 3. 选择准确的断言。 kotlin.test 刻意保持精简——每种意图都恰好有一个直观的调用:
意图调用
值相等(按 == 判断)assertEquals(expected, actual, message?)
浮点数在容差范围内assertEquals(expected, actual, absoluteTolerance, message?)(Double/Float,自 1.5 起)
值不相等assertNotEquals(illegal, actual, message?)(也有浮点容差重载)
是/不是同一实例(===)assertSame / assertNotSame
null/非 nullassertNull(actual) / assertNotNull(actual)——assertNotNull 会返回非空的 T(并且有 assertNotNull(actual) { it -> … } 块形式)
是/不是某种类型assertIs<T>(value) / assertIsNot<T>(value)(自 1.5 起)——assertIs<T> 会返回经过智能类型转换的 T,可在其上链式调用断言
有序内容相等对 Iterable?/Sequence?/Array?/原始类型数组使用 assertContentEquals(expected, actual, message?)(自 1.5 起)。会比较顺序——对 Set 使用 assertEquals(assertContentEquals 的 Set 重载因含义不明确而已弃用)
成员关系assertContains(container, element, message?)——适用于 Iterable/Sequence/Array/Map(键)/IntRange/CharSequence 等(自 1.5 起)
布尔条件assertTrue(actual, message?) / assertFalse(actual, message?);若条件本身开销较大,则使用 assertTrue(message?) { lazyCondition }
无条件失败fail(message?) 或 fail(message?, cause)(自 1.4 起)——返回 Nothing,因此在 when/?: 中可满足类型检查器的要求
“此块返回 X”expect(expected) { block } / expect(expected, message) { block }
块抛出异常assertFails { block } 会返回 Throwable;assertFailsWith<E> { block } / assertFailsWith(E::class) { block } 会返回类型化的 E,以便进一步断言其消息
  • 4. 使用 assertFailsWith 断言抛出的异常,而不是使用 try/catch。 它会返回异常,因此你可以断言其消息/原因:
@Test fun withdrawOverdraws() {
    val e = assertFailsWith<InsufficientFundsException> { account.withdraw(BigDecimal(100)) }
    assertEquals("balance is 20", e.message)
}
  • 5. 借助 @OnlyInputTypes——类型不匹配会导致编译错误,而不是运行时失败。 assertEquals、assertContentEquals、assertContains、assertSame、expect 声明时都带有 <@OnlyInputTypes T>,因此 assertEquals(1, "1") 无法通过编译。不要为了“让它通过”而扩大到 Any——应修正测试。

  • 6. 将 JUnit 适配器保留在类路径中,以便在 JVM 上恢复 JUnit 风格的差异报告。 kotlin.test 会在运行时通过 ServiceLoader<AsserterContributor> 解析 Asserter。kotlin-test-junit 工件会注册一个贡献者:当 org.junit.Assert 存在时,它会返回一个 JUnitAsserter,将 assertEquals 委托给 org.junit.Assert.assertEquals——后者会抛出 ComparisonFailure,从而在 IDE 中显示可点击的预期值/实际值差异。若没有贡献者,DefaultAsserter 会抛出普通的 AssertionError。因此,只要适配器存在(通过 kotlin("test") 引入),kotlin.test 的失败就会自动呈现为 JUnit 风格的失败。只有在需要非 JUnit 报告后端时,才实现自定义 Asserter 并注册 AsserterContributor(JVM:META-INF/services/kotlin.test.AsserterContributor)。

模式

模式:在多平台共享测试中导入 org.junit.* 注解
// WRONG — in src/commonTest, or in src/test that you later want to share
import org.junit.Test            // unresolved in commonTest; couples JVM source to JUnit4
import org.junit.Assert.assertEquals
class FooTest {
    @Test fun bar() { assertEquals(2, foo()) }
}
// WRONG because: org.junit is a JVM-JUnit4 dependency. It does not exist in commonMain/commonTest,
// it pins you to JUnit4 (not 5), and it cannot run on Kotlin/JS or Kotlin/Native.
// RIGHT — kotlin.test only; runs everywhere, on whatever engine the build selects
import kotlin.test.Test
import kotlin.test.assertEquals
class FooTest {
    @Test fun bar() { assertEquals(2, foo()) }
}
模式:手动检查异常
// WRONG
@Test fun parseRejectsGarbage() {
    try {
        parse("garbage")
        fail("expected ParseException")
    } catch (e: ParseException) {
        // ok — but verbose, and a different exception type slips through as a test error not a failure
    }
}
// WRONG because: assertFailsWith already does exactly this, returns the exception for assertions,
// and reports a clear "expected ParseException but was X" message.
// RIGHT
@Test fun parseRejectsGarbage() {
    val e = assertFailsWith<ParseException> { parse("garbage") }
    assertEquals(0, e.offset)
}
模式:使用 assertTrue(a == b) 而不是 assertEquals
// WRONG
assertTrue(result == expected)
// WRONG because: on failure the message is just "Expected value to be true." — no expected/actual.
// assertEquals reports both values (and a diff via the JUnit asserter).
// RIGHT
assertEquals(expected, result)
// reserve assertTrue for genuine boolean predicates: assertTrue(result.isValid, "result was rejected: ${result.errors}")
模式:比较列表内容
// WRONG — assertEquals on lists works, but assertEquals on the wrong shape is silent type widening
assertEquals(expected as List<Any>, actual)         // never widen to Any to dodge a type error
// RIGHT — assertContentEquals is the explicit "same elements, same order" check; @OnlyInputTypes keeps it honest
assertContentEquals(listOf(1, 2, 3), service.ids())
// for a Set (order-insensitive) use assertEquals: assertEquals(setOf("a", "b"), service.tags())

强制规则

  • 必须依赖 kotlin("test")(多平台项目在 commonTest 中依赖,JVM/Android 项目使用 testImplementation(kotlin("test"))),而不是直接导入 org.junit.*,这样测试源代码才能与测试引擎无关,并可移植到多平台。
  • 必须从 kotlin.test 导入注解(@Test、@BeforeTest、@AfterTest、@Ignore),而不是从 org.junit / org.junit.jupiter.api 导入。
  • 必须根据具体用途使用 assertEquals/assertNotEquals/assertContentEquals/assertIs/assertContains,而不是一律使用 assertTrue(...)——这些专用调用会提供预期值/实际值的诊断信息。
  • 必须使用 assertFailsWith<E> { … } 检查抛出的异常(它会返回异常,以便断言消息/原因),而不是使用 try/catch + fail。
  • 不得扩大参数类型(as Any、as List<Any>)来消除 @OnlyInputTypes 编译错误——这个错误揭示了真正的类型不匹配。
  • 不得对 Set 调用 assertContentEquals(已弃用,且含义不明确);无序集合相等性应使用 assertEquals。
  • 不得在 JVM 上移除 kotlin-test-junit/kotlin-test-junit5 适配器——如果类路径中没有 AsserterContributor,失败时只会显示没有差异信息的 AssertionError。
  • **优先使用:**在测试中使用 assertNotNull(x) / assertIs<T>(x),而不是 x!! / x as T——前者失败时会给出清晰的消息,而不是抛出 NPE/ClassCastException,并返回经过类型收窄的值。
  • **优先使用:**当计算条件或消息的开销较高时,使用 assertTrue(message) { expensiveCondition() } 延迟计算的代码块重载。

验证

  • 面向多平台/共享的源码集中没有 import org.junit.* 或 import org.junit.jupiter.api.*;grep -rn 'import org.junit' src/commonTest src/test 没有结果(或仅存在有充分理由的平台专用导入)。
  • ./gradlew :module:dependencies --configuration testRuntimeClasspath(JVM)显示通过 kotlin("test") 功能解析出 kotlin-test-junit 或 kotlin-test-junit5。
  • 测试类使用来自 kotlin.test 的 @Test/@BeforeTest/@AfterTest。
  • 异常测试用例使用 assertFailsWith<…> { … },而不是 try/catch。
  • 没有为了让断言通过编译而添加 as Any / as List<Any> 类型扩大操作。
  • 在 JVM 模块中故意让 assertEquals 失败时,会显示 JUnit ComparisonFailure(可点击的差异)——这证明类路径中存在 AsserterContributor。

参考资料

  • kotlinlang.org/api/latest/kotlin.test/ — kotlin.test API 参考:所有 assert* 函数、expect、fail、注解、Asserter/AsserterContributor/DefaultAsserter。
  • kotlinlang.org/docs/jvm-test-using-junit.html 和 kotlinlang.org/docs/multiplatform-run-tests.html — 在 JVM 上(选择 JUnit4/JUnit5)和跨多平台目标使用 kotlin("test")。
  • libraries/kotlin.test/annotations-common/src/main/kotlin/kotlin.test/Annotations.kt(Kotlin 仓库)— expect annotation class Test/Ignore/BeforeTest/AfterTest;JVM 的 actual typealias 位于 libraries/kotlin.test/junit/src/main/kotlin/Annotations.kt(→ org.junit.*)和 libraries/kotlin.test/junit5/src/main/kotlin/Annotations.kt(→ org.junit.jupiter.api.*)。
  • libraries/kotlin.test/common/src/main/kotlin/kotlin/test/Assertions.kt(Kotlin 仓库)— assertTrue/assertFalse/assertEquals/assertNotEquals/assertSame/assertNotSame/assertNull/assertNotNull/assertIs/assertIsNot/assertContains/assertContentEquals/fail/expect/assertFails/assertFailsWith 的签名、@OnlyInputTypes 注解,以及 Asserter / AsserterContributor 接口。
  • libraries/kotlin.test/jvm/src/main/kotlin/AsserterLookup.kt 和 libraries/kotlin.test/junit/src/main/kotlin/JUnitSupport.kt(Kotlin 仓库)— 使用 ServiceLoader<AsserterContributor> 查找;存在 org.junit.Assert 时,JUnitContributor/JUnitAsserter 会委托给它,否则使用 DefaultAsserter。
  • 跨集合:../../../jvm-tests/runner/configuring-junit4-on-android/SKILL.md — JUnit4 运行器 / AndroidJUnit4 堆栈;kotlin.test 断言可直接用于这些测试,无需更改。
  • 跨集合:../../../jvm-tests/coroutines/testing-coroutines-with-runtest/SKILL.md — runTest 是外围作用域;kotlin.test 在其中提供 assert* 调用。
  • 跨集合:../../../jvm-tests/coroutines/testing-flows-with-turbine/SKILL.md — Turbine 的 awaitItem() 与 kotlin.test 的 assertEquals 配合使用。
  • 跨集合:../../../fundamentals/strategies/organizing-test-source-sets/SKILL.md — 这些测试所在的位置(src/test/、src/commonTest/),以及 testImplementation 与 androidTestImplementation 的划分。
  • 跨集合:../../../fundamentals/doubles/picking-test-doubles/SKILL.md — 断言所检查的伪造对象/存根。

评论

支持 Markdown