Recommended resources
The freely available resources this lesson will direct you to.
Hello! Last lesson, you chose between null, exceptions, and Result to make failures visible to callers. Now you’ll turn those choices into executable checks. You’ll write ordinary assertions for specific behaviors, use parameterized tests for families of inputs, and replace an external dependency with a small test double when testing a service.
We’ll use JUnit Jupiter on the JVM. The goal is not merely to get a green test result; it is to check the contract a caller depends on, including boundaries and failure paths.
Start with a contract and an assertion
Consider the parser from the previous lesson. A valid non-negative integer is returned as an Int; anything else produces null:
fun parseAttemptCount(raw: String): Int? {
val count = raw.toIntOrNull() ?: return null
return if (count >= 0) count else null
}
A unit test calls this function with a known input and compares its actual output with an expected one. Before writing the tests, look at the Kotlin documentation’s setup and test examples. Its sample tests Java code from Kotlin, but the JUnit annotations and assertions work the same way when the code under test is Kotlin.
Test Java code using Kotlin and JUnit – tutorial | Kotlin Documentation
Read Kotlin’s JUnit tutorial for the JVM test setup and the shape of a Kotlin test class. You do not need to build its sample application.
In “Configure the project,” go to the Gradle build.gradle.kts example in step 3; skip the Maven configuration and version-catalog listing. Read from the notes beginning the Gradle notes, focusing on test dependencies and JUnit Platform. Then, in “Add your first Kotlin test,” read from the test examples. Notice what @Test, @BeforeEach, and assertThrows each do.
In a Kotlin/JVM Gradle project, tests normally live under src/test/kotlin. The tutorial’s Gradle setup enables JUnit Platform and includes JUnit Jupiter’s API, parameterized-test support, and runtime engine. Use the versions and dependency declarations appropriate to your project; without the engine or useJUnitPlatform(), a test that compiles may not run through Gradle’s test task.
Here are two tests for the parser. The imports come from JUnit Jupiter:
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class AttemptCountTest {
@Test
fun `zero is a valid attempt count`() {
val actual = parseAttemptCount("0")
assertEquals(0, actual)
}
@Test
fun `non numeric input has no count`() {
val actual = parseAttemptCount("unknown")
assertNull(actual)
}
}
@Test tells JUnit to run each function as a test. In assertEquals(expected, actual), the order matters: if it fails, JUnit can report what you expected and what the function returned. The first test is especially useful because 0 is a boundary value that must not be mistaken for absence.
A test should make its claim observable. Calling parseAttemptCount("unknown") without an assertion would show that it did not throw, but would not check the promised null result. Likewise, a test name describes intent; the assertion is what checks it.

The image shows a multiplatform commonTest example, whereas our examples use JVM JUnit tests. In either setting, read a green result narrowly: ask which behavior did this test check?
One rule, several cases
Separate tests are valuable when cases express different rules. But repeatedly copying the same test body for different inputs can obscure the rule they share. A parameterized test runs one test method once per supplied case and reports the invocations separately.
Parameterized Classes and Tests :: JUnit User Guide
Read the JUnit User Guide’s introduction to parameterized tests and the argument sources used below. Focus on parameterized methods, not parameterized classes.
Under “Parameterized Classes and Tests,” read the opening explanation and @ValueSource example, beginning the introduction. Then go to “Sources of Arguments”: read the “@ValueSource” subsection, including its example, around single arguments. Finally, read the “@CsvSource” subsection’s opening example and explanation around CSV records. You can leave the other source types for later.
For valid inputs, each case below supplies both a raw string and the result it should produce:
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
class ValidAttemptCountTest {
@ParameterizedTest
@CsvSource(value = ["1, 1", "04, 4", "12, 12"])
fun `parses valid counts`(raw: String, expected: Int) {
assertEquals(expected, parseAttemptCount(raw))
}
}
JUnit converts the second CSV column to the test method’s Int parameter. It runs the method three times, once for each record. Notice that "04" checks a slightly different detail from "1": the returned number is 4, not a preserved string representation.
Invalid inputs need only one supplied argument because they all have the same expected result:
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
class InvalidAttemptCountTest {
@ParameterizedTest
@ValueSource(strings = ["unknown", "-1", "", " ", "2147483648"])
fun `invalid counts produce no value`(raw: String) {
assertNull(parseAttemptCount(raw))
}
}
These cases cover non-numeric text, a negative value, empty and blank strings, and a number outside Int’s range. Parameterization reduces duplication; it does not choose good cases for you. Keep cases together when they check the same rule, and use a separate, clearly named test when a behavior deserves a distinct explanation—such as zero being valid.
Testing what a service does with a dependency
A parser has no collaborators, so its output is enough to inspect. A service may make a consequential call to another component. Suppose a delivery service validates its inputs, then asks a gateway to send a message:
interface MessageGateway {
fun send(recipient: String, attempts: Int): String
}
class DeliveryService(private val gateway: MessageGateway) {
fun dispatch(recipient: String, rawAttempts: String): Result<String> {
require(recipient.isNotBlank()) { "Recipient must not be blank" }
val attempts = parseAttemptCount(rawAttempts)
?: return Result.failure(
IllegalArgumentException("Invalid attempt count")
)
return runCatching { gateway.send(recipient, attempts) }
}
}
This short service has three distinct failure contracts. A blank recipient throws immediately. An invalid count returns a failed Result without contacting the gateway. If the gateway throws, runCatching returns that failure in a Result.
A unit test should not need a real network connection to check these decisions. Because the service accepts a MessageGateway, a test can supply another implementation of that interface. Such a replacement is a test double. This one returns a controlled receipt and records calls:
class RecordingGateway(
var nextReceipt: String = "accepted",
var failure: Exception? = null
) : MessageGateway {
val calls = mutableListOf<Pair<String, Int>>()
override fun send(recipient: String, attempts: Int): String {
calls += recipient to attempts
failure?.let { throw it }
return nextReceipt
}
}
RecordingGateway is a small hand-written fake with a recording feature. It does not simulate a network; that is precisely why these tests remain focused on DeliveryService. A mocking library can create configurable doubles too, but an interface implementation is often clearer when the behavior is this small.
The successful case checks both the returned receipt and the request sent to the gateway:
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class DeliveryServiceTest {
@Test
fun `valid dispatch sends the parsed count once`() {
val gateway = RecordingGateway()
val service = DeliveryService(gateway)
val result = service.dispatch("recipient@example.test", "2")
assertEquals("accepted", result.getOrThrow())
assertEquals(
listOf("recipient@example.test" to 2),
gateway.calls
)
}
}
A receipt alone would not show whether the service passed the correct count. The recorded list checks the arguments and, because it contains exactly one entry, the number of calls. Here an interaction assertion matters because sending is the service’s purpose; for a pure calculation, inspecting the result would usually be enough.
For the invalid count, the important fact is that sending does not happen:
@Test
fun `invalid count returns a failure without sending`() {
val gateway = RecordingGateway()
val service = DeliveryService(gateway)
val result = service.dispatch("recipient@example.test", "unknown")
assertEquals(
"Invalid attempt count",
result.exceptionOrNull()?.message
)
assertEquals(emptyList<Pair<String, Int>>(), gateway.calls)
}
Put this method inside DeliveryServiceTest, alongside the successful test. Each method constructs a fresh gateway, so one test’s calls cannot leak into another. The failure assertion also distinguishes a returned failed Result from a thrown exception: dispatch completes and gives the test a result to inspect.
The remaining two paths need different assertions:
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
@Test
fun `gateway exception is returned as a failure`() {
val problem = IllegalStateException("Gateway unavailable")
val gateway = RecordingGateway(failure = problem)
val service = DeliveryService(gateway)
val result = service.dispatch("recipient@example.test", "2")
assertSame(problem, result.exceptionOrNull())
assertEquals(listOf("recipient@example.test" to 2), gateway.calls)
}
@Test
fun `blank recipient throws before sending`() {
val gateway = RecordingGateway()
val service = DeliveryService(gateway)
val problem = assertThrows(IllegalArgumentException::class.java) {
service.dispatch(" ", "2")
}
assertEquals("Recipient must not be blank", problem.message)
assertTrue(gateway.calls.isEmpty())
}
Add these methods to the same class. assertThrows checks an exception that escapes the call and returns it for inspection. By contrast, exceptionOrNull() examines a failure inside a returned Result. assertSame checks that the gateway’s original exception was retained, not replaced with a different one.
A test double gives control, but also sets a limit on what these tests prove. They check the service’s decisions about its gateway; they cannot prove that a real gateway can connect or deliver a message. That connection will need a different kind of test later. For now, prefer the smallest double that exposes the behavior you actually need to check.
Key takeaways
A useful unit test specifies an input, runs the code, and asserts an observable part of its contract. Use parameterized tests when several inputs check the same rule, while choosing boundary and failure cases deliberately. When a unit depends on another component, inject a test double to control its response and inspect meaningful calls without involving external systems.
Next, you’ll use these Kotlin foundations for structured asynchronous work with coroutines. The same testing question will remain: what observable result or interaction demonstrates that the code behaved as intended?