关于 writing-tests-with-kotlin-test
面向 Kotlin 测试编写者的技能指南,介绍 kotlin.test 的断言、注解及 Gradle 配置。它强调在 JVM、Android、JS 和 Kotlin Multiplatform 测试中使用统一且与测试框架无关的 API。
主要功能
- 01跨目标一致的断言与注解
- 02异常与类型断言模式
- 03@OnlyInputTypes 类型检查
- 04Asserter 扩展点说明
- 05Gradle 依赖配置指南
适用场景
- 01在 commonTest 编写共享测试
- 02为不同平台复用测试源码
- 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/非 null | assertNull(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失败时,会显示 JUnitComparisonFailure(可点击的差异)——这证明类路径中存在AsserterContributor。
参考资料
- kotlinlang.org/api/latest/kotlin.test/ —
kotlin.testAPI 参考:所有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— 断言所检查的伪造对象/存根。