Recommended resources
The freely available resources this lesson will direct you to.
- The Ultimate Guide to Android Testing (Unit Tests, UI Tests, End-to-End Tests) - Clean Architecturewww.youtube.com
- Stop Using JUnit BeforeEach!www.youtube.com
- Parameterized Tests in Kotlin – Sam Radhakrishnan – Developer based in Berlinsam09.github.io
- JUnit 5 for Kotlin Developers | Baeldung on Kotlinwww.baeldung.com
Welcome back. Last time, you implemented longestUniqueSubstringLength and tried a few inputs in main. Those calls were useful while developing the sliding window, but they cannot automatically tell you when a later change breaks it.
Today you’ll turn those examples into parameterized unit tests: one test definition run with several independently reported inputs. You’ll also test a small page-size parser, because the substring function has no invalid String input that it promises to reject. That distinction matters: a good failure test verifies a real contract, rather than inventing an error the function should never produce. Allow about 40–45 minutes, including reading and a coding pass.
Start with the contract, not the annotation
A parameterized test is valuable only if its cases represent meaningfully different behavior. For the substring function, use the contract from last lesson: it returns the length of the longest substring with no repeated Kotlin Char values; an empty string returns 0.
| Input | Expected | Why keep this case? |
|---|---|---|
"" |
0 | Empty-input boundary |
"a" |
1 | Smallest nonempty input |
"aaaa" |
1 | Every later character repeats |
"abba" |
2 | Removing one character is not enough to restore the window |
"dvdf" |
3 | The best window grows after an earlier repeat |
"abcd" |
4 | No shrinking is needed |
These expectations come from the function’s contract, not from running the implementation and copying its output. In particular, "abba" is worth retaining even though other cases contain duplicates: it can expose a change from while to if in the window-shrinking logic.
A test runner executes each row separately and reports which input failed. The IntelliJ result below illustrates that reporting model: two parameterized phone-number tests each produced three executions. Six passed describes those six supplied cases; it does not, by itself, establish that every important boundary was tested.

For a concise introduction to the mechanism, read Sam Radhakrishnan’s Parameterized Tests in Kotlin.
Parameterized Tests in Kotlin – Sam Radhakrishnan – Developer based in Berlin
Read the introduction and the “Getting Started” and “Simple example” sections to see how one test runs with multiple inputs.
Begin at the introduction and example. Stop after the explanation of @ValueSource, before “Customizing The Display Name.” Notice that @ValueSource supplies one argument per invocation; our substring cases need both an input and an expected answer, so we’ll use a different source for them.
Choose a source that fits the test data
JUnit 5’s @ParameterizedTest marks the test method. An argument source supplies one set of values for each invocation. Three sources are enough for today:
@ValueSourcesuits several inputs that all have the same expected behavior, such as strings that must be rejected.@CsvSourcesuits short, readable rows of simple inputs and expected values.@MethodSourcesuits rows that need more structure, including our named substring scenarios. In a Kotlin test class, a source in its companion object needs@JvmStaticso JUnit can call it as a static method.
We also need assertThrows for failure cases: it checks that the specific call inside its lambda throws the expected exception type.
Read the relevant portions of Baeldung’s JUnit 5 for Kotlin Developers. Its sample dependency versions are dated; use the setup shown later in this lesson rather than copying versions from the article.
JUnit 5 for Kotlin Developers | Baeldung on Kotlin
Read the Kotlin-specific @MethodSource setup and the exception assertion. They explain the two details most likely to interrupt your first test run.
In “4.2. Parameterized Tests,” start with the paragraph about calling a source function from a Kotlin test class. Read the source setup, including the @JvmStatic companion-object example; stop after the @CsvSource explanation and example. Then, in “3.1. Asserting Exceptions,” read the exception example, ending with the assertion on the caught exception.
Give failure cases their own contract
longestUniqueSubstringLength accepts every non-null String, including "". An empty input is a boundary case, not a failure case. Kotlin’s non-null parameter also means passing null from ordinary Kotlin code is not a meaningful test of this function.
Consider instead a setting an Android feature might parse before requesting a page of data. Define its contract precisely: accept a decimal integer from 1 through 100, inclusive; reject malformed text and out-of-range values with IllegalArgumentException.
Keep your substring function from the previous lesson. Add this function to your production Kotlin source:
fun parsePageSize(raw: String): Int {
val size = raw.toIntOrNull()
?: throw IllegalArgumentException("Page size must be a whole number")
require(size in 1..100) {
"Page size must be between 1 and 100"
}
return size
}
Now the test choices follow the contract. 1 and 100 test the inclusive boundaries; 25 is an ordinary valid value. 0, 101, and -1 are parseable but out of range. "", "abc", "1.5", and a number too large for Int cannot be parsed by toIntOrNull(). The two invalid groups should not be combined merely because they share an exception type: they reach different decisions in the function.
In a real codebase, agree on details such as whether surrounding whitespace is allowed before writing the tests. This implementation does not trim the input; a test should document that choice only if it is part of the intended product contract.
Write and run the parameterized suite
For an Android app module using JUnit 5 for local JVM tests, place the test under app/src/test/java (a .kt file is fine there). A compatible Gradle Kotlin DSL setup is:
import org.gradle.api.tasks.testing.Test
dependencies {
testImplementation(platform("org.junit:junit-bom:5.11.4"))
testImplementation("org.junit.jupiter:junit-jupiter-api")
testImplementation("org.junit.jupiter:junit-jupiter-params")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
Use the same package for the production functions and test, or import the functions. If your project already has a JUnit setup, check its conventions before changing the test engine: enabling the JUnit Platform does not automatically make existing JUnit 4 tests run on it. These are local unit tests, not androidTest device tests.
Here is the suite. The scenario label makes a failed substring row recognizable; the parser tests separate successful values from its two failure paths.
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.MethodSource
import org.junit.jupiter.params.provider.ValueSource
class KotlinFunctionsTest {
@ParameterizedTest(name = "{index}: {0}")
@MethodSource("substringCases")
fun `finds longest substring without repeated chars`(
scenario: String,
input: String,
expected: Int
) {
assertEquals(expected, longestUniqueSubstringLength(input), scenario)
}
@ParameterizedTest(name = "page size {0} is valid")
@CsvSource(
"1, 1",
"25, 25",
"100, 100"
)
fun `accepts page sizes in range`(raw: String, expected: Int) {
assertEquals(expected, parsePageSize(raw))
}
@ParameterizedTest(name = "page size {0} is out of range")
@ValueSource(strings = ["0", "101", "-1"])
fun `rejects out of range page sizes`(raw: String) {
val error = assertThrows(IllegalArgumentException::class.java) {
parsePageSize(raw)
}
assertEquals("Page size must be between 1 and 100", error.message)
}
@ParameterizedTest(name = "page size [{0}] is not an integer")
@ValueSource(strings = ["", "abc", "1.5", "99999999999999999999"])
fun `rejects malformed page sizes`(raw: String) {
val error = assertThrows(IllegalArgumentException::class.java) {
parsePageSize(raw)
}
assertEquals("Page size must be a whole number", error.message)
}
companion object {
@JvmStatic
fun substringCases(): List<Arguments> = listOf(
Arguments.of("empty input", "", 0),
Arguments.of("one character", "a", 1),
Arguments.of("all repeated", "aaaa", 1),
Arguments.of("shrink past earlier duplicate", "abba", 2),
Arguments.of("grow after shrinking", "dvdf", 3),
Arguments.of("all distinct", "abcd", 4)
)
}
}
Run the app module’s local debug unit tests with ./gradlew :app:testDebugUnitTest, or run this test class from the IDE. You should see 16 invocations, not four: six substring rows, three valid page sizes, three out-of-range values, and four malformed values. If JUnit reports no tests found, first check that the JUnit Platform and Jupiter engine are configured; if it cannot resolve @ParameterizedTest, check the junit-jupiter-params dependency.
The assertions deliberately call the function inside assertThrows for failure rows. Calling it before that block would let the exception escape before JUnit could verify it. Conversely, a failure test with no assertion about the exception type could pass for the wrong reason.
For a quick check of the suite’s sensitivity, imagine changing the substring function’s shrinking while to if: the "abba" row should object. Changing the parser’s upper bound from 100 to 99 should make the 100 row fail. This is a useful way to review a test set: ask which plausible mistake each important row would catch.
Key takeaways
Parameterized tests replace repetitive test methods with independently reported contract examples. Choose cases by behavior: ordinary values, boundaries, and distinct failure paths. Use @MethodSource when rows benefit from structure, @CsvSource for simple input–expected pairs, and @ValueSource when each input shares the same assertion. Most importantly, do not manufacture a failure case for a function that has no such failure in its contract.
This completes the Kotlin foundations module. Next, we’ll move into coroutines and Flow, where tests will eventually need to account for asynchronous execution as well as input values.