jqwik-team / jqwik

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

Consider supporting @UseType for sealed interfaces #523

Open dlesl opened 1 year ago

dlesl commented 1 year ago

Testing Problem

Here is an example of testing a sealed interface which represents a data type.

class ExampleProperty {
  sealed interface JsonValue {
    record Text(String text) implements JsonValue {}
    record Number(double value) implements JsonValue {}
  }

  @Property
  @Domain(DomainContext.Global.class)
  @Domain(JsonDomain.class)
  void example(@ForAll @UseType JsonValue jsonValue) {
    System.out.println(jsonValue);
  }

  static class JsonDomain extends DomainContextBase {
    @Provide
    Arbitrary<JsonValue> jsonValue() {
      return Arbitraries.of(JsonValue.class.getPermittedSubclasses())
          .flatMap(c -> Arbitraries.forType(c).map(j -> (JsonValue) j));
    }
  }
}

Perhaps this strategy could be attempted by jqwik's @UseType by default?

Discussion

The current behaviour is that generation fails immediately for a sealed interface. Having this as the default behaviour could potentially be worse if it fails to discover/generate some of the subclasses and they get missed in testing?

jlink commented 1 year ago

Sounds like a very reasonable idea.

Currently I've no build setup that allows me to use Java 17 features. I wonder if an additional module for Java 17 would do.

jibidus commented 9 months ago

Same for kotlin module would be great.

lenaschoenburg commented 9 months ago

Just FYI, If the type hierarchy is deeper, recursive search for subclasses is needed:

  public static <T> Stream<Class<T>> implementationsOfSealedInterface(final Class<T> clazz) {
    if (!clazz.isSealed()) {
      throw new IllegalArgumentException(String.format("Class %s is not sealed", clazz.getName()));
    }
    return Stream.of(clazz.getPermittedSubclasses())
        .flatMap(
            c -> {
              if (c.isSealed()) {
                return implementationsOfSealedInterface((Class<T>) c);
              } else {
                return Stream.of((Class<T>) c);
              }
            });
  }
jlink commented 9 months ago

Since work for Jqwik2 will probably be started in the upcoming months, this feature might have to wait till then.

jibidus commented 9 months ago

Another solution with Kotlin where we use custom arbitrary for some subtypes:

Usage:

anyForSubtype<MyInterface> {
   use<MySubtype> { mySubTypeArbitrary() }
}

Implementation:

inline fun <reified T> anyForSubtype(
    customize: SubTypeDeclaration<T>.() -> Unit = {}
): Arbitrary<T> where T : Any {
    val subTypeDeclaration = SubTypeDeclaration<T>().apply(customize)
    return Arbitraries.of(T::class.sealedSubclasses).flatMap {
        subTypeDeclaration.arbitraryFor(it) ?: buildArbitrary<T>(it)
    }
}

inline fun <reified T> buildArbitrary(it: KClass<out T>) where T : Any =
    Arbitraries.forType(it.java as Class<T>).enableRecursion().map { obj -> obj as T }

class SubTypeDeclaration<T> {
    val arbitraryFactoriesByTargetClass = mutableMapOf<KClass<*>, ArbitraryFactory<*>>()

    inline fun <reified S> use(noinline factory: ArbitraryFactory<S>) where S : T {
        arbitraryFactoriesByTargetClass[S::class] = factory
    }

    fun <S : Any> arbitraryFor(target: KClass<S>): Arbitrary<T>? =
        arbitraryFactoriesByTargetClass[target]?.invoke() as Arbitrary<T>?
}

typealias ArbitraryFactory<T> = () -> Arbitrary<T>
jlink commented 9 months ago

Kotlin only

Another solution with Kotlin where we use custom arbitrary for some subtypes:

Usage:

anyForSubtype<MyInterface> {
   use<MySubtype> { mySubTypeArbitrary() }
}

This should be automatically handled by ˋanyForTypeˋ IMO. And since there’s already a Kotlin-specific module, it could be done with much less effort than for Java sealed types. Maybe you‘d like to make a PR for that?

jibidus commented 9 months ago

Yes, of course, I can try to submit a PR with this.

jibidus commented 9 months ago

PR #555 created @jlink.