Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support range filter in @EnumSource #4185

Open
2 tasks
bjmi opened this issue Dec 8, 2024 · 14 comments · May be fixed by #4221
Open
2 tasks

Support range filter in @EnumSource #4185

bjmi opened this issue Dec 8, 2024 · 14 comments · May be fixed by #4221

Comments

@bjmi
Copy link
Contributor

bjmi commented Dec 8, 2024

Sometimes it is necessary to test a consecutive range of enum constants in a unit test. The existing matchers of @EnumSource can't help here.
As Enum constants have a natural order, it would be useful to support a range in @EnumSource. If a bound is not specified, it is considered unbounded.

Following example would work then: all constants of ChronoUnit starting at WEEKS aren't supported by Instant.plus(..).

class InstantTests {

@EnumSource(from = "WEEKS")
@ParameterizedTest
void plus_unsupportedUnits(ChronoUnit unit) {
    Instant now = Instant.now();
    assertThrows(UnsupportedTemporalTypeException.class, () -> now.plus(1, unit));
}
...

Candidates are

  • @EnumSource(from = "..", to = ".."),
  • @EnumSource(begin = "..", end = "..") or
  • @EnumSource(start = "..", end = "..")
  • ...

The existing matchers could be applied to a specified range additionally.

Deliverables

  • @EnumSource with optional from and to attributes
  • Update documentation and release notes
@kaipe
Copy link

kaipe commented Dec 11, 2024

For this you can use EnumSet and LazyParams to cook your own parameterization solution ...

import java.util.EnumSet;
import static org.lazyparams.LazyParams.pickValue;

public class EnumParams {

    public static <E extends Enum<E>> E pickFromRange(
            String paramName, E from, E to) {

        if (null == from) {
            from = EnumSet.allOf(to.getDeclaringClass()).stream()
                    .findFirst().get();

        } else if (null == to) {
            to = EnumSet.allOf(from.getDeclaringClass()).stream()
                    .sorted(Comparator.reverseOrder())
                    .findFirst().get();
        }
        return (E) pickValue( paramName, EnumSet.range(from, to).toArray());
    }
}

The above method can then be used to achieve parameterization in midair during the execution of a regular JUnit test:

@Test
void plus_unsupportedUnits() {
    Instant now = Instant.now();
    ChronoUnit unit = EnumParams.pickFromRange("unit", ChronoUnit.WEEKS, null);
    assertThrows(UnsupportedTemporalTypeException.class, () -> now.plus(1, unit));
}

// With ConsoleLauncher the test result is presented like this:
//  JUnit Jupiter ✔
//  └─ InstantTests ✔
//     └─ plus_unsupportedUnits() ✔
//        ├─ plus_unsupportedUnits unit=Weeks ✔
//        ├─ plus_unsupportedUnits unit=Months ✔
//        ├─ plus_unsupportedUnits unit=Years ✔
//        ├─ plus_unsupportedUnits unit=Decades ✔
//        ├─ plus_unsupportedUnits unit=Centuries ✔
//        ├─ plus_unsupportedUnits unit=Millennia ✔
//        ├─ plus_unsupportedUnits unit=Eras ✔
//        └─ plus_unsupportedUnits unit=Forever ✔

@marcphilipp
Copy link
Member

marcphilipp commented Dec 12, 2024

@kaipe LazyParams looks interesting! Is there some documentation explaining how it works?

I wonder if this would be a good addition to Pioneer's range sources. @bjmi WDYT?

FWIW, this can be achieved using a @MethodSource like this:

@ParameterizedTest
@MethodSource
void plus_unsupportedUnits(ChronoUnit unit) {
	Instant now = Instant.now();
	assertThrows(UnsupportedTemporalTypeException.class, () -> now.plus(1, unit));
}

static Set<ChronoUnit> plus_unsupportedUnits() {
	return EnumSet.range(ChronoUnit.WEEKS, ChronoUnit.FOREVER);
}

@sbrannen
Copy link
Member

LazyParams looks interesting!

Indeed... a bit of magic. 🪄

Is there some documentation explaining how it works?

It's performing bytecode manipulation with a ByteBuddy agent.

I wonder if this would be a good addition to Pioneer's range sources.

I cannot speak for the Pioneer team, but my guess is that bytecode manipulation would be out of scope for a project like Pioneer.

@scordio
Copy link
Contributor

scordio commented Dec 12, 2024

I cannot speak for the Pioneer team, but my guess is that bytecode manipulation would be out of scope for a project like Pioneer.

Maybe not with bytecode manipulation but an @EnumRangeSource(from = "..", to = "..") might be a good fit with the other sources Pioneer offers, and that could be implemented with EnumSet::range, like in #4185 (comment).

@marcphilipp
Copy link
Member

Is there some documentation explaining how it works?

It's performing bytecode manipulation with a ByteBuddy agent.

Yeah, I took a glance and saw that as well. But I was wondering what it manipulated it into. Does it use @ParameterizedTest under the hood?

I cannot speak for the Pioneer team, but my guess is that bytecode manipulation would be out of scope for a project like Pioneer.

Maybe not with bytecode manipulation but an @EnumRangeSource(from = "..", to = "..") might be a good fit with the other sources Pioneer offers, and that could be implemented with EnumSet::range, like in #4185 (comment).

That's what I meant as well.

@bjmi
Copy link
Contributor Author

bjmi commented Dec 12, 2024

@kaipe Thank you for sharing the interesting solution using LazyParams approach.

@marcphilipp
There are certainly further programmatic solutions e.g. assumeTrue(unit.compareTo(WEEKS) > -1); in the beginning of the parameterized test method,
but I would prefer a convenient, declarative and built-in solution and I don't think a potential EnumRangeSource justifies the introduction of junit-pioneer in the codebase for now.

Another approach could just extend EnumSource.Mode with RANGE entry to avoid new attributes in @EnumSource.
Then it would look like (just brainstorming):

  • @EnumSource(names = "WEEKS..FOREVER", mode = RANGE).
  • @EnumSource(names = "[WEEKS,FOREVER]", mode = RANGE)
  • @EnumSource(names = "(DAYS,FOREVER]", mode = RANGE)
  • @EnumSource(names = "(DAYS,]", mode = RANGE)

@marcphilipp
Copy link
Member

Team decision: Introduce new from and to attributes on @EnumSource.

@marcphilipp
Copy link
Member

@bjmi Do you have time to work on a PR?

@sbrannen
Copy link
Member

FWIW, this can be achieved using a @MethodSource like this:

Following up on @marcphilipp's suggestion for an alternative solution in the interim, with recent versions of JUnit Jupiter this can be simplified even further with a @FieldSource.

@ParameterizedTest
@FieldSource
void plus_unsupportedUnits(ChronoUnit unit) {
	Instant now = Instant.now();
	assertThrows(UnsupportedTemporalTypeException.class, () -> now.plus(1, unit));
}

static Set<ChronoUnit> plus_unsupportedUnits = EnumSet.range(WEEKS, FOREVER);

@kaipe
Copy link

kaipe commented Dec 15, 2024

@kaipe LazyParams looks interesting! Is there some documentation explaining how it works?

Available documentation only concerns all-pairs testing and lazy parameterization features. I work on some slides to highlight how declarative parameterization features can be implemented with Jupiter extension model. An example is LazyParameterResolver, which is a Jupiter ParameterResolver extension with roughly 50 lines of code. It allows the developer to quickly define simple annotation-types for declarative parameterization, such as these ...

    @ExtendWith(LazyParameterResolver.class)
    @Retention(RetentionPolicy.RUNTIME)
    @interface IntParam { int[] value(); }

    @ExtendWith(LazyParameterResolver.class)
    @Retention(RetentionPolicy.RUNTIME)
    @interface StringParam { String[] value(); }

... which are defined within a demo test-class GlobalAndLocalLifecycleMethodParameters. The punch-line is how a Jupiter ParameterResolver extension can be applied on parameters for all kinds of lifecycle methods, i.e. not just @Test methods. GlobalAndLocalLifecycleMethodParameters also uses its annotations for @BeforeAll and @BeforeEach. A simplification of the demo test-class could look like this:

public class GlobalAndLocal {
    @BeforeAll
    static void setupGlobal(@StringParam({"AA","BB"}) String gbl) {/*...*/}

    @BeforeEach
    void setupLocal(@IntParam({1,2,3}) int local) {/*...*/}

    @Test void vanilla() {/*...*/}

    @Test void extraParameters(
            @StringParam({"foo","bar","buz"}) String extraStr,
            @IntParam     ({ 41,  42,  43})   int extraInt) {/*...*/}

//With ConsoleLauncher the test result is presented like this:
// JUnit Jupiter ✔
// └─ GlobalAndLocal ✔
//    ├─ GlobalAndLocal gbl=AA ✔
//    │  ├─ extraParameters(String, int) gbl=AA ✔
//    │  │  ├─ extraParameters(String, int) gbl=AA / local=1 extraStr=foo extraInt=41 ✔
//    │  │  ├─ extraParameters(String, int) gbl=AA / local=2 extraStr=bar extraInt=42 ✔
//    │  │  ├─ extraParameters(String, int) gbl=AA / local=3 extraStr=buz extraInt=43 ✔
//    │  │  ├─ extraParameters(String, int) gbl=AA / local=1 extraStr=bar extraInt=43 ✔
//    │  │  ├─ extraParameters(String, int) gbl=AA / local=3 extraStr=foo extraInt=42 ✔
//    │  │  ├─ extraParameters(String, int) gbl=AA / local=2 extraStr=buz extraInt=41 ✔
//    │  │  ├─ extraParameters(String, int) gbl=AA / local=1 extraStr=buz extraInt=42 ✔
//    │  │  ├─ extraParameters(String, int) gbl=AA / local=2 extraStr=foo extraInt=43 ✔
//    │  │  └─ extraParameters(String, int) gbl=AA / local=3 extraStr=bar extraInt=41 ✔
//    │  └─ vanilla gbl=AA ✔
//    │     ├─ vanilla gbl=AA / local=1 ✔
//    │     ├─ vanilla gbl=AA / local=2 ✔
//    │     └─ vanilla gbl=AA / local=3 ✔
//    └─ GlobalAndLocal gbl=BB ✔
//       ├─ extraParameters(String, int) gbl=BB ✔
//       │  ├─ extraParameters(String, int) gbl=BB / local=1 extraStr=foo extraInt=41 ✔
//       │  ├─ extraParameters(String, int) gbl=BB / local=2 extraStr=bar extraInt=42 ✔
//       │  ├─ extraParameters(String, int) gbl=BB / local=3 extraStr=buz extraInt=43 ✔
//       │  ├─ extraParameters(String, int) gbl=BB / local=1 extraStr=bar extraInt=43 ✔
//       │  ├─ extraParameters(String, int) gbl=BB / local=3 extraStr=foo extraInt=42 ✔
//       │  ├─ extraParameters(String, int) gbl=BB / local=2 extraStr=buz extraInt=41 ✔
//       │  ├─ extraParameters(String, int) gbl=BB / local=1 extraStr=buz extraInt=42 ✔
//       │  ├─ extraParameters(String, int) gbl=BB / local=2 extraStr=foo extraInt=43 ✔
//       │  └─ extraParameters(String, int) gbl=BB / local=3 extraStr=bar extraInt=41 ✔
//       └─ vanilla gbl=BB ✔
//          ├─ vanilla gbl=BB / local=1 ✔
//          ├─ vanilla gbl=BB / local=2 ✔
//          └─ vanilla gbl=BB / local=3 ✔
}

(By implementing a Jupiter ParameterResolver, I believe it's within reach to implement a @Range annotation that can be used on all of Jupiter's lifecycle methods.)

But the above is all about how-to-use LazyParams and I suspect this crowd would enjoy documentation on LazyParams' technical solution ...

Is there some documentation explaining how it works?

It's performing bytecode manipulation with a ByteBuddy agent.

Yeah, I took a glance and saw that as well. But I was wondering what it manipulated it into. Does it use @ParameterizedTest under the hood?

... which is not based on @ParameterizedTest or any other Jupiter functionality. Of course, design choices are made for optimal Jupiter compatibility but those AOP advices are in fact applied on the hierarchical test-engine functionality of junit-platform-engine. Therewith the lazy parameterization API should also be compatible with other (non-Jupiter) test engines that rely on the hierarchical engine functionality. I have successfully managed to apply some lazy parameterization on some tests run by Cucumber Test Engine, even though that compatibility is not prioritized.

If you think the comment field of a feature request is a suitable forum(?), then I could go into detail on how the additional junit-platform-engine functionality works.

@marcphilipp
Copy link
Member

marcphilipp commented Dec 16, 2024

@kaipe Thanks for that!

If you think the comment field of a feature request is a suitable forum(?), then I could go into detail on how the additional junit-platform-engine functionality works.

No, I don't think this the right place. Maybe a wiki page on your repo? But you don't need to write that just for me.

@yhkuo41
Copy link
Contributor

yhkuo41 commented Dec 17, 2024

Hello, I want to contribute to this, but I have a question regarding this use case:

@ParameterizedTest
@EnumSource(mode = EXCLUDE, names = { "HOURS", "DAYS" }, from = "HOURS", to = "YEARS")
void testWithEnumSourceExcludeRange(ChronoUnit unit) {
	// test
}

I noticed that EnumArgumentsProvider#provideArguments checks for duplicate enum constant names. Should I also warn users in this case?

@marcphilipp
Copy link
Member

You mean because HOURS is excluded despite being at the start of the from-to range so this would be equivalent to @EnumSource(from = "HALF_DAYS", to = "YEARS", mode = EXCLUDE, names = "DAYS")? If so, I think that's rather an edge case and we can do without such a validation.

@yhkuo41
Copy link
Contributor

yhkuo41 commented Dec 18, 2024

I see. I initially thought I needed to merge the sets of names and from-to attributes. In this case, it would be equivalent to using @EnumSource(mode = EXCLUDE, names = { "HOURS", "HALF_DAYS", "DAYS", "WEEKS", "MONTHS", "YEARS" }). However, based on your response, the from-to attributes only support INCLUDE mode. Is that right?

yhkuo41 added a commit to yhkuo41/junit5 that referenced this issue Dec 24, 2024
yhkuo41 added a commit to yhkuo41/junit5 that referenced this issue Dec 24, 2024
yhkuo41 added a commit to yhkuo41/junit5 that referenced this issue Dec 24, 2024
@yhkuo41 yhkuo41 linked a pull request Dec 24, 2024 that will close this issue
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants