Kotlin Parameterized Tests
When writing tests in Kotlin, you'll often find yourself creating similar test cases with different input values. This is where parameterized tests come in — they allow you to run the same test logic against multiple sets of inputs, making your test code more concise and maintainable.
What Are Parameterized Tests?
Parameterized tests let you execute the same test multiple times with different arguments. Instead of writing separate test functions for each input scenario, you define a single test that runs with various input parameters.
The benefits include:
- Less code duplication: Write the test logic once and reuse it
- Better test coverage: Easily test with many input variations
- Improved readability: Test cases are more structured and organized
- Easier maintenance: When test logic changes, you only need to update it in one place
Getting Started with JUnit 5 Parameterized Tests
JUnit 5 is one of the most popular testing frameworks for Kotlin and offers excellent support for parameterized tests.
Setting Up Dependencies
First, add the required dependencies to your build.gradle.kts
file:
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.2")
testImplementation("org.junit.jupiter:junit-jupiter-params:5.9.2")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.2")
}
Basic Parameterized Test
Let's start with a simple example — testing a function that checks if a number is even:
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
class NumberTests {
fun isEven(number: Int): Boolean = number % 2 == 0
@ParameterizedTest
@ValueSource(ints = [0, 2, 4, 100, 1000])
fun `should return true for even numbers`(number: Int) {
assertEquals(true, isEven(number))
}
@ParameterizedTest
@ValueSource(ints = [1, 3, 5, 99, 1001])
fun `should return false for odd numbers`(number: Int) {
assertEquals(false, isEven(number))
}
}
Here, the @ParameterizedTest
annotation marks a method as parameterized, and @ValueSource
provides the test values. Each value is passed one by one to the test method.
Parameter Sources in JUnit 5
JUnit 5 offers several ways to provide parameters:
1. ValueSource
Provides a simple array of literal values:
@ParameterizedTest
@ValueSource(strings = ["apple", "banana", "cherry"])
fun `test with string parameters`(fruit: String) {
assertTrue(fruit.length > 3)
}
2. CsvSource
Supplies multiple parameters per test invocation using CSV format:
@ParameterizedTest
@CsvSource(
"apple, 5",
"banana, 6",
"cherry, 6"
)
fun `test fruit name lengths`(fruit: String, length: Int) {
assertEquals(length, fruit.length)
}
3. MethodSource
References a method that provides the test arguments:
@ParameterizedTest
@MethodSource("fruitProvider")
fun `test fruits from method source`(fruit: String) {
assertTrue(fruit.length > 3)
}
companion object {
@JvmStatic
fun fruitProvider() = Stream.of("apple", "banana", "cherry")
}
4. ArgumentsSource
Uses a custom implementation of ArgumentsProvider
:
class FruitProvider : ArgumentsProvider {
override fun provideArguments(context: ExtensionContext): Stream<out Arguments> {
return Stream.of(
Arguments.of("apple"),
Arguments.of("banana"),
Arguments.of("cherry")
)
}
}
@ParameterizedTest
@ArgumentsSource(FruitProvider::class)
fun `test with custom argument provider`(fruit: String) {
assertTrue(fruit.isNotBlank())
}
Testing Complex Cases with ArgumentsSource
Let's look at a more complex example using ArgumentsSource
to test a user validation function:
data class User(val name: String, val age: Int, val email: String)
class UserValidator {
fun isValidUser(user: User): Boolean {
return user.name.isNotBlank() &&
user.age >= 18 &&
user.email.matches(Regex("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}\$"))
}
}
class UserValidationProvider : ArgumentsProvider {
override fun provideArguments(context: ExtensionContext): Stream<out Arguments> {
return Stream.of(
Arguments.of(User("Alice", 25, "[email protected]"), true),
Arguments.of(User("", 25, "[email protected]"), false),
Arguments.of(User("Alice", 15, "[email protected]"), false),
Arguments.of(User("Alice", 25, "not-an-email"), false)
)
}
}
class UserValidatorTest {
private val validator = UserValidator()
@ParameterizedTest
@ArgumentsSource(UserValidationProvider::class)
fun `should validate users correctly`(user: User, expected: Boolean) {
assertEquals(expected, validator.isValidUser(user))
}
}
Customizing Test Names
To make your test reports more readable, you can customize the display name of parameterized tests:
@ParameterizedTest(name = "{0} should have length {1}")
@CsvSource(
"apple, 5",
"banana, 6",
"cherry, 6"
)
fun `test fruit name lengths`(fruit: String, length: Int) {
assertEquals(length, fruit.length)
}
This will display tests like "apple should have length 5" in test reports.
Parameterized Tests with Kotest
Kotest is a flexible testing framework designed specifically for Kotlin and offers elegant support for parameterized tests:
import io.kotest.core.spec.style.FunSpec
import io.kotest.datatest.withData
import io.kotest.matchers.shouldBe
class StringTests : FunSpec({
context("string length") {
withData(
mapOf(
"apple" to 5,
"banana" to 6,
"cherry" to 6
)
) { (input, expected) ->
input.length shouldBe expected
}
}
})
This approach is particularly nice because it leverages Kotlin's native features for cleaner test code.
Real-World Example: Testing a Password Validator
Let's create a more practical example testing a password validation service:
class PasswordValidator {
fun isValid(password: String): Boolean {
return password.length >= 8 && // at least 8 chars
password.any { it.isUpperCase() } && // at least one uppercase
password.any { it.isDigit() } && // at least one digit
password.any { !it.isLetterOrDigit() } // at least one special char
}
}
class PasswordValidatorTest {
private val validator = PasswordValidator()
@ParameterizedTest
@CsvSource(
"Password1!, true",
"short1!, false",
"password1!, false",
"PASSWORD1!, true",
"Password1, false",
"!1aB45!@, true"
)
fun `should validate passwords correctly`(password: String, expected: Boolean) {
assertEquals(expected.toBoolean(), validator.isValid(password))
}
}
Dynamic Test Generation
For even more flexibility, JUnit 5 supports dynamic test generation:
@TestFactory
fun `generate tests dynamically`(): Collection<DynamicTest> {
val testCases = mapOf(
"apple" to 5,
"banana" to 6,
"cherry" to 6,
"date" to 4,
"elderberry" to 10
)
return testCases.map { (fruit, length) ->
DynamicTest.dynamicTest("Length of $fruit should be $length") {
assertEquals(length, fruit.length)
}
}
}
Best Practices for Parameterized Tests
- Keep test cases focused: Each parameterized test should verify one specific behavior
- Choose descriptive names: Use clear test names to understand what's being tested
- Select representative test data: Cover boundary cases, typical cases, and edge cases
- Use appropriate source annotations: Select the best parameter source for your needs
- Don't overuse: For very complex test scenarios, sometimes separate test methods are clearer
- Handle exceptions consistently: If some parameters might throw exceptions, handle them appropriately
Summary
Parameterized tests in Kotlin significantly improve your testing efficiency by allowing you to:
- Test multiple input scenarios with minimal code duplication
- Clearly express test cases with various parameter sources
- Improve test coverage with minimal effort
- Make tests more maintainable
Whether you're using JUnit 5 or Kotest, parameterized tests should be a key part of your Kotlin testing toolkit. They enable you to write more comprehensive tests with less code, leading to more robust software.
Additional Resources
Exercises
- Write a parameterized test for a function that checks if a string is a palindrome
- Create a test for a function that converts temperatures between Celsius and Fahrenheit using
@CsvSource
- Implement a custom
ArgumentsProvider
to test a calculator class with various operations - Use dynamic tests to verify a function that validates email addresses with different patterns
- Refactor an existing non-parameterized test to use the parameterized approach
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)