Parameterized Tests
Run one test body against many inputs with @ParameterizedTest and its sources, so edge cases are cheap to add and easy to read.
@ParameterizedTest(name = "{0} -> {1}")
@CsvSource({"input, expected", "other, result"})
void test(String input, String expected) { ... }Validation rules, price bands and status mappings are tested with many inputs and the same steps. Copying a test ten times buries the one value that differs. A parameterized test writes the steps once and lists the cases as data; each case is reported and can fail on its own.
The parts
Replace @Test with @ParameterizedTest and add a source. The annotations live in org.junit.jupiter.params, which comes with the junit-jupiter artifact.
@ValueSource(strings = {...}),intsand so on: one argument per case.@CsvSource: several arguments per case, one line each. ItstextBlockattribute reads well for tables.@MethodSource("name"): astaticmethod returning aStream<Arguments>, for objects that cannot be written as text.@EnumSource: every constant of an enum, optionally filtered withnamesandmode.@NullSource,@EmptySource,@NullAndEmptySource: addnulland empty values to another source.
JUnit converts CSV text to the parameter types for you: numbers, booleans, enums by name, LocalDate in ISO format, UUID and more. For BigDecimal, writing the amount as text is exact, which is what you want for money.
Readable names
The name attribute controls how each case appears in the report. Placeholders {0}, {1} stand for arguments and {index} for the case number. A good name makes a red line in CI understandable without opening the code.
Mistakes people make
- In
@CsvSource, an empty value isnull, while''is an empty string. Testing blank input needs the quotes. - A value containing a comma must be quoted with single quotes, or it splits into two arguments.
- Forgetting that a
@MethodSourcefactory must bestatic(unless the class uses per-class lifecycle). - Putting
ifstatements in the test body to handle different kinds of case. When cases need different assertions, write separate tests, for example one for accepted and one for rejected input. - Computing the expected value with the same formula the code uses. Write expected results as literal values worked out by hand.
Example
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class DiscountPolicyTest {
private final DiscountPolicy policy = new DiscountPolicy();
(name = "basket {0} gets {1}% off")
(textBlock = """
0.00, 0
99.99, 0
100.00, 5
499.99, 5
500.00, 10
""")
void discountDependsOnTheBasketTotal(BigDecimal total, int expectedPercent) {
assertThat(policy.percentOff(total)).isEqualTo(expectedPercent);
}
}
// The class under test
class DiscountPolicy {
int percentOff(BigDecimal basketTotal) {
if (basketTotal.compareTo(new BigDecimal("500.00")) >= 0) {
return 10;
}
if (basketTotal.compareTo(new BigDecimal("100.00")) >= 0) {
return 5;
}
return 0;
}
}
When to use it
- Check every boundary of a tiered shipping price table from one readable CSV block.
- Run the same input validation test against null, empty, blank and malformed values sent in a JSON body.
- Make sure each value of an order status enum maps to a defined response when a new status is added.
More examples
Blank and invalid input in one place
@NullAndEmptySource adds null and the empty string to the listed values, which covers the inputs a JSON body sends when a field is missing or empty. Accepted and rejected cases are separate tests, so neither body needs an if.
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
class EmailValidatorTest {
private final EmailValidator validator = new EmailValidator();
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "ada", "ada@", "@example.test", "ada@@example.test"})
void rejects(String email) {
assertThat(validator.isValid(email)).isFalse();
}
@ParameterizedTest
@ValueSource(strings = {"[email protected]", "[email protected]"})
void accepts(String email) {
assertThat(validator.isValid(email)).isTrue();
}
}
// The class under test
class EmailValidator {
boolean isValid(String email) {
if (email == null || email.isBlank()) {
return false;
}
int at = email.indexOf('@');
return at > 0 && at == email.lastIndexOf('@') && at < email.length() - 1;
}
}
Objects as arguments with @MethodSource
A factory method can build lists, records or DTOs that would be awkward as CSV text. The cases cover a full page, a partial last page, a page past the end and an empty result, which is where pagination code usually breaks.
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.params.provider.Arguments.arguments;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
class PaginationTest {
static Stream<Arguments> pages() {
List<String> ids = List.of("a", "b", "c", "d", "e");
return Stream.of(
arguments(ids, 0, 2, List.of("a", "b")),
arguments(ids, 2, 2, List.of("e")),
arguments(ids, 3, 2, List.of()),
arguments(List.of(), 0, 2, List.of()));
}
@ParameterizedTest(name = "[{index}] page {1}, size {2} -> {3}")
@MethodSource("pages")
void returnsTheRequestedSlice(List<String> all, int page, int size, List<String> expected) {
assertThat(Paginator.slice(all, page, size)).isEqualTo(expected);
}
}
// The class under test
class Paginator {
static <T> List<T> slice(List<T> all, int page, int size) {
int from = Math.min(page * size, all.size());
int to = Math.min(from + size, all.size());
return all.subList(from, to);
}
}
Every enum constant except one
When someone adds a new constant to OrderStatus, the first test runs it automatically and fails if the mapper does not handle it. Listing names explicitly in the second test keeps the rule visible in the test itself.
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
class RefundRulesTest {
@ParameterizedTest
@EnumSource(value = OrderStatus.class, names = {"CANCELLED"}, mode = EnumSource.Mode.EXCLUDE)
void everyActiveStatusMapsToAnHttpStatus(OrderStatus status) {
assertThat(StatusMapper.httpStatusFor(status)).isBetween(200, 299);
}
@ParameterizedTest
@EnumSource(value = OrderStatus.class, names = {"SHIPPED", "DELIVERED"})
void shippedOrdersCannotBeCancelled(OrderStatus status) {
assertThat(RefundRules.canCancel(status)).isFalse();
}
}
// The code under test
enum OrderStatus { PENDING, PAID, SHIPPED, DELIVERED, CANCELLED }
class StatusMapper {
static int httpStatusFor(OrderStatus status) {
return switch (status) {
case PENDING -> 202;
case PAID, SHIPPED, DELIVERED -> 200;
case CANCELLED -> 410;
};
}
}
class RefundRules {
static boolean canCancel(OrderStatus status) {
return status == OrderStatus.PENDING || status == OrderStatus.PAID;
}
}
Discussion