kotlin.test & JUnit 5
@Test ยท @BeforeEach ยท @DisplayNameMost Kotlin/JVM projects run on JUnit 5 directly, using its annotations as-is โ @Test, @BeforeEach, @Nested all work unchanged from Kotlin. kotlin.test is a thin multiplatform layer on top (useful if the same test code needs to run on Kotlin/JS or Kotlin/Native too), but for a pure JVM project, writing directly against JUnit 5 is the more common and more directly documented path.
import org.junit.jupiter.api.Test import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Assertions.assertEquals class CalculatorTest { private lateinit var calculator: Calculator // Runs before every test method in this class @BeforeEach fun setUp() { calculator = Calculator() } @Test fun `adding two positive numbers returns their sum`() { val result = calculator.add(2, 3) assertEquals(5, result) } }
fun \`adding two positive numbers returns their sum\`() โ which is legal specifically because a test method is never called directly from other code, only invoked by the test runner via reflection. It's the idiomatic way to write a readable test name without a separate @DisplayName annotation, and most test reports display the backtick name verbatim.Assertion Styles
assertEquals ยท assertTrue ยท Kotest-styleJUnit 5's own Assertions class covers the basics โ equality, truthiness, exceptions, nullability โ and is often all a project needs. Some teams layer a fluent assertion library like AssertJ or Kotest's matchers on top for more expressive failure messages and chainable checks; either is a project-level choice, not something the language mandates.
import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test class UserValidatorTest { @Test fun `rejects a blank name`() { val result = UserValidator.validate(name = "") assertFalse(result.isValid) assertEquals("name cannot be blank", result.error) } @Test fun `throws for a negative age`() { // assertThrows returns the thrown exception for further assertions val exception = assertThrows<IllegalArgumentException> { UserValidator.requireValidAge(-1) } assertEquals("age must be non-negative", exception.message) } @Test fun `nullable result is null for an unknown id`() { assertNull(UserRepository.findById(-1)) } }
assertThrows<ExceptionType> { block } as an inline function โ a much cleaner call than Java's equivalent, which needs the exception's Class object passed explicitly as an argument.Parameterized Tests
@ParameterizedTest ยท @ValueSource ยท @CsvSourceA parameterized test runs the same test body once per input, reported as separate test results โ the direct equivalent of Go's table-driven test pattern, but with the table declared as an annotation rather than a slice literal you range over by hand.
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import org.junit.jupiter.params.provider.CsvSource class StringUtilsTest { @ParameterizedTest @ValueSource(strings = ["racecar", "level", "noon"]) fun `recognizes palindromes`(input: String) { assertTrue(input.isPalindrome()) } // CsvSource: multiple named arguments per test case @ParameterizedTest @CsvSource( "2, 3, 5", "10, -5, 5", "0, 0, 0" ) fun `adds two numbers correctly`(a: Int, b: Int, expected: Int) { assertEquals(expected, a + b) } }
@MethodSource instead of @CsvSource once your test cases need real objects rather than primitives and strings โ it points at a companion object function returning a Stream of arguments, which composes naturally with data class test-case objects for readability.MockK Basics
mockk() ยท every ยท verify ยท relaxedMockito is Java's default, but it struggles with Kotlin's language features โ final classes by default, extension functions, top-level functions โ because it works by generating subclasses at runtime. MockK was built for Kotlin specifically and handles all of that natively, which is why it's the more common choice in Kotlin-first codebases.
import io.mockk.* import org.junit.jupiter.api.Test interface UserRepository { fun findById(id: Int): User? } class UserServiceTest { @Test fun `returns the user's display name`() { // mockk() creates a fake implementation of the interface val repository = mockk<UserRepository>() // every { } stubs what the mock returns for a given call every { repository.findById(1) } returns User(1, "Ada") val service = UserService(repository) val name = service.displayNameFor(1) assertEquals("Ada", name) // verify { } asserts a specific call actually happened verify(exactly = 1) { repository.findById(1) } } }
mockk<UserRepository>(relaxed = true) instead returns sensible defaults (empty collections, zero, null) for every unstubbed call, which trades that safety for less setup boilerplate when a test only cares about one or two specific interactions.runTest for Coroutines
runTest ยท virtual time ยท TestScopeA regular test function can't call a suspend function directly โ it isn't itself suspend, and there's no coroutine scope to launch into. runTest, from kotlinx-coroutines-test, solves both: it provides a TestScope and, more importantly, runs on virtual time, so a test that calls delay(10_000) completes instantly instead of actually waiting ten seconds.
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.delay import org.junit.jupiter.api.Test suspend fun fetchGreeting(): String { delay(5000) // simulates a slow network call return "Hello" } class GreetingTest { @Test fun `fetches the greeting without actually waiting 5 seconds`() = runTest { val result = fetchGreeting() // delay() is skipped forward on virtual time assertEquals("Hello", result) } // This test runs in milliseconds of real wall-clock time, // despite the 5-second delay() inside fetchGreeting() }
runTest's TestScope to launch background coroutines under test and control their virtual-time progression explicitly with functions like advanceTimeBy or advanceUntilIdle when a test needs to assert on state at a specific point mid-execution, not just at completion.Quick Reference
Syntax cheat-sheet| Concept | Syntax | Notes |
|---|---|---|
| Test method | @Test fun \`readable name\`() | Backtick names avoid needing @DisplayName |
| Setup hook | @BeforeEach fun setUp() | Runs before every test in the class |
| Equality assertion | assertEquals(expected, actual) | Expected first, actual second |
| Exception assertion | assertThrows<E> { block } | Reified generic; returns the thrown exception |
| Simple parameterization | @ParameterizedTest @ValueSource(...) | One primitive/string argument per case |
| Multi-arg parameterization | @CsvSource("a, b, c") | Comma-separated arguments per test case |
| Create a mock | mockk<Interface>() | Strict by default; throws on unstubbed calls |
| Lenient mock | mockk<Interface>(relaxed = true) | Returns defaults for unstubbed calls |
| Stub a call | every { mock.f() } returns x | Defines the mock's behavior |
| Verify a call happened | verify(exactly = 1) { mock.f() } | Asserts on interaction, not just return value |
| Test a suspend function | @Test fun f() = runTest { } | Provides a TestScope; runs on virtual time |
| Advance virtual time | advanceTimeBy / advanceUntilIdle | Controls delay() progression inside runTest |