Skip to content

Commit

Permalink
Introduce from and to attributes on @EnumSource (junit-team#4185)
Browse files Browse the repository at this point in the history
Resolves junit-team#4185.
  • Loading branch information
yhkuo41 committed Dec 24, 2024
1 parent efc375d commit 56557dc
Show file tree
Hide file tree
Showing 6 changed files with 175 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ JUnit repository on GitHub.
thread dump to `System.out` prior to interrupting a test thread due to a timeout.
* `TestReporter` now allows publishing files for a test method or test class which can be
used to include them in test reports, such as the Open Test Reporting format.
* New `from` and `to` attributes added to `@EnumSource` to support range selection of
enum constants.


[[release-notes-5.12.0-M1-junit-vintage]]
Expand Down
26 changes: 24 additions & 2 deletions documentation/src/docs/asciidoc/user-guide/writing-tests.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1560,14 +1560,28 @@ include::{testDir}/example/ParameterizedTestDemo.java[tags=EnumSource_example_au
----

The annotation provides an optional `names` attribute that lets you specify which
constants shall be used, like in the following example. If omitted, all constants will be
used.
constants shall be used, like in the following example.

[source,java,indent=0]
----
include::{testDir}/example/ParameterizedTestDemo.java[tags=EnumSource_include_example]
----

In addition to `names`, you can use the `from` and `to` attributes to specify a range of
constants. The range starts from the constant specified in the `from` attribute and
includes all subsequent constants up to and including the one specified in the `to`
attribute, based on the natural order of the enum constants.

If `from` and `to` attributes are omitted, they default to the first and last constants
in the enum type, respectively. If all `names`, `from`, and `to` attributes are omitted,
all constants will be used. The following example demonstrates how to specify a range of
constants.

[source,java,indent=0]
----
include::{testDir}/example/ParameterizedTestDemo.java[tags=EnumSource_range_example]
----

The `@EnumSource` annotation also provides an optional `mode` attribute that enables
fine-grained control over which constants are passed to the test method. For example, you
can exclude names from the enum constant pool or specify regular expressions as in the
Expand All @@ -1583,6 +1597,14 @@ include::{testDir}/example/ParameterizedTestDemo.java[tags=EnumSource_exclude_ex
include::{testDir}/example/ParameterizedTestDemo.java[tags=EnumSource_regex_example]
----

You can also combine `mode` with the `from`, `to` and `names` attributes to define a
range of constants while excluding specific values from that range as shown below.

[source,java,indent=0]
----
include::{testDir}/example/ParameterizedTestDemo.java[tags=EnumSource_range_exclude_example]
----

[[writing-tests-parameterized-tests-sources-MethodSource]]
===== @MethodSource

Expand Down
17 changes: 17 additions & 0 deletions documentation/src/test/java/example/ParameterizedTestDemo.java
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ void testWithEnumSourceInclude(ChronoUnit unit) {
}
// end::EnumSource_include_example[]

// tag::EnumSource_range_example[]
@ParameterizedTest
@EnumSource(from = "HOURS", to = "DAYS")
void testWithEnumSourceRange(ChronoUnit unit) {
assertTrue(EnumSet.of(ChronoUnit.HOURS, ChronoUnit.HALF_DAYS, ChronoUnit.DAYS).contains(unit));
}
// end::EnumSource_range_example[]

// tag::EnumSource_exclude_example[]
@ParameterizedTest
@EnumSource(mode = EXCLUDE, names = { "ERAS", "FOREVER" })
Expand All @@ -164,6 +172,15 @@ void testWithEnumSourceRegex(ChronoUnit unit) {
}
// end::EnumSource_regex_example[]

// tag::EnumSource_range_exclude_example[]
@ParameterizedTest
@EnumSource(mode = EXCLUDE, from = "HOURS", to = "DAYS", names = { "HALF_DAYS" })
void testWithEnumSourceRangeExclude(ChronoUnit unit) {
assertTrue(EnumSet.of(ChronoUnit.HOURS, ChronoUnit.DAYS).contains(unit));
assertFalse(EnumSet.of(ChronoUnit.HALF_DAYS).contains(unit));
}
// end::EnumSource_range_exclude_example[]

// tag::simple_MethodSource_example[]
@ParameterizedTest
@MethodSource("stringProvider")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ protected Stream<? extends Arguments> provideArguments(ExtensionContext context,

private <E extends Enum<E>> Set<? extends E> getEnumConstants(ExtensionContext context, EnumSource enumSource) {
Class<E> enumClass = determineEnumClass(context, enumSource);
return EnumSet.allOf(enumClass);
E[] constants = enumClass.getEnumConstants();
if (constants.length == 0) {
return EnumSet.noneOf(enumClass);
}
E from = enumSource.from().isEmpty() ? constants[0] : Enum.valueOf(enumClass, enumSource.from());
E to = enumSource.to().isEmpty() ? constants[constants.length - 1] : Enum.valueOf(enumClass, enumSource.to());
return EnumSet.range(from, to);
}

@SuppressWarnings({ "unchecked", "rawtypes" })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@
* attribute. Otherwise, the declared type of the first parameter of the
* {@code @ParameterizedTest} method is used.
*
* <p>The set of enum constants can be restricted via the {@link #names} and
* {@link #mode} attributes.
* <p>The set of enum constants can be restricted via the {@link #names},
* {@link #from}, {@link #to} and {@link #mode} attributes.
*
* @since 5.0
* @see org.junit.jupiter.params.provider.ArgumentsSource
Expand All @@ -63,6 +63,8 @@
* first parameter of the {@code @ParameterizedTest} method is used.
*
* @see #names
* @see #from
* @see #to
* @see #mode
*/
Class<? extends Enum<?>> value() default NullEnum.class;
Expand All @@ -71,16 +73,48 @@
* The names of enum constants to provide, or regular expressions to select
* the names of enum constants to provide.
*
* <p>If no names or regular expressions are specified, all enum constants
* declared in the specified {@linkplain #value enum type} will be provided.
* <p>If no names or regular expressions are specified, and neither {@link #from}
* nor {@link #to} are specified, all enum constants declared in the specified
* {@linkplain #value enum type} will be provided.
*
* <p>If {@link #from} or {@link #to} are specified, the elements in names must
* fall within the range defined by {@link #from} and {@link #to}.
*
* <p>The {@link #mode} determines how the names are interpreted.
*
* @see #value
* @see #from
* @see #to
* @see #mode
*/
String[] names() default {};

/**
* The starting enum constant of the range to be included.
*
* <p>Defaults to an empty string, where the range starts from the first enum
* constant of the specified {@linkplain #value enum type}.
*
* @see #value
* @see #names
* @see #to
* @see #mode
*/
String from() default "";

/**
* The ending enum constant of the range to be included.
*
* <p>Defaults to an empty string, where the range ends at the last enum
* constant of the specified {@linkplain #value enum type}.
*
* @see #value
* @see #names
* @see #from
* @see #mode
*/
String to() default "";

/**
* The enum constant selection mode.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.params.provider.EnumArgumentsProviderTests.EnumWithTwoConstants.BAR;
import static org.junit.jupiter.params.provider.EnumArgumentsProviderTests.EnumWithTwoConstants.FOO;
import static org.junit.jupiter.params.provider.EnumArgumentsProviderTests.EnumWithFourConstants.BAR;
import static org.junit.jupiter.params.provider.EnumArgumentsProviderTests.EnumWithFourConstants.BAZ;
import static org.junit.jupiter.params.provider.EnumArgumentsProviderTests.EnumWithFourConstants.FOO;
import static org.junit.jupiter.params.provider.EnumArgumentsProviderTests.EnumWithFourConstants.QUX;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

Expand All @@ -34,54 +36,56 @@ class EnumArgumentsProviderTests {

@Test
void providesAllEnumConstants() {
var arguments = provideArguments(EnumWithTwoConstants.class);
var arguments = provideArguments(EnumWithFourConstants.class);

assertThat(arguments).containsExactly(new Object[] { FOO }, new Object[] { BAR });
assertThat(arguments).containsExactly(new Object[] { FOO }, new Object[] { BAR }, new Object[] { BAZ },
new Object[] { QUX });
}

@Test
void provideSingleEnumConstant() {
var arguments = provideArguments(EnumWithTwoConstants.class, "FOO");
var arguments = provideArguments(EnumWithFourConstants.class, "FOO");

assertThat(arguments).containsExactly(new Object[] { FOO });
}

@Test
void provideAllEnumConstantsWithNamingAll() {
var arguments = provideArguments(EnumWithTwoConstants.class, "FOO", "BAR");
var arguments = provideArguments(EnumWithFourConstants.class, "FOO", "BAR");

assertThat(arguments).containsExactly(new Object[] { FOO }, new Object[] { BAR });
}

@Test
void duplicateConstantNameIsDetected() {
Exception exception = assertThrows(PreconditionViolationException.class,
() -> provideArguments(EnumWithTwoConstants.class, "FOO", "BAR", "FOO").findAny());
() -> provideArguments(EnumWithFourConstants.class, "FOO", "BAR", "FOO").findAny());
assertThat(exception).hasMessageContaining("Duplicate enum constant name(s) found");
}

@Test
void invalidConstantNameIsDetected() {
Exception exception = assertThrows(PreconditionViolationException.class,
() -> provideArguments(EnumWithTwoConstants.class, "FO0", "B4R").findAny());
() -> provideArguments(EnumWithFourConstants.class, "FO0", "B4R").findAny());
assertThat(exception).hasMessageContaining("Invalid enum constant name(s) in");
}

@Test
void invalidPatternIsDetected() {
Exception exception = assertThrows(PreconditionViolationException.class,
() -> provideArguments(EnumWithTwoConstants.class, Mode.MATCH_ALL, "(", ")").findAny());
() -> provideArguments(EnumWithFourConstants.class, Mode.MATCH_ALL, "(", ")").findAny());
assertThat(exception).hasMessageContaining("Pattern compilation failed");
}

@Test
void providesEnumConstantsBasedOnTestMethod() throws Exception {
when(extensionContext.getRequiredTestMethod()).thenReturn(
TestCase.class.getDeclaredMethod("methodWithCorrectParameter", EnumWithTwoConstants.class));
TestCase.class.getDeclaredMethod("methodWithCorrectParameter", EnumWithFourConstants.class));

var arguments = provideArguments(NullEnum.class);

assertThat(arguments).containsExactly(new Object[] { FOO }, new Object[] { BAR });
assertThat(arguments).containsExactly(new Object[] { FOO }, new Object[] { BAR }, new Object[] { BAZ },
new Object[] { QUX });
}

@Test
Expand All @@ -104,8 +108,64 @@ void methodsWithoutParametersAreDetected() throws Exception {
assertThat(exception).hasMessageStartingWith("Test method must declare at least one parameter");
}

@Test
void providesEnumConstantsStartingFromBar() {
var arguments = provideArguments(EnumWithFourConstants.class, "BAR", "", Mode.INCLUDE);

assertThat(arguments).containsExactly(new Object[] { BAR }, new Object[] { BAZ }, new Object[] { QUX });
}

@Test
void providesEnumConstantsEndingAtBaz() {
var arguments = provideArguments(EnumWithFourConstants.class, "", "BAZ", Mode.INCLUDE);

assertThat(arguments).containsExactly(new Object[] { FOO }, new Object[] { BAR }, new Object[] { BAZ });
}

@Test
void providesEnumConstantsFromBarToBaz() {
var arguments = provideArguments(EnumWithFourConstants.class, "BAR", "BAZ", Mode.INCLUDE);

assertThat(arguments).containsExactly(new Object[] { BAR }, new Object[] { BAZ });
}

@Test
void providesEnumConstantsFromFooToBazWhileExcludingBar() {
var arguments = provideArguments(EnumWithFourConstants.class, "FOO", "BAZ", Mode.EXCLUDE, "BAR");

assertThat(arguments).containsExactly(new Object[] { FOO }, new Object[] { BAZ });
}

@Test
void providesNoEnumConstant() {
var arguments = provideArguments(EnumWithNoConstant.class);

assertThat(arguments).isEmpty();
}

@Test
void invalidConstantNameIsDetectedInRange() {
Exception exception = assertThrows(PreconditionViolationException.class,
() -> provideArguments(EnumWithFourConstants.class, "FOO", "BAZ", Mode.EXCLUDE, "QUX").findAny());
assertThat(exception).hasMessageContaining("Invalid enum constant name(s) in");
}

@Test
void invalidStartingRangeIsDetected() {
Exception exception = assertThrows(IllegalArgumentException.class,
() -> provideArguments(EnumWithFourConstants.class, "B4R", "", Mode.INCLUDE).findAny());
assertThat(exception).hasMessageContaining("No enum constant");
}

@Test
void invalidEndingRangeIsDetected() {
Exception exception = assertThrows(IllegalArgumentException.class,
() -> provideArguments(EnumWithFourConstants.class, "", "B4R", Mode.INCLUDE).findAny());
assertThat(exception).hasMessageContaining("No enum constant");
}

static class TestCase {
void methodWithCorrectParameter(EnumWithTwoConstants parameter) {
void methodWithCorrectParameter(EnumWithFourConstants parameter) {
}

void methodWithIncorrectParameter(Object parameter) {
Expand All @@ -115,21 +175,32 @@ void methodWithoutParameters() {
}
}

enum EnumWithTwoConstants {
FOO, BAR
enum EnumWithFourConstants {
FOO, BAR, BAZ, QUX
}

enum EnumWithNoConstant {
}

private <E extends Enum<E>> Stream<Object[]> provideArguments(Class<E> enumClass, String... names) {
return provideArguments(enumClass, Mode.INCLUDE, names);
}

private <E extends Enum<E>> Stream<Object[]> provideArguments(Class<E> enumClass, Mode mode, String... names) {
return provideArguments(enumClass, "", "", mode, names);
}

private <E extends Enum<E>> Stream<Object[]> provideArguments(Class<E> enumClass, String from, String to, Mode mode,
String... names) {
var annotation = mock(EnumSource.class);
when(annotation.value()).thenAnswer(invocation -> enumClass);
when(annotation.from()).thenAnswer(invocation -> from);
when(annotation.to()).thenAnswer(invocation -> to);
when(annotation.mode()).thenAnswer(invocation -> mode);
when(annotation.names()).thenAnswer(invocation -> names);
when(annotation.toString()).thenReturn(String.format("@EnumSource(value=%s.class, mode=%s, names=%s)",
enumClass.getSimpleName(), mode, Arrays.toString(names)));
when(annotation.toString()).thenReturn(
String.format("@EnumSource(value=%s.class, from=%s, to=%s, mode=%s, names=%s)", enumClass.getSimpleName(),
from, to, mode, Arrays.toString(names)));

var provider = new EnumArgumentsProvider();
provider.accept(annotation);
Expand Down

0 comments on commit 56557dc

Please sign in to comment.