skip to content

How does kotlin.test make @Test in common code resolve to the right test framework on each target? Explain the expect/actual (typealias) mechanism.

level: seniorimportance: should knowfreq 35%

answer

  1. expect in common, actual per target
  2. JVM actual = typealias to JUnit annotation
  3. typealias = same type, so JUnit discovers it
  4. kotlin-test-junit vs kotlin-test-junit5 picks the runner
  5. lowest-common-denominator API

basics

~20 s

kotlin.test declares the annotations once in common code. For each platform it provides a matching real version, so on the JVM @Test becomes JUnit's @Test, on JS it becomes a JS runner's, and so on. One annotation, many backings.

solid answer

~40 s

In a multiplatform library, common code can declare a symbol with the `expect` keyword and each target supplies an `actual`. kotlin.test uses this for its annotations: in the common source set `@Test`, `@BeforeTest`, `@AfterTest`, `@Ignore` are effectively expected declarations, and each platform module provides the `actual`. On the JVM the actual is a **typealias** to the underlying JUnit annotation (JUnit 4's `org.junit.Test` or JUnit Platform's, depending on the chosen `kotlin-test-junit`/`kotlin-test-junit5` artifact). On JS the actuals map to the JS test runner, and on Native to the Kotlin/Native test runner. So when JUnit scans for its own annotation on the JVM, it finds it via the typealias and runs the test. The mapping is resolved at compile time per target — the common code never references a platform-specific framework.

code

kotlin · 11 lines
kotlin
// commonTest source set — no platform imports
import kotlin.test.Test
import kotlin.test.assertTrue

class VersionTest {
    @Test fun isPositive() = assertTrue(LibVersion.major >= 0)
}

// Under the hood on JVM, kotlin.test.Test is:
//   actual typealias Test = org.junit.jupiter.api.Test  (with kotlin-test-junit5)
// so the JUnit Platform runner discovers and executes isPositive().

go deeper

for a junior

Knows the same @Test 'just works' on multiple platforms without the mechanism.

for a middle

Can say expect/actual maps the annotation to JUnit on the JVM.

for a senior

Explains the actual typealias, why type identity lets JUnit discover it, and the runner-selecting artifacts.

for a principal

Discusses the lowest-common-denominator trade-off, when to split JVM-only test source sets, and API evolution constraints of expect/actual.

## expect / actual in one paragraph Kotlin Multiplatform's core mechanism for platform-specific code is the **`expect`/`actual`** pair. In the **common** source set you declare an `expect` symbol (function, class, property, typealias, or annotation) — a contract with no body. Each **platform** source set provides a matching `actual` with the real implementation. The compiler checks, per target, that every `expect` has exactly one `actual`. ## How kotlin.test applies it to annotations The annotations you import from `kotlin.test` (`@Test`, `@BeforeTest`, `@AfterTest`, `@Ignore`) are platform-neutral in common code. Per target, kotlin.test supplies the `actual` — and on the JVM the cleanest way to make a *third-party* framework discover your tests is an **`actual typealias`**: ```kotlin // Conceptually, in the JVM source set of kotlin-test: actual typealias Test = org.junit.Test // with kotlin-test-junit (JUnit 4) // or, with kotlin-test-junit5: actual typealias Test = org.junit.jupiter.api.Test ``` A **typealias** doesn't create a new type — it makes `kotlin.test.Test` *the very same annotation* JUnit looks for. So when the JUnit runner scans the compiled class, it sees its own `@Test` and runs the method. ## Per-target backings - **JVM** — JUnit. You choose the runner by dependency: `kotlin-test-junit` (JUnit 4) or `kotlin-test-junit5` (JUnit Platform / Jupiter). The Gradle Kotlin plugin can auto-wire this via `useJUnitPlatform()` / `Test.useJUnit()`. - **JS** — maps to a JS test framework (Mocha/Jasmine-style) executed by the Kotlin/JS test infrastructure. - **Native** — the Kotlin/Native built-in test runner generates a test executable. - **Wasm** — the Kotlin/Wasm test runner. ## Why this design matters The common source set stays free of any JVM-only import, so the *same* test sources compile and run everywhere. The price is a **lowest-common-denominator** API: features unique to JUnit 5 (parameterized tests, `@Nested`, extensions) are not available through kotlin.test — you'd drop to JVM-only test source sets for those. ## Gotcha Because the JVM `@Test` is an alias to JUnit's, JVM-only tests can freely mix kotlin.test assertions with native JUnit features — but only in the JVM (not common) source set.

  • Why a typealias rather than a brand-new annotation that wraps JUnit's?
    A typealias keeps the type identity identical to JUnit's annotation, so JUnit's reflection-based discovery finds it with zero adapter. A wrapper type would require a custom runner.
  • Can you use JUnit 5 @ParameterizedTest from common code?
    No. It's JVM/JUnit-specific and not part of the kotlin.test common API. You'd write that test in the JVM source set only.

expect/actual is a job posting (expect) filled by a different hire in each office (actual); the JVM 'hire' is really JUnit wearing a kotlin.test name tag (typealias).

saying these in an interview costs you the question

  • Claiming kotlin.test reimplements JUnit on every platform
  • Saying the mapping happens at runtime via reflection rather than at compile time
  • Thinking @Test is a new wrapper type rather than a typealias on JVM
  • Believing you can access all JUnit 5 features through common kotlin.test

context