Writing Tests With kotlin.test — One Assertion API Across Every Target
kotlin.test is Kotlin's own thin test library: framework-agnostic assert* functions plus @Test/@BeforeTest/@AfterTest annotations that typealias onto whatever runner is on the classpath (JUnit4, JUnit5, TestNG, the JS/Native runners). Write import kotlin.test.* once and the same test source compiles in commonTest, on the JVM, and on every Kotlin Multiplatform target. This skill covers the API surface, the @OnlyInputTypes compile-time guard, the Asserter extension point, and the Gradle wiring. It is the assertion layer; the runner layer for Android instrumentation is ../../../jvm-tests/runner/configuring-junit4-on-android/SKILL.md.
When to use this skill
- The module is Kotlin Multiplatform and tests live in
commonTest— JUnit'sorg.junit.*is not available there;kotlin.testis. - The user wants one assertion vocabulary that reads the same on JVM, Android, JS, and Native.
- The user reaches for
assertFailsWith<IllegalStateException> { … },assertContentEquals(...),assertIs<Foo>(value),assertContains(list, x),expect(3) { compute() }, or@BeforeTest/@AfterTest. - A JVM module has
testImplementation(kotlin("test"))and the test "runs on JUnit but I never added JUnit" — explaining the capability-based resolution. kotlin.test.assertEqualsfailures render as a plainAssertionErrorwith no diff, and the user wants the JUnit-style comparison failure back.
When NOT to use this skill
- The user wants rich fluent assertions (
assertThat(x).isEqualTo(...), soft assertions, collection matchers) — that is AssertJ / Google Truth / Kotest assertions, deliberately out ofkotlin.test's minimal scope. - The user is writing an Android instrumentation test and asking about the runner (
@RunWith(AndroidJUnit4::class),AndroidJUnitRunner,androidx.test:*) — use../../../jvm-tests/runner/configuring-junit4-on-android/SKILL.mdand../../../instrumentation/runner/running-instrumented-tests-with-androidjunit4/SKILL.md. (You can still usekotlin.testassertions inside those tests.) - The user is testing coroutines /
Flow—runTest,TestScope,Dispatchers.setMainis../../../jvm-tests/coroutines/testing-coroutines-with-runtest/SKILL.md; Turbine is../../../jvm-tests/coroutines/testing-flows-with-turbine/SKILL.md. (kotlin.testsupplies theassert*calls inside those.) - The user wants Compose UI assertions (
assertIsDisplayed,assertTextEquals) — that is thecompose/assertions/skills, unrelated tokotlin.test.
Prerequisites
- Gradle: for a JVM/Android module,
testImplementation(kotlin("test")). For a Kotlin Multiplatform module,kotlin.testis added to thecommonTestsource set:sourceSets { commonTest.dependencies { implementation(kotlin("test")) } }. - The
kotlin("test")shorthand is a capability-based dependency (Kotlin 1.4+). On the JVM it resolves to the JUnit4 variant (kotlin-test-junit) by default; if theTesttask is configured withuseJUnitPlatform(), it resolves to the JUnit5 variant (kotlin-test-junit5) instead. You can also depend on a specific adapter explicitly:org.jetbrains.kotlin:kotlin-test-junit,:kotlin-test-junit5,:kotlin-test-testng,:kotlin-test-js. - A test runner on the classpath for the chosen target —
kotlin.test's@Testis only an annotation; something has to execute it (JUnit, TestNG, the Kotlin/JS or Kotlin/Native test infra). The adapter artifact pulls it in.
Workflow
1. Add
kotlin("test")to the test source set, not a hardcoded JUnit dependency. In a multiplatform module put it incommonTest; in a JVM/Android module usetestImplementation(kotlin("test")). Do not also addjunit:junitororg.junit.jupiter:*directly unless you have framework-specific needs — the adapter brings the right one.2. Import the framework-agnostic symbols.
import kotlin.test.*(or the specific ones). The annotations come fromkotlin.test, notorg.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 */ }
}
On JVM-JUnit4, kotlin.test.Test is typealias'd to org.junit.Test, BeforeTest to org.junit.Before, AfterTest to org.junit.After, Ignore to org.junit.Ignore. On JUnit5 they map to org.junit.jupiter.api.Test/BeforeEach/AfterEach/Disabled. So the same source runs on either engine, and in commonTest.
- 3. Pick the precise assertion.
kotlin.testis small on purpose — there is exactly one obvious call per intent:
| Intent | Call |
|---|---|
Values equal (by ==) |
assertEquals(expected, actual, message?) |
| Floating-point within tolerance | assertEquals(expected, actual, absoluteTolerance, message?) (Double/Float, since 1.5) |
| Values not equal | assertNotEquals(illegal, actual, message?) (also a float-tolerance overload) |
Same / not-same instance (===) |
assertSame / assertNotSame |
null / not-null |
assertNull(actual) / assertNotNull(actual) — assertNotNull returns the non-null T (and has an assertNotNull(actual) { it -> … } block form) |
| Is / is-not a type | assertIs<T>(value) / assertIsNot<T>(value) (since 1.5) — assertIs<T> returns T smart-cast, so chain assertions on it |
| Ordered content equal | assertContentEquals(expected, actual, message?) for Iterable?/Sequence?/Array?/primitive arrays (since 1.5). Order-sensitive — for Set use assertEquals instead (the Set overload of assertContentEquals is deprecated as ambiguous) |
| Membership | assertContains(container, element, message?) — Iterable/Sequence/Array/Map (key)/IntRange/CharSequence/etc. (since 1.5) |
| Boolean condition | assertTrue(actual, message?) / assertFalse(actual, message?), or assertTrue(message?) { lazyCondition } when the condition itself is expensive |
| Unconditional failure | fail(message?) or fail(message?, cause) (since 1.4) — returns Nothing, so it satisfies the type checker in when/?: |
| "this block returns X" | expect(expected) { block } / expect(expected, message) { block } |
| A block throws | assertFails { block } returns the Throwable; assertFailsWith<E> { block } / assertFailsWith(E::class) { block } returns the typed E for further assertions on its message |
- 4. Assert on a thrown exception with
assertFailsWith, not atry/catch. It returns the exception so you can assert its message/cause:
@Test fun withdrawOverdraws() {
val e = assertFailsWith<InsufficientFundsException> { account.withdraw(BigDecimal(100)) }
assertEquals("balance is 20", e.message)
}
5. Lean on
@OnlyInputTypes— type mismatches are compile errors, not runtime failures.assertEquals,assertContentEquals,assertContains,assertSame,expectare declared with<@OnlyInputTypes T>, soassertEquals(1, "1")does not compile. Don't widen toAnyto "make it pass" — fix the test.6. Restore JUnit-style diffs on the JVM by keeping the JUnit adapter on the classpath.
kotlin.testresolves anAsserterat runtime viaServiceLoader<AsserterContributor>. Thekotlin-test-junitartifact registers a contributor that, whenorg.junit.Assertis present, returns aJUnitAsserterdelegatingassertEqualstoorg.junit.Assert.assertEquals— which throwsComparisonFailure, giving the clickable expected/actual diff in IDEs. With no contributor,DefaultAsserterthrows a plainAssertionError. Sokotlin.testfailures look like JUnit failures automatically as long as the adapter is there (it is, viakotlin("test")). Only implement a customAsserter+ register anAsserterContributor(JVM:META-INF/services/kotlin.test.AsserterContributor) if you need a non-JUnit reporting backend.
Patterns
Pattern: importing org.junit.* annotations in a multiplatform-shared test
// 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()) }
}
Pattern: hand-rolled exception checking
// 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)
}
Pattern: assertTrue(a == b) instead of 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}")
Pattern: comparing list content
// 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())
Mandatory rules
- MUST depend on
kotlin("test")(incommonTestfor multiplatform,testImplementation(kotlin("test"))for JVM/Android) rather than importingorg.junit.*directly, so the test source is engine-agnostic and multiplatform-portable. - MUST import annotations from
kotlin.test(@Test,@BeforeTest,@AfterTest,@Ignore), notorg.junit/org.junit.jupiter.api. - MUST use
assertEquals/assertNotEquals/assertContentEquals/assertIs/assertContainsfor their specific intents instead of collapsing everything intoassertTrue(...)— the specific calls produce expected/actual diagnostics. - MUST check thrown exceptions with
assertFailsWith<E> { … }(returning the exception for message/cause assertions), nottry/catch+fail. - MUST NOT widen argument types (
as Any,as List<Any>) to silence an@OnlyInputTypescompile error — that error is catching a real mismatch. - MUST NOT call
assertContentEqualson aSet(deprecated, ambiguous); useassertEqualsfor unordered set equality. - MUST NOT drop the
kotlin-test-junit/kotlin-test-junit5adapter on the JVM — without anAsserterContributoron the classpath, failures degrade to bareAssertionErrorwith no diff. - PREFERRED:
assertNotNull(x)/assertIs<T>(x)overx!!/x as Tin tests — they fail with a clear message instead of an NPE/ClassCastExceptionand return the narrowed value. - PREFERRED: the
assertTrue(message) { expensiveCondition() }lazy-block overload when computing the condition or message is costly.
Verification
- No
import org.junit.*orimport org.junit.jupiter.api.*in source sets meant to be multiplatform/shared;grep -rn 'import org.junit' src/commonTest src/testis empty (or justified per-platform only). -
./gradlew :module:dependencies --configuration testRuntimeClasspath(JVM) showskotlin-test-junitorkotlin-test-junit5resolved via thekotlin("test")capability. - Test classes use
@Test/@BeforeTest/@AfterTestfromkotlin.test. - Exception cases use
assertFailsWith<…> { … }rather thantry/catch. - No
as Any/as List<Any>widening was added to make an assertion compile. - A deliberately failing
assertEqualsin a JVM module renders as a JUnitComparisonFailure(clickable diff) — proof theAsserterContributoris on the classpath.
References
- kotlinlang.org/api/latest/kotlin.test/ — the
kotlin.testAPI reference: everyassert*function,expect,fail, the annotations,Asserter/AsserterContributor/DefaultAsserter. - kotlinlang.org/docs/jvm-test-using-junit.html and kotlinlang.org/docs/multiplatform-run-tests.html — using
kotlin("test")on the JVM (JUnit4/JUnit5 selection) and across multiplatform targets. libraries/kotlin.test/annotations-common/src/main/kotlin/kotlin.test/Annotations.kt(Kotlin repo) —expect annotation class Test/Ignore/BeforeTest/AfterTest; the JVMactual typealiases inlibraries/kotlin.test/junit/src/main/kotlin/Annotations.kt(→org.junit.*) andlibraries/kotlin.test/junit5/src/main/kotlin/Annotations.kt(→org.junit.jupiter.api.*).libraries/kotlin.test/common/src/main/kotlin/kotlin/test/Assertions.kt(Kotlin repo) —assertTrue/assertFalse/assertEquals/assertNotEquals/assertSame/assertNotSame/assertNull/assertNotNull/assertIs/assertIsNot/assertContains/assertContentEquals/fail/expect/assertFails/assertFailsWithsignatures, the@OnlyInputTypesannotations, and theAsserter/AsserterContributorinterfaces.libraries/kotlin.test/jvm/src/main/kotlin/AsserterLookup.ktandlibraries/kotlin.test/junit/src/main/kotlin/JUnitSupport.kt(Kotlin repo) —ServiceLoader<AsserterContributor>lookup;JUnitContributor/JUnitAsserterdelegating toorg.junit.Assertwhen present, elseDefaultAsserter.- Cross-set:
../../../jvm-tests/runner/configuring-junit4-on-android/SKILL.md— the JUnit4 runner /AndroidJUnit4stack;kotlin.testassertions slot into those tests unchanged. - Cross-set:
../../../jvm-tests/coroutines/testing-coroutines-with-runtest/SKILL.md—runTestis the surrounding scope;kotlin.testsupplies theassert*calls inside it. - Cross-set:
../../../jvm-tests/coroutines/testing-flows-with-turbine/SKILL.md— Turbine'sawaitItem()paired withkotlin.test'sassertEquals. - Cross-set:
../../../fundamentals/strategies/organizing-test-source-sets/SKILL.md— where these tests live (src/test/,src/commonTest/) and thetestImplementationvsandroidTestImplementationsplit. - Cross-set:
../../../fundamentals/doubles/picking-test-doubles/SKILL.md— fakes/stubs the assertions are checking.