jqwik-team / jqwik

Property-Based Testing on the JUnit Platform
http://jqwik.net
Eclipse Public License 2.0
576 stars 64 forks source link

question: any way to imperatively summon a pre-configured arbitrary? #527

Open SimY4 opened 1 year ago

SimY4 commented 1 year ago

Quick question, maybe I'm missing something in the doc.

I can summon arbitraries in PBTs and @Provide annotated methods with configuration annotations.

Is there a handy way to summon already pre-configured arbitrary instances in an imperative way? I was hoping this will do the trick: Arbitraries.defaultFor(TypeUsage.of(MyClass.class).withAnnotation(Annotation)) but looks like it doesn't.

jlink commented 1 year ago

I’m not perfectly sure what you mean by „preconfigured“ but I assume you mean a type enriched by one or more annotation? If so, the answer is no. Without thinking deeply about it I’d say the use case is quite narrow, since there’s no way in Java I know of (without byte code manipulation anyway) to instantiate an annotation with some properties set. What remains is the case of a type with just plain annotations.

SimY4 commented 1 year ago

@jlink by pre-configured I mean it went through all applicable ArbitraryConfigurator instances available.

My usecase is essentially creating an arbitrary that can pass down the configuration info from itself. Imagine a product type.

A { b: B, c: C }

if B and C can be configured with annotation D I'd like an A to also be configured with D and essentially pass down annotation info to B and C.

// in arbitrary provider
@Override
public Set<Arbitrary<?>> provideFor(TypeUsage targetType, SubtypeProvider subtypeProvider) {
  var maybeD = targetType.findAnnotation(D.class)
  Combinators.combine(
    Arbitraries.defaultFor(B.class),  // pass D
    Arbitraries.defaultFor(C.class).  // pass D
  ).as(A::new)
jlink commented 1 year ago

Configurators are not handed down to an arbitrary‘s children. If they were, one couldn’t be precise with where to apply them since some could be applicable to both parent and children. What if you want it only for the parent?

Maybe you can show concrete use cases and why the current API is a bad fit. There may be a new feature hiding there.

SimY4 commented 1 year ago

@jlink somewhat abstracted use-case of mine. Let's assume I have my own real number type:

class Money(integer: long, fraction: long) extends Number

And I have configurator I call small, that comes with annotation @Small that's applicable for longs. Essentially just generates only small long values.

Now, I can say that my Money type can also be small if I can pass down the annotation from Money down to longs.

I don't want to write a configurator for Money. If I can just summon the right instances for longs based on annotations present on the type.

jlink commented 1 year ago

Now, I can say that my Money type can also be small if I can pass down the annotation from Money down to longs.

So the problem I described above holds:

Configurators are not handed down to an arbitrary‘s children. If they were, one couldn’t be precise with where to apply them since some could be applicable to both parent and children. What if you want it only for the parent?

I'm not sure how to mitigate that. One could introduce a meta annotation to specify if an annotation is handed down or not, but that introduces another bag of complications.

Have you tried using domains for your use case? If your domain-specific long generator was defined within the domain then it would be picked up automatically in a defaultFor call. Domains also give a clear boundary of when you want to apply what defaults.

SimY4 commented 12 months ago

@jlink

Configurators are not handed down to an arbitrary‘s children.

They are not but they are easily accessible on a parent instance and once instantiated annotation doesn't hold any connections to the parent type so can be used as an annotation on an inner type.

One way to solve my problem is by introducing a wrapper type:

record Small<T extends Number>(value: T) extends Number {}

Then, instead of creating configurations for small longs, i'd create an arbitrary for a Small<Long> and then arbitrary for a Small<Money> would summon two instances of Small<Long>s.

This works. But wrappers are not composing really well. If you imagine a scenario where I have three ways of restricting a type:

Arbtrary<Small<Odd<Positive<Long>>>>

you'd have to either use these restrictions in a specific order or provide arbitraries for exponentially growing combinations of these restrictors. Oppose to

Arbitrary<@Odd @Small @Positive Long>

where you just pipe them through the list of configurations.

Have you tried using domains for your use case?

tbh, I'm not sold on the idea of domains. because the use of domain immediately removes access for globally available arbitrary instances (you have to include them explicitly) it's easy to miss applicable instances and therefore skip valuable tests once you're in domains.

jlink commented 12 months ago

tbh, I'm not sold on the idea of domains. because the use of domain immediately removes access for globally available arbitrary instances (you have to include them explicitly) it's easy to miss applicable instances and therefore skip valuable tests once you're in domains

A domain class can itself have annotation
@Domain(DomainContext.Global.class) which will automatically import the „normal“ resolvers for users of your domain.

jlink commented 11 months ago

I still think domains are your best choice here, but you could also create an arbitrary provider yourself for types where handing down annotations is warranted:

class MyProviderForA implements ArbitraryProvider {
  @Override
  public boolean canProvideFor(TypeUsage targetType) {
    return targetType.isAssignableFrom(A.class);
  }

  private TypeUsage addAnnotations(List<Annotation> annotations, TypeUsage type) {
    if (annotations.isEmpty()) {
      return type;
    }
    Annotation annotation = annotations.get(0);
    return addAnnotations(annotations.subList(1, annotations.size()), type.withAnnotation(annotation));
  }

  @Override
  public Set<Arbitrary<?>> provideFor(TypeUsage targetType, SubtypeProvider subtypeProvider) {
    var annotations = targetType.getAnnotations()
    Combinators.combine(
      addAnnotations(annotations, Arbitraries.defaultFor(B.class)), 
      addAnnotations(annotations, Arbitraries.defaultFor(C.class))  
    ).as(A::new)
  }
}
SimY4 commented 11 months ago

@jlink this is what I do today. With a minor tweak that my addAnnotations is essentially just manually calling to configurer, I.e.:

  private final SmallConfigurer configurer = new SmallConfigurer()

  @Override
  public Set<Arbitrary<?>> provideFor(TypeUsage targetType, SubtypeProvider subtypeProvider) {
    var small = targetType.getAnnotation(Small.class)
    Combinators.combine(
      configurer.configureB(Arbitraries.defaultFor(B.class), small), 
      configurer.configureC(Arbitraries.defaultFor(C.class), small)  
    ).as(A::new)
  }

That's why I was wondering if you can just have an api to take this responsibility into jqwik. But that's cool if it's not part of jqwik, I just decided to ask.

jlink commented 11 months ago

I am open to considering an additional API just not to routinely handing down annotations.

Do you have a suggestion how this API could look and fit into the existing ones?

SimY4 commented 11 months ago

@jlink my original thought was (and TBH I still think it's the best of all options) to consider annotation instances added onto TypeUsage to look up configurations:

Arbitraries.defaultFor(TypeUsage.of(MyClass.class).withAnnotation(<annotation instance>))

(+) this API exists today and doesn't require new extensions (+) TypeUsage already supports attaching annotations to it (!) It's a behavioural breaking change so maybe better to have an alternative API if these types of changes are considered as breaking.

Some other options (or rather state of the art in other places that I saw) that I personally think worse than this one:

Arbitraries.defaultFor(new TypeToken<@Small Long>() {});

(+) captures annotations on types (-) pollutes userspace with anonymous classes (!) somewhat hard to use

  private final @Small long smallLong;

  @Override
  public Set<Arbitrary<?>> provideFor(TypeUsage targetType, SubtypeProvider subtypeProvider) {
    Arbitrary<Long> smallLong = Arbitraries.defaultFor(getClass().getField("smallLong"));
  }

(+) captures annotations on types (-) checked reflection exceptions everywhere (!) somewhat hard to use and easy to mess up

jlink commented 11 months ago

Arbitraries.defaultFor(TypeUsage.of(MyClass.class).withAnnotation(<annotation instance>))

This one already works, if you can grab an instance of an annotation from somewhere. It does not propagate it down, and it never will because this would make the application of annotations ambiguous (see further up in the discussion).

I could introduce TypeUsage.withAnnotationClass(Class<? extends Annotation> anClass) but that would not solve the propagation issue either and only allow for annotations without additional values.

Drawing from your Junit Quickcheck example, what about a new way to create TypeUsage instances like that:

private final @Small long smallLong;

@Override
public Set<Arbitrary<?>> provideFor(TypeUsage targetType, SubtypeProvider subtypeProvider) {
  Arbitrary<Long> smallLong = Arbitraries.defaultFor(
    TypeUsage.fromField(getClass().getField("smallLong"))
  );  
}

The logic for doing that is probably already present somewhere in the engine's code, so it'd be just a matter of cleaning it up and presenting it as a public interface.

SimY4 commented 11 months ago

@jlink

This one already works, if you can grab an instance of an annotation from somewhere.

hmmm, indeed... Though I think it messes with the cashes again in some nasty way, because:

this works ok:

configurer.configureB(Arbitraries.defaultFor(B.class), small)

this doesn't work:

Arbitraries.defaultFor(TypeUsage.of(B.class).withAnnotation(small))

In my case if fails with the configurator essentially filtering too many values (10000). Somehow the same doesn't occur if I summon first and then configure.

jlink commented 11 months ago

In my case if fails with the configurator essentially filtering too many values (10000). Somehow the same doesn't occur if I summon first and then configure.

Depending on how the small configurator works the order of application could play a role. E.g. first constraining the value range and then filtering might work, the other way around might lead to too many misses.

martyn0ff commented 10 months ago

Not sure if it will fit your use case, but I'll share how I'm doing it. It is however not annotation driven. What I'm doing is creating an interface like FooArbitraries. There, I define a handful of default methods annotated with @Provide and implement them suitable to my needs.

Then it becomes plug and play. For tests, a test class would then implement FooArbitraries (or however many others). Define a bunch of String fields in FooArbitraries interface mirroring names of provider methods so that values can be easily injected in test methods - all you have to do is use @ForAll(<arbitrary name>) and IntelliJ will even give you autocompletion. For combining arbitraries like you want, you'd have something like NumberBoundariesArbitraries with defined boundaries that emit number ranges that you want. Your MoneyArbitraries would then extend NumberBoundariesArbitraries and Money provider would re-use the Arbitrary constrained to the range you had defined earlier. Again, expose method names through String fields in MoneyArbitraries, make your test implement MoneyArbitraries and easily inject them in your tests. Somewhat messy and less fancy approach, but gets the job done.