Parameterized Test in Java

Parameterized tests in Java provide a powerful way to run the same test multiple times with different sets of input data. In order to go on with this topic on the new advanced features of JUnit, make sure you have already covered the principles of traditional unit tests and the JUnit framework and familiarized yourself with lifecycle annotations used to control the execution of tests.

Getting started

In this topic, we will expand our Calculator class from the introductory JUnit topic by adding a new method that will calculate the maximum among two arguments:

public class Calculator {

    public int maxOf(int a, int b) {
        if (a >= b) {
            return a;
        } else {
            return b;
        }
    }
}

We will also write the necessary tests to be sure that this method works correctly. We need to test three cases: when the first argument is greater than the second one, when the first argument is less than the second one, and when both arguments are equal.

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTests {

    @Test
    void testMaxFirstArgGreaterThanSecondArg() {
        Calculator calculator = new Calculator();
        int result = calculator.maxOf(2, 1);
        int expected = 2;

        assertEquals(expected, result);
    }

    @Test
    void testMaxFirstArgLessThanSecondArg() {
        Calculator calculator = new Calculator();
        int result = calculator.maxOf(1, 2);
        int expected = 2;

        assertEquals(expected, result);
    }

    @Test
    void testMaxFirstArgEqualToSecondArg() {
        Calculator calculator = new Calculator();
        int result = calculator.maxOf(2, 2);
        int expected = 2;

        assertEquals(expected, result);
    }
}

Now, let's run these tests to be sure that our implementation of the max method successfully passes all the tests. Running the test using Gradle gives the following output:

CalculatorTests > testMaxFirstArgEqualToSecondArg() PASSED
CalculatorTests > testMaxFirstArgLessThanSecondArg() PASSED
CalculatorTests > testMaxFirstArgGreaterThanSecondArg() PASSED

However, if you look at these tests you will notice that they are nearly identical and the only difference is the values we use in their bodies. Do we have a way to write such tests in a cleaner manner? JUnit provides us such an option, which is called "parameterized tests".

First, let's add the following dependency to our project so that JUnit be able to work with parameterized tests.

Gradle:

dependencies {
    testImplementation "org.junit.jupiter:junit-jupiter-params:5.7.1"
}

Maven:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.7.1</version>
    <scope>test</scope>
</dependency>

If you use JUnit Jupiter aggregator artifact dependency 'org.junit.jupiter:junit-jupiter:5.7.1', it automatically pulls in dependencies on junit-jupiter-apijunit-jupiter-params, and junit-jupiter-engine.

@ParameterizedTest

@ParameterizedTest allows us to invoke a single test method multiple times, passing different arguments to it. Take a look at the following code snippet:

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.*;

class CalculatorTests {
    
    @ParameterizedTest
    @CsvSource({"2, 1, 2", "1, 2, 2", "1, 1, 1"})
    void testMax(int first, int second, int expected) {
        Calculator calculator = new Calculator();
        int result = calculator.maxOf(first, second);

        assertEquals(expected, result);
    }
}

In this example, we use the @ParameterizedTest annotation instead of @Test to specify that the corresponding test should be executed multiple times with different arguments. We also use the @CsvSource annotation to provide an array of such arguments. JUnit has plenty of annotations for different sources of arguments, such as @ValueSource@EnumSource@MethodSource@CsvSource@CsvFileSource, and @ArgumentsSource, some of which we are going to discuss below.

Note that the test method now has three parameters: int firstint second, and int expected, which are used in the body of the test method, and respective arguments are supplied by JUnit at runtime based on the specified argument source. Let's run this test:

CalculatorTests > [1] 2, 1, 2 PASSED
CalculatorTests > [2] 1, 2, 2 PASSED
CalculatorTests > [3] 1, 1, 1 PASSED

The default output consists of the current invocation index and the list of the arguments. We can specify a custom message format for a test using attributes and placeholders, for example:

@ParameterizedTest(name = "{index} => maxOf({0}, {1}) == {2}")

The execution of the same test using the custom message format gives the following output:

CalculatorTests > 1 => maxOf(2, 1) == 2 PASSED
CalculatorTests > 2 => maxOf(1, 2) == 2 PASSED
CalculatorTests > 3 => maxOf(1, 1) == 1 PASSED

With the help of custom display names, you can easily and conveniently provide pretty and informative test outputs.

Sources of arguments

JUnit provides a number of annotations to define a source of arguments. Such arguments may be a sequence of test method arguments of the same type. They accept a single test method argument or a sequence of arguments of the same or different types, which in turn accept multiple arguments. We are going to discuss @ValueSource@CsvSource, and @CsvFileSource in detail and have a glimpse of some other annotations. You can find the full information about them in the official guide.

@ValueSource

@ValueSource is an argument source that supplies an array of literal values for test methods with a single parameter. Such literal values may be of any of the following types: shortbyteintlongfloatdoublecharboolean, java.lang.String, and java.lang.Class.

Let us add another method to our Calculator class, which will accept a single int argument and return boolean:

public boolean isEven(int a) {
    return a % 2 == 0;
}

After that, we will use the following test method that will be invoked multiple times with different integer arguments supplied by @ValueSource:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.*;

class CalculatorTests {
    
    @ParameterizedTest
    @ValueSource(ints = { 0, 2, 4, 1000 })
    void testIsEven(int arg) {
        assertTrue(new Calculator().isEven(arg));
    }
}

For non-primitive types, you can use @EmptySource@NullSource or @NullAndEmptySource annotations to pass null and empty values. In order to avoid writing too much code, we will be using more abstract examples to illustrate how to pass different types of arguments to test methods. In the following code snippets, an empty arg array is passed to the testMethod(int[] arg) method, and the testNullAndEmpty(List<String> arg) method is invoked twice: the first time arg is null, and the second time arg is empty.

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EmptySource;
import org.junit.jupiter.params.provider.NullAndEmptySource;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class CalculatorTests {

    @ParameterizedTest
    @EmptySource
    void testEmpty(int[] arg) {
        assertEquals(0, arg.length);
    }

    @ParameterizedTest
    @NullAndEmptySource
    void testNullAndEmpty(List<String> arg) {
        assertTrue(arg == null || arg.isEmpty());
    }
}

You can even combine these annotations with @ValueSource values to check the whole range of test cases in a single test method.

@MethodSource

This annotation allows you to use a method of your test class or an external class as a source of arguments. Each method annotated with @MethodSource must satisfy the following requirements: it must be static, it must not accept any arguments, and must return a stream, an array, or a collection of arguments.

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class CalculatorTests {

    @ParameterizedTest
    @MethodSource("stringFactory")
    void testStrings(String str) {
        assertFalse(str.isEmpty());
    }

    static List<String> stringFactory() {
        return List.of("apple", "banana", "lemon", "orange");
    }
}

You may use non-static methods as long as they are internal methods of the test class annotated with @TestInstance(Lifecycle.PER_CLASS), but if you use methods of external classes as a source of arguments, they must always be declared as static.

If a parameterized test method has multiple parameters, your argument source method needs to return a collection, a stream, or an array of Arguments or an array of Object. In this case, Arguments can be generated by the arguments static method:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.params.provider.Arguments.arguments;

class CalculatorTests {

    @ParameterizedTest
    @MethodSource("argFactory")
    void testStringLength(String str, int length) {
        assertEquals(length, str.length());
    }

    static List<Arguments> argFactory() {
        return List.of(arguments("apple", 5), arguments("watermelon", 10));
    }
}

See the official JUnit documentation for more detailed information on @MethodSource.

Working with CSV

In the first example of a parameterized test, we used the annotation @CsvSource. It allows you to supply a list of arguments as comma-separated values (CSV format), for example:

@CsvSource({ "apple, 5", "strawberry, 10", "cherry, 6" })

In this case, each value is represented by a String literal containing a list of arguments separated by a comma which serves as the default delimiter. @CsvSource also has a number of attributes to define the format of the arguments. You can change the default delimiter to another character or even a String literal, as well as define the representation of empty and null values. However, all these attributes are optional and can be used when needed.

Due to its flexibility, @CsvSource is well suited for supplying arguments for methods with multiple input parameters of different types.

In addition to @CsvSource, JUnit has the @CsvFileSource annotation which is used to load a CSV file from the class path or the local file system. Each line from a CSV file serves as a source of arguments for one invocation of the parameterized test. You may skip the desired number of lines in the file by setting the numLinesToSkip attribute. Also, if you want any lines in the CSV file to be ignored, you can use the symbol # at the beginning of the respective lines to comment them out.

Here is an example of a CSV file:

String, Length
apple, 5
strawberry, 10
# commented line
cherry, 6

And an example of the @CsvFileSourceannotation:

@CsvFileSource(resources = "/dataset.csv", numLinesToSkip = 1)

This way you can use big sets of input data for your tests. If you are interested in detailed instructions on how to work with CSV argument sources, check the corresponding sections of the official JUnit5 guide.

Conclusion

In this topic, you learned about the concept of parameterized tests and familiarized yourself with the tools JUnit provides for this purpose. Parameterized tests are a convenient tool for writing effective and concise tests. Instead of multiple test methods, you can have one method denoted by @ParameterizedTests that takes parameters so that you can supply different arguments to it. This allows you to reuse your code efficiently and improve the readability of your tests.

In addition to this, JUnit has many options to set up argument sources for your tests, including @ValueSource@MethodSource@CsvSource and a number of other annotations. By combining different sources, you can use sets of input data of any size to cover as many test cases as possible. This allows you to test units of code with very complex logic and extremely large numbers of execution paths that otherwise could not be reliably tested.

Create a free account to access the full topic

“It has all the necessary theory, lots of practice, and projects of different levels. I haven't skipped any of the 3000+ coding exercises.”
Andrei Maftei
Hyperskill Graduate