Structuring Tests with JUnit 5 and Kotlin
JUnit 5 plus Kotlin's backtick names turns tests into readable, self-documenting specifications.
JUnit 5 (also called JUnit Jupiter) is the default testing engine in Spring Boot 3 and the standard choice for Kotlin projects. A test is simply a function annotated with @Test, declared inside an ordinary class. Unlike JUnit 4, the test class and methods do not need to be public, which suits Kotlin perfectly since members are public by default and the framework can use reflection to access them. You pull it in through the org.junit.jupiter:junit-jupiter dependency, or for free via spring-boot-starter-test, and run it with the Gradle test task.
The most Kotlin-friendly feature is the backtick identifier. Kotlin lets you name a function with any string by wrapping it in backticks, so instead of a cramped camelCase name like addsTwoNumbers you write fun `adds two numbers`(). The test report then reads as a plain-English sentence, which makes failures instantly understandable. This is the idiomatic way to name tests in Kotlin and it pairs naturally with the Arrange-Act-Assert structure inside the body. Note that backtick names with spaces are not legal on the JVM platform target for Android instrumentation tests, but they work everywhere on the standard JVM where Spring runs.
Lifecycle annotations control setup and teardown. A method annotated with @BeforeEach runs before every single test method, giving each test a fresh, isolated fixture; @AfterEach runs after each one to clean up resources. There are also @BeforeAll and @AfterAll for one-time setup shared across the whole class, but because JUnit 5 creates a new test instance per method by default, those must be static, which in Kotlin means placing them inside a companion object annotated with @JvmStatic (or switching the class to PER_CLASS lifecycle). Favor @BeforeEach for anything that must be independent between tests to avoid shared mutable state and flaky ordering bugs.
Assertions are the heart of a test. JUnit 5 ships them as top-level functions in org.junit.jupiter.api.Assertions, and the Kotlin-flavored variants in the same package read cleanly. assertEquals(expected, actual) verifies a value, with the expected argument first by convention. assertThrows<T> { ... } is the idiomatic Kotlin form: it is a reified inline function that runs the lambda, asserts that an exception of type T was thrown, and returns the caught exception so you can further inspect its message. This is far better than wrapping code in try/catch with a manual fail() call.
When you need to verify several independent properties of a result, reach for assertAll. It takes a vararg of executable lambdas and runs all of them even if some fail, then reports every failure together. This prevents the frustrating cycle of fixing one assertion, rerunning, and discovering the next broken one. Group related checks (for example, all the fields of a returned object) inside a single assertAll block so a single run paints the full picture of what is wrong.
@DisplayName provides a human-readable label for a test or an entire test class, shown in IDE runners and CI reports. With Kotlin's backtick names you often do not need it on individual methods, but it shines on the class itself and when you want emoji, punctuation, or wording that even backticks cannot express cleanly. You can also apply @DisplayNameGeneration with a generator like ReplaceUnderscores to auto-convert method names, though backticks usually make that unnecessary.
Putting it together, a well-structured JUnit 5 test class in Kotlin reads top to bottom as a specification: a @DisplayName describing the unit under test, a @BeforeEach that builds a fresh subject, and a series of backtick-named functions each following Arrange-Act-Assert with focused assertions. Keep one logical behavior per test, name it after the behavior rather than the method it calls, and let assertAll batch the related checks. This style scales from a tiny calculator to a full Spring service test without losing clarity.
For Spring Boot specifically, these same primitives underpin everything you will write later: a @SpringBootTest or a sliced @WebMvcTest is still just a JUnit 5 class with @Test methods, lifecycle hooks, and these assertions. Mastering the plain JUnit structure first means that when you add Spring's test context, MockMvc, or @MockkBean, you are only layering framework wiring on top of fundamentals you already understand. Run your suite often with ./gradlew test and treat a green bar as a precondition for every commit.
import org.junit.jupiter.api.BeforeEachimport org.junit.jupiter.api.DisplayNameimport org.junit.jupiter.api.Testimport org.junit.jupiter.api.assertThrowsimport org.junit.jupiter.api.Assertions.assertEqualsclass Calculator {fun add(a: Int, b: Int): Int = a + bfun divide(a: Int, b: Int): Int {require(b != 0) { "cannot divide by zero" }return a / b}}@DisplayName("Calculator")class CalculatorTest {private lateinit var calculator: Calculator@BeforeEachfun setUp() {// Runs before every test, so each gets a fresh instancecalculator = Calculator()}@Testfun `adds two numbers`() {val result = calculator.add(2, 3)assertEquals(5, result) // expected first, actual second}@Testfun `throws when dividing by zero`() {val ex = assertThrows<IllegalArgumentException> {calculator.divide(10, 0)}assertEquals("cannot divide by zero", ex.message)}}
A complete, idiomatic JUnit 5 test class in Kotlin: @DisplayName on the class, @BeforeEach for a fresh fixture, backtick test names, and assertEquals / assertThrows<T>. This runs on plain Kotlin stdlib plus the JUnit 5 dependency.
import org.junit.jupiter.api.Testimport org.junit.jupiter.api.assertAllimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.api.Assertions.assertTruedata class User(val name: String, val age: Int, val active: Boolean)class UserTest {@Testfun `builds a valid adult user`() {val user = User(name = "Ada", age = 36, active = true)// All three checks run; a single failure does not hide the othersassertAll({ assertEquals("Ada", user.name) },{ assertTrue(user.age >= 18, "user should be an adult") },{ assertTrue(user.active, "user should be active") },)}}
assertAll runs every assertion even if earlier ones fail, then reports all failures at once. Ideal for checking multiple fields of a single returned object. Requires the JUnit 5 dependency.
import org.junit.jupiter.api.AfterAllimport org.junit.jupiter.api.BeforeAllimport org.junit.jupiter.api.Testclass DatabaseSuiteTest {companion object {@JvmStatic@BeforeAllfun startSharedResource() {// e.g. spin up a Testcontainer once for the whole classprintln("shared setup")}@JvmStatic@AfterAllfun stopSharedResource() {println("shared teardown")}}@Testfun `uses the shared resource`() {// ...}}
@BeforeAll / @AfterAll run once per class. Because JUnit 5 uses a new instance per test by default, in Kotlin they must live in a companion object with @JvmStatic.
🧠Check your understanding
0/1 · 0/1 answered1. In a Kotlin JUnit 5 test class, why is assertThrows<IllegalArgumentException> { service.call() } preferred over a manual try/catch with fail()?