Add parameterized module: JUnit 5 @ParameterizedTest argument sources, display names, and two real anti-pattern failures

This commit is contained in:
Claude
2026-10-04 11:54:49 +00:00
parent 8be1245951
commit 2b2fbe54a2
45 changed files with 1181 additions and 0 deletions
@@ -0,0 +1,20 @@
package com.ankurm.tutorials.junit.parameterized;
import java.util.Set;
public class AuthService {
private static final Set<Role> WRITE_ROLES = Set.of(Role.ADMIN, Role.SUPERUSER);
public boolean canWrite(Role role) {
return WRITE_ROLES.contains(role);
}
public boolean canRead(Role role) {
return true;
}
public boolean canDelete(Role role) {
return role == Role.ADMIN;
}
}
@@ -0,0 +1,12 @@
package com.ankurm.tutorials.junit.parameterized;
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int multiply(int a, int b) {
return a * b;
}
}
@@ -0,0 +1,12 @@
package com.ankurm.tutorials.junit.parameterized;
import java.util.Set;
public class Cms {
private static final Set<String> PUBLISHING_ROLES = Set.of("EDITOR", "ADMIN");
public boolean canPublish(int userId, String role) {
return PUBLISHING_ROLES.contains(role);
}
}
@@ -0,0 +1,20 @@
package com.ankurm.tutorials.junit.parameterized;
/** A deliberately mutable object, used to demonstrate the shared-mutable-argument anti-pattern. */
public class Config {
private int callCount = 0;
public void touch() {
callCount++;
}
public int getCallCount() {
return callCount;
}
@Override
public String toString() {
return "Config[callCount=" + callCount + "]";
}
}
@@ -0,0 +1,14 @@
package com.ankurm.tutorials.junit.parameterized;
import java.util.Set;
public class CurrencyService {
private static final Set<String> KNOWN_ISO_CODES = Set.of(
"USD", "EUR", "GBP", "JPY", "INR", "AUD", "CHF"
);
public boolean isValidCode(String code) {
return KNOWN_ISO_CODES.contains(code);
}
}
@@ -0,0 +1,22 @@
package com.ankurm.tutorials.junit.parameterized;
import java.util.Map;
public class Geocoder {
private static final Map<String, String> REGION_BY_POSTCODE = Map.of(
"SW1A", "London",
"M1", "Manchester",
"EH1", "Edinburgh",
"CF10", "Cardiff",
"BT1", "Belfast"
);
public String getRegion(String postcode) {
String region = REGION_BY_POSTCODE.get(postcode);
if (region == null) {
throw new IllegalArgumentException("unknown postcode: " + postcode);
}
return region;
}
}
@@ -0,0 +1,5 @@
package com.ankurm.tutorials.junit.parameterized;
public enum Role {
ADMIN, SUPERUSER, EDITOR, USER, GUEST
}
@@ -0,0 +1,16 @@
package com.ankurm.tutorials.junit.parameterized;
public class ScenarioProcessor {
private static final int MAX_ALLOWED = 999;
public boolean evaluate(String scenarioId, int value) {
if (value < 0 || value > MAX_ALLOWED) {
return false;
}
return switch (scenarioId) {
case "zero_value" -> false;
default -> true;
};
}
}
@@ -0,0 +1,36 @@
package com.ankurm.tutorials.junit.parameterized;
import java.time.LocalDate;
/**
* A subscription account. {@code renewalDueDate} is when the subscription is next due for
* renewal; an overdue renewal still counts as active for a short grace window, as long as the
* account itself hasn't been disabled.
*/
public class User {
private static final int RENEWAL_GRACE_PERIOD_DAYS = 3;
private final String name;
private final LocalDate renewalDueDate;
private final boolean enabled;
public User(String name, LocalDate renewalDueDate, boolean enabled) {
this.name = name;
this.renewalDueDate = renewalDueDate;
this.enabled = enabled;
}
public String getName() {
return name;
}
public boolean isActive() {
return enabled && renewalDueDate.isAfter(LocalDate.now().minusDays(RENEWAL_GRACE_PERIOD_DAYS));
}
@Override
public String toString() {
return name;
}
}
@@ -0,0 +1,16 @@
package com.ankurm.tutorials.junit.parameterized;
import java.util.HashMap;
import java.util.Map;
public class UserService {
private final Map<String, String> passwordsByUsername = new HashMap<>();
public void register(String username, String password) {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("username must not be null or blank");
}
passwordsByUsername.put(username, password);
}
}
@@ -0,0 +1,17 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ArgumentsSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ArgumentsSourceTest {
private final ScenarioProcessor processor = new ScenarioProcessor();
@ParameterizedTest(name = "scenario: {0}")
@ArgumentsSource(ScenarioArgumentsProvider.class)
void testWithExternalScenarios(String scenarioId, int value, boolean expected) {
assertEquals(expected, processor.evaluate(scenarioId, value));
}
}
@@ -0,0 +1,18 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.converter.ConvertWith;
import org.junit.jupiter.params.provider.ValueSource;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ConvertWithTest {
@ParameterizedTest(name = "date {0} should be in 2023")
@ValueSource(strings = {"2023-01-01", "2023-06-15", "2023-12-31"})
void testDatesAreIn2023(@ConvertWith(DashDateConverter.class) LocalDate date) {
assertEquals(2023, date.getYear());
}
}
@@ -0,0 +1,17 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvFileSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CsvFileSourceTest {
private final Geocoder geocoder = new Geocoder();
@ParameterizedTest(name = "postcode {0} should map to region {1}")
@CsvFileSource(resources = "/test-data/postcode-regions.csv", numLinesToSkip = 1)
void shouldMapPostcodeToRegion(String postcode, String expectedRegion) {
assertEquals(expectedRegion, geocoder.getRegion(postcode));
}
}
@@ -0,0 +1,51 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CsvSourceTest {
private final Calculator calculator = new Calculator();
private final Cms cms = new Cms();
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"10, 5, 15",
"0, 0, 0",
"-3, 7, 4",
"100, -50, 50"
})
void shouldAddNumbers(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
@ParameterizedTest(name = "user {0} with role {1}")
@CsvSource(useHeadersInDisplayName = true, textBlock = """
USER_ID, ROLE, CAN_PUBLISH
# Elevated users
1001, EDITOR, true
1002, ADMIN, true
# Standard users
1003, USER, false
""")
void testPublishingPermissions(int userId, String role, boolean canPublish) {
assertEquals(canPublish, cms.canPublish(userId, role));
}
// Same flag and headers as testPublishingPermissions above, reduced to one row and with no
// custom `name` -- shows what useHeadersInDisplayName actually changes on its own: the
// auto-generated default display name. Compare this invocation's name against
// testPublishingPermissions' above (same flag, custom name) and against
// HeaderNamePlaceholderMistakeTest (same flag, an attempt to reference headers from a
// custom name, which fails).
@ParameterizedTest
@CsvSource(useHeadersInDisplayName = true, textBlock = """
USER_ID, ROLE, CAN_PUBLISH
1001, EDITOR, true
""")
void testPublishingPermissionsDefaultDisplayName(int userId, String role, boolean canPublish) {
assertEquals(canPublish, cms.canPublish(userId, role));
}
}
@@ -0,0 +1,13 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.converter.SimpleArgumentConverter;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
class DashDateConverter extends SimpleArgumentConverter {
@Override
protected Object convert(Object source, Class<?> targetType) {
return LocalDate.parse((String) source, DateTimeFormatter.ISO_LOCAL_DATE);
}
}
@@ -0,0 +1,17 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DisplayNameTest {
private final Calculator calculator = new Calculator();
@ParameterizedTest(name = "[{index}] {0} × {1} = {2}")
@CsvSource({"3, 4, 12", "0, 5, 0", "-2, 3, -6"})
void shouldMultiply(int a, int b, int expected) {
assertEquals(expected, calculator.multiply(a, b));
}
}
@@ -0,0 +1,26 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class EnumSourceTest {
private final AuthService authService = new AuthService();
@ParameterizedTest(name = "{0} should have write access")
@EnumSource(value = Role.class, names = {"ADMIN", "SUPERUSER"})
void privilegedRolesShouldHaveWriteAccess(Role role) {
assertTrue(authService.canWrite(role));
}
@ParameterizedTest(name = "{0} must NOT have write access")
@EnumSource(value = Role.class,
names = {"ADMIN", "SUPERUSER"},
mode = EnumSource.Mode.EXCLUDE)
void nonPrivilegedRolesMustNotWrite(Role role) {
assertFalse(authService.canWrite(role));
}
}
@@ -0,0 +1,23 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.FieldSource;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FieldSourceTest {
static final List<String> VALID_ISO_CODES = List.of(
"USD", "EUR", "GBP", "JPY", "INR", "AUD", "CHF"
);
private final CurrencyService currencyService = new CurrencyService();
@ParameterizedTest(name = "ISO code {0} should be recognized")
@FieldSource("VALID_ISO_CODES")
void shouldRecognizeIsoCurrencyCode(String code) {
assertTrue(currencyService.isValidCode(code));
}
}
@@ -0,0 +1,27 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
/**
* This class is not meant to compile into a passing test -- it exists only to let
* docs/output/06-header-name-placeholder-throws.txt capture the real exception that a
* {@code name} template referencing a CSV header by name (instead of a positional index)
* produces. The method is {@code @Disabled} in the committed version; the failure was captured
* by temporarily removing that annotation and running this class alone. See the companion post
* for why {@code {USER_ID}} is not valid syntax here even with {@code useHeadersInDisplayName}.
*/
class HeaderNamePlaceholderMistakeTest {
@org.junit.jupiter.api.Disabled("Kept disabled so the module's build stays green -- "
+ "see docs/output/06-header-name-placeholder-throws.txt for the real failure "
+ "this produces when the @Disabled annotation is removed.")
@ParameterizedTest(name = "user {USER_ID} with role {ROLE}")
@CsvSource(useHeadersInDisplayName = true, textBlock = """
USER_ID, ROLE, CAN_PUBLISH
1001, EDITOR, true
""")
void nameTemplateCannotReferenceHeadersByName(int userId, String role, boolean canPublish) {
// Never reached -- JUnit rejects the display-name pattern before this method runs.
}
}
@@ -0,0 +1,27 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.time.LocalDate;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
class MethodSourceTest {
@ParameterizedTest(name = "user {0} active={1}")
@MethodSource("activeUserProvider")
void testUserActiveStatus(User user, boolean expectedActive) {
assertEquals(expectedActive, user.isActive());
}
static Stream<Arguments> activeUserProvider() {
return Stream.of(
Arguments.of(new User("alice", LocalDate.now().minusDays(1), true), true),
Arguments.of(new User("bob", LocalDate.now().plusDays(30), true), true),
Arguments.of(new User("carol", LocalDate.now().minusDays(1), false), false)
);
}
}
@@ -0,0 +1,34 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MixedAssertionsAntiPatternTest {
private final AuthService auth = new AuthService();
// BAD: different assertions encoded by role, in one parameterized test.
@ParameterizedTest
@CsvSource({"ADMIN, true, true", "GUEST, false, true"})
void testPermissions(String role, boolean canDelete, boolean canRead) {
assertEquals(canDelete, auth.canDelete(Role.valueOf(role)));
assertEquals(canRead, auth.canRead(Role.valueOf(role))); // different assertion per row
}
// BETTER: split into two focused parameterized tests, one behavior each.
@ParameterizedTest
@EnumSource(value = Role.class, names = "ADMIN")
void adminShouldDelete(Role role) {
assertTrue(auth.canDelete(role));
}
@ParameterizedTest
@EnumSource(Role.class)
void allRolesShouldRead(Role role) {
assertTrue(auth.canRead(role));
}
}
@@ -0,0 +1,20 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertThrows;
class NullAndEmptySourceTest {
private final UserService userService = new UserService();
@ParameterizedTest(name = "blank username [{0}] should be rejected")
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void shouldRejectBlankUsernames(String username) {
assertThrows(IllegalArgumentException.class,
() -> userService.register(username, "password123"));
}
}
@@ -0,0 +1,24 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.ArgumentsProvider;
import org.junit.jupiter.params.support.ParameterDeclarations;
import java.util.stream.Stream;
class ScenarioArgumentsProvider implements ArgumentsProvider {
// ArgumentsProvider has carried two provideArguments overloads since 5.13: this one, which
// also receives the test method's ParameterDeclarations, and a legacy single-argument form
// that still compiles but is now deprecated. Prefer this one in new code.
@Override
public Stream<? extends Arguments> provideArguments(ParameterDeclarations parameters, ExtensionContext ctx) {
// Could load from a test database, JSON fixture, environment, etc.
return Stream.of(
Arguments.of("happy_path", 100, true),
Arguments.of("zero_value", 0, false),
Arguments.of("max_boundary", 999, true)
);
}
}
@@ -0,0 +1,52 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Two parameterized tests, same shape, one real difference: whether the data source hands out
* one shared {@link Config} instance or a fresh one per invocation. Both tests assert the same
* thing -- "this invocation's Config has been touched exactly once" -- which is true in
* isolation but only reliable in the GOOD version.
*/
class SharedMutableArgumentAntiPatternTest {
@org.junit.jupiter.api.Disabled("Kept disabled so the module's build stays green -- see "
+ "docs/output/09-shared-mutable-argument-failure.txt for the real failure this "
+ "produces on its second invocation when the @Disabled annotation is removed.")
@ParameterizedTest
@MethodSource("sharedProvider")
void badSharedConfigAccumulatesTouches(Config config) {
config.touch();
assertEquals(1, config.getCallCount());
}
@ParameterizedTest
@MethodSource("freshProvider")
void goodFreshConfigIsAlwaysTouchedOnce(Config config) {
config.touch();
assertEquals(1, config.getCallCount());
}
// BAD: one Config instance is built once and handed to every invocation.
static Stream<Arguments> sharedProvider() {
Config shared = new Config();
return Stream.of(
Arguments.of(shared), // invocation 1 touches it -> callCount becomes 1
Arguments.of(shared) // invocation 2 touches the SAME instance -> callCount becomes 2
);
}
// GOOD: a fresh Config is built inside the Stream for every invocation.
static Stream<Arguments> freshProvider() {
return Stream.of(
Arguments.of(new Config()),
Arguments.of(new Config())
);
}
}
@@ -0,0 +1,15 @@
package com.ankurm.tutorials.junit.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ValueSourceTest {
@ParameterizedTest(name = "{0} should be a palindrome")
@ValueSource(strings = {"racecar", "radar", "level"})
void shouldIdentifyPalindromes(String candidate) {
assertTrue(candidate.equals(new StringBuilder(candidate).reverse().toString()));
}
}
@@ -0,0 +1,6 @@
postcode,region
SW1A,London
M1,Manchester
EH1,Edinburgh
CF10,Cardiff
BT1,Belfast
1 postcode region
2 SW1A London
3 M1 Manchester
4 EH1 Edinburgh
5 CF10 Cardiff
6 BT1 Belfast