#Android

Parameterized Tests: Run your JUnit tests with parameters

JUnit4 Parameterized tests offer a powerful way to run the same test suite with different sets of data. Instead of writing the same test multiple times for different inputs, you write your tests once and define the parameters to be used. This approach not only reduces code duplication but also makes your test suite more manageable and easier to reason about in the future. 

By leveraging Parameterized tests, you can cover more scenarios and edge cases, ensuring that your application is thoroughly tested. This leads to higher code coverage and fewer bugs in production. In the following sections, we will delve deeper into the specific problems that JUnit4 Parameterized tests solve compared to traditional unit tests and provide examples to illustrate their benefits.

What’s wrong with traditional unit tests?

Let’s look at a simple example from our codebase, we are going to write unit tests for a class called EmailValidationTextWatcher. It implements Android’s TextWatcher interface to validate email addresses while the user inputs them in a text box. It also includes a fun validate(value: String): Boolean method. We want to write unit tests to make sure that the validate(String): Boolean method works as expected in different cases. Here is a unit test for that class:

@Test
fun `given a valid emailAddress when calling textWatcher_validate then true is returned`() {
    val emailAddress = "test@example.com"
    val isValid = textWatcher.validate(emailAddress)
    Assert.assertTrue(isValid)
}

Now let’s test that behavior for an invalid email "plainaddress”. Our test suite looks like this:

@Test
fun `given a valid emailAddress when calling textWatcher_validate then true is returned`() {
    val emailAddress = "test@example.com"
    val isValid = textWatcher.validate(emailAddress)
    Assert.assertTrue(isValid)
}

@Test
fun `given a invalid emailAddress when calling textWatcher_validate then false is returned`() {
    val emailAddress = "plainaddress"
    val isValid = textWatcher.validate(emailAddress)
    Assert.assertFalse(isValid)
}

Let’s add a third scenario:

@Test
fun `given a valid emailAddress when calling textWatcher_validate then true is returned`() {
    val emailAddress = "test@example.com"
    val isValid = textWatcher.validate(emailAddress)
    Assert.assertTrue(isValid)
}

@Test
fun `given a invalid emailAddress when calling textWatcher_validate then false is returned`() {
    val emailAddress = "plainaddress"
    val isValid = textWatcher.validate(emailAddress)
    Assert.assertFalse(isValid)
}

@Test
fun `given a invalid emailAddress when calling textWatcher_validate then false is returned (1)`() {
    val emailAddress = "username@"
    val isValid = textWatcher.validate(emailAddress)
    Assert.assertFalse(isValid)
}

The problem with this approach is obvious. All of the three unit tests above are really the same unit test with a different input parameter. And to make matters worse, these are not even all the cases we want to test. In total we want to test against 12 valid emails and 16 invalid emails; that’s a test suite with 28 tests! There must be a better way.

Using Parameterized Tests

In the example above we had to write a unit test for each parameter we wanted to test against. With Parameterized tests we solve that problem, by defining our tests and a set of parameters they will receive. First, we setup our tests as usual, and add the @RunWith(Parameterized::class) annotation to our test class.

@RunWith(Parameterized::class)
class EmailValidationTextWatcherTest

The next thing we need is a list of parameters. Every test in our test suite will run once for each set of parameters. For that we need a static method annotated with @Parameterized.Parameters.

companion object {

    @JvmStatic
    @Parameterized.Parameters
    fun testParameters(): List<Array<Any>> {
        val validEmails: List<Array<Any>> = listOf(
            "test@example.com",
            "user@mail.example.com",
            "first.last@example.com",
            "user-name@example.com",
            "12345@example.com",
            "user@example-domain.com",
            "user_name@example.com",
            "user@123example.com",
            "user@x.com",
            "user@example..com",
            "user..name@example.com"
        )
            .map { arrayOf(it, true) }
        val invalidEmails: List<Array<Any>> = listOf(
            "plainaddress",
            "username@",
            "@example.com",
            "user@@example.com",
            "user name@example.com",
            "user@exam!ple.com",
            ".user@example.com",
            "user.@example.com",
            "user~name@example.com",
            "user@-example.com",
            "user@example.c",
            "user#name@example.com",
            "user@example.123",
            "user@example!com",
            ""
        )
            .map { arrayOf(it, false) }
        return validEmails + invalidEmails
    }
}

So what is going on here? The method returns a List of Arrays. This is because each Array returns the set of parameters for every run, while every item on the Array is 1 parameter. A typical usecase is to provide an input (email address in our case) and the expected value (true/false in our case for the validity of the email). 

The last step is to consume these values. We do this by implementing a constructor that accepts them as parameters.

@RunWith(Parameterized::class)
class EmailValidationTextWatcherTest(
    private val emailAddress: String,
    private val isValid: Boolean
) {

    // ... test cases here

}

The parameters are now available to use in our test cases. Here is how the EmailValidationTextWatcherTest class looks like with Parameterized tests:

@RunWith(Parameterized::class)
class EmailValidationTextWatcherTest(
    private val emailAddress: String,
    private val isValid: Boolean
) {

    private val view = mock<View>()
    private val textWatcher = EmailValidationTextWatcher(view)

    @Test
    fun checkEmailValidity() {
        val expected = isValid
        val actual = textWatcher.validate(emailAddress)
        Assert.assertEquals(expected, actual)
    }

    companion object {

        @JvmStatic
        @Parameterized.Parameters
        fun testParameters(): List<Array<Any>> {
            val validEmails: List<Array<Any>> = listOf(
                "test@example.com",
                "user@mail.example.com",
                "first.last@example.com",
                "user-name@example.com",
                "12345@example.com",
                "user@example-domain.com",
                "user_name@example.com",
                "user@123example.com",
                "user@x.com",
                "user@example..com",
                "user..name@example.com"
            )
                .map { arrayOf(it, true) }
            val invalidEmails: List<Array<Any>> = listOf(
                "plainaddress",
                "username@",
                "@example.com",
                "user@@example.com",
                "user name@example.com",
                "user@exam!ple.com",
                ".user@example.com",
                "user.@example.com",
                "user~name@example.com",
                "user@-example.com",
                "user@example.c",
                "user#name@example.com",
                "user@example.123",
                "user@example!com",
                ""
            )
                .map { arrayOf(it, false) }
            return validEmails + invalidEmails
        }
    }
}

The test suite only includes 1 test function and it runs once for every email address. Run these tests and you will see the following image:

test-results-untitle-short.png

1 tip to improve the readability of the test results

By default, Parameterized tests give a unique name to each test run and that name is in the format <test_function>[index] . This makes it hard to know what parameters were used for a failed test. It turns out we can use one of the parameters as the name for the test run. We will add a third parameter and let JUnit know that we want to use the 3rd parameter as the name for the test case. This is the test class with those modifications:

@RunWith(Parameterized::class)
class EmailValidationTextWatcherTest(
    private val emailAddress: String,
    private val isValid: Boolean,
    @Suppress("UNUSED_PARAMETER") testCaseMessage: String // the constructor has to receive all parameters
) {

    private val view = mock<View>()
    private val textWatcher = EmailValidationTextWatcher(view)

    @Test
    fun checkEmailValidity() {
        val expected = isValid
        val actual = textWatcher.validate(emailAddress)
        Assert.assertEquals(expected, actual)
    }

    companion object {

        @JvmStatic
        @Parameterized.Parameters(name = "{2}") // use the third parameter as the test name
        fun testParameters(): List<Array<Any>> {
            val validEmails: List<Array<Any>> = listOf(
                "test@example.com",
                "user@mail.example.com",
                "first.last@example.com",
                "user-name@example.com",
                "12345@example.com",
                "user@example-domain.com",
                "user_name@example.com",
                "user@123example.com",
                "user@x.com",
                "user@example..com",
                "user..name@example.com"
            )
                .map { arrayOf(it, true, "allow $it") } // test name for valid emails
            val invalidEmails: List<Array<Any>> = listOf(
                "plainaddress",
                "username@",
                "@example.com",
                "user@@example.com",
                "user name@example.com",
                "user@exam!ple.com",
                ".user@example.com",
                "user.@example.com",
                "user~name@example.com",
                "user@-example.com",
                "user@example.c",
                "user#name@example.com",
                "user@example.123",
                "user@example!com",
                ""
            )
                .map { arrayOf(it, false, "block $it") } // test name for invalid emails
            return validEmails + invalidEmails
        }
    }
}

After running our tests again we get more readable test results:

test-results-titled-short.png

That's it 🎉. We have succcessfully turned our unit tests to Parameterized tests!

Benefits of Parameterized tests

Improved readability

With this approach a reader of our code can easily glance at the scenarios we want to test, since there is only 1 function for each one. The tests run multiple times for different parameters but are defined only once!

Scalability

Defining a list of input parameters is much easier than rewriting the same test cases multiple times.

Higher code coverage

By defining all possible input parameters you can test your SUT under multiple scenarios. More test scenarios means greater code coverage.

Reduced code duplication

By using Parameterized tests we solved the problem where we had to define a test function for each email, massively reducing code duplication.

Parameterized tests + Robolectric

You can also run Robolectric tests in the same manner using the annotation @RunWith(ParameterizedRobolectricTestRunner::class) . You also need to change from the @Parameterized.Parameters annotation to @ParameterizedRobolectricTestRunner.Parameters and you're good to go!

robolectric-annotation.png

robolectric-test-parameters.png

Further reading

 

 

Loading...