diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-RC1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-RC1.adoc index 50e9e4b123cf..d52501f90ea3 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-RC1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-RC1.adoc @@ -45,7 +45,8 @@ repository on GitHub. [[release-notes-5.13.0-RC1-junit-jupiter-new-features-and-improvements]] ==== New Features and Improvements -* ❓ +* Add support for Kotlin `Sequence` to `@MethodSource`, `@FieldSource`, and + `@TestFactory`. [[release-notes-5.13.0-RC1-junit-vintage]] diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index e84ed85c59be..b9c86dbb8a89 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -1949,10 +1949,11 @@ of the annotated `@ParameterizedClass` or `@ParameterizedTest`. Generally speaki translates to a `Stream` of `Arguments` (i.e., `Stream`); however, the actual concrete return type can take on many forms. In this context, a "stream" is anything that JUnit can reliably convert into a `Stream`, such as `Stream`, `DoubleStream`, -`LongStream`, `IntStream`, `Collection`, `Iterator`, `Iterable`, an array of objects, or -an array of primitives. The "arguments" within the stream can be supplied as an instance -of `Arguments`, an array of objects (e.g., `Object[]`), or a single value if the -parameterized class or test method accepts a single argument. +`LongStream`, `IntStream`, `Collection`, `Iterator`, `Iterable`, an array of objects or +primitives, or any type that provides an `iterator(): Iterator` method (such as, for +example, a `kotlin.sequences.Sequence`). The "arguments" within the stream can be supplied +as an instance of `Arguments`, an array of objects (e.g., `Object[]`), or a single value +if the parameterized class or test method accepts a single argument. If the return type is `Stream` or one of the primitive streams, JUnit will properly close it by calling `BaseStream.close()`, @@ -2038,10 +2039,11 @@ In this context, a "stream" is anything that JUnit can reliably convert to a `St however, the actual concrete field type can take on many forms. Generally speaking this translates to a `Collection`, an `Iterable`, a `Supplier` of a stream (`Stream`, `DoubleStream`, `LongStream`, or `IntStream`), a `Supplier` of an `Iterator`, an array of -objects, or an array of primitives. Each set of "arguments" within the "stream" can be -supplied as an instance of `Arguments`, an array of objects (for example, `Object[]`, -`String[]`, etc.), or a single value if the parameterized class or test method accepts a -single argument. +objects or primitives, or any type that provides an `iterator(): Iterator` method (such +as, for example, a `kotlin.sequences.Sequence`). Each set of "arguments" within the +"stream" can be supplied as an instance of `Arguments`, an array of objects (for example, +`Object[]`, `String[]`, etc.), or a single value if the parameterized class ortest method accepts +a single argument. [WARNING] ==== @@ -2870,7 +2872,11 @@ generated at runtime by a factory method that is annotated with `@TestFactory`. In contrast to `@Test` methods, a `@TestFactory` method is not itself a test case but rather a factory for test cases. Thus, a dynamic test is the product of a factory. Technically speaking, a `@TestFactory` method must return a single `DynamicNode` or a -`Stream`, `Collection`, `Iterable`, `Iterator`, or array of `DynamicNode` instances. +_stream_ of `DynamicNode` instances or any of its subclasses. In this context, a "stream" +is anything that JUnit can reliably convert into a `Stream`, such as `Stream`, +`Collection`, `Iterator`, `Iterable`, an array of objects, or any type that provides an +`iterator(): Iterator` method (such as, for example, a `kotlin.sequences.Sequence`). + Instantiable subclasses of `DynamicNode` are `DynamicContainer` and `DynamicTest`. `DynamicContainer` instances are composed of a _display name_ and a list of dynamic child nodes, enabling the creation of arbitrarily nested hierarchies of dynamic nodes. diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/TestFactory.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/TestFactory.java index 42835ebd6888..b502f382c321 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/TestFactory.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/TestFactory.java @@ -30,7 +30,9 @@ * *

{@code @TestFactory} methods must not be {@code private} or {@code static} * and must return a {@code Stream}, {@code Collection}, {@code Iterable}, - * {@code Iterator}, or array of {@link DynamicNode} instances. Supported + * {@code Iterator}, array of {@link DynamicNode} instances, or any type that + * provides an {@link java.util.Iterator Iterator}-returning {@code iterator()} + * method (such as, for example, a {@code kotlin.sequences.Sequence}). Supported * subclasses of {@code DynamicNode} include {@link DynamicContainer} and * {@link DynamicTest}. Dynamic tests will be executed lazily, * enabling dynamic and even non-deterministic generation of test cases. diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestFactoryMethod.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestFactoryMethod.java index aaf90d6db727..fcc37c85bf2f 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestFactoryMethod.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestFactoryMethod.java @@ -40,7 +40,7 @@ public class IsTestFactoryMethod extends IsTestableMethod { private static final String EXPECTED_RETURN_TYPE_MESSAGE = String.format( - "must return a single %1$s or a Stream, Collection, Iterable, Iterator, or array of %1$s", + "must return a single %1$s or a Stream, Collection, Iterable, Iterator, Iterator provider, or array of %1$s", DynamicNode.class.getName()); public IsTestFactoryMethod(DiscoveryIssueReporter issueReporter) { diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/FieldSource.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/FieldSource.java index 4ca4717fd4d7..32666855bb9c 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/FieldSource.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/FieldSource.java @@ -44,11 +44,13 @@ * {@link java.util.stream.DoubleStream DoubleStream}, * {@link java.util.stream.LongStream LongStream}, or * {@link java.util.stream.IntStream IntStream}), a {@code Supplier} of an - * {@link java.util.Iterator Iterator}, an array of objects, or an array of - * primitives. Each set of "arguments" within the "stream" can be supplied as an - * instance of {@link Arguments}, an array of objects (for example, {@code Object[]}, - * {@code String[]}, etc.), or a single value if the parameterized - * class or test accepts a single argument. + * {@link java.util.Iterator Iterator}, an array of objects or primitives, or + * any type that provides an {@link java.util.Iterator Iterator}-returning + * {@code iterator()} method (such as, for example, a + * {@code kotlin.sequences.Sequence}). Each set of "arguments" within the + * "stream" can be supplied as an instance of {@link Arguments}, an array of + * objects (for example, {@code Object[]}, {@code String[]}, etc.), or a single + * value if the parameterized class or test accepts a single argument. * *

In contrast to the supported return types for {@link MethodSource @MethodSource} * factory methods, the value of a {@code @FieldSource} field cannot be an instance of diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/MethodSource.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/MethodSource.java index aec745a5188b..d6b4d3599fa3 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/MethodSource.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/MethodSource.java @@ -43,12 +43,13 @@ * {@link java.util.stream.LongStream LongStream}, * {@link java.util.stream.IntStream IntStream}, * {@link java.util.Collection Collection}, - * {@link java.util.Iterator Iterator}, - * {@link Iterable}, an array of objects, or an array of primitives. Each set of - * "arguments" within the "stream" can be supplied as an instance of - * {@link Arguments}, an array of objects (e.g., {@code Object[]}, - * {@code String[]}, etc.), or a single value if the parameterized test - * method accepts a single argument. + * {@link java.util.Iterator Iterator}, an array of objects or primitives, or + * any type that provides an {@link java.util.Iterator Iterator}-returning + * {@code iterator()} method (such as, for example, a + * {@code kotlin.sequences.Sequence}). Each set of "arguments" within the + * "stream" can be supplied as an instance of {@link Arguments}, an array of + * objects (e.g., {@code Object[]}, {@code String[]}, etc.), or a single + * value if the parameterized test method accepts a single argument. * *

If the return type is {@code Stream} or * one of the primitive streams, JUnit will properly close it by calling diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/CollectionUtils.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/CollectionUtils.java index e12d0421f286..6f250e532e76 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/CollectionUtils.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/CollectionUtils.java @@ -16,8 +16,10 @@ import static java.util.stream.Collectors.toList; import static java.util.stream.StreamSupport.stream; import static org.apiguardian.api.API.Status.INTERNAL; +import static org.junit.platform.commons.support.ReflectionSupport.invokeMethod; import java.lang.reflect.Array; +import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -36,6 +38,7 @@ import org.apiguardian.api.API; import org.junit.platform.commons.PreconditionViolationException; +import org.junit.platform.commons.support.ReflectionSupport; /** * Collection of utilities for working with {@link Collection Collections}. @@ -122,7 +125,7 @@ public static Set toSet(T[] values) { * returned, so if more control over the returned list is required, * consider creating a new {@code Collector} implementation like the * following: - * + *

*

 	 * public static <T> Collector<T, ?, List<T>> toUnmodifiableList(Supplier<List<T>> listSupplier) {
 	 *     return Collectors.collectingAndThen(Collectors.toCollection(listSupplier), Collections::unmodifiableList);
@@ -161,7 +164,8 @@ public static boolean isConvertibleToStream(Class type) {
 				|| Iterable.class.isAssignableFrom(type)//
 				|| Iterator.class.isAssignableFrom(type)//
 				|| Object[].class.isAssignableFrom(type)//
-				|| (type.isArray() && type.getComponentType().isPrimitive()));
+				|| (type.isArray() && type.getComponentType().isPrimitive())//
+				|| findIteratorMethod(type).isPresent());
 	}
 
 	/**
@@ -177,6 +181,9 @@ public static boolean isConvertibleToStream(Class type) {
 	 * 
  • {@link Iterator}
  • *
  • {@link Object} array
  • *
  • primitive array
  • + *
  • any type that provides an + * {@link java.util.Iterator Iterator}-returning {@code iterator()} method + * (such as, for example, a {@code kotlin.sequences.Sequence})
  • * * * @param object the object to convert into a stream; never {@code null} @@ -223,8 +230,21 @@ public static Stream toStream(Object object) { if (object.getClass().isArray() && object.getClass().getComponentType().isPrimitive()) { return IntStream.range(0, Array.getLength(object)).mapToObj(i -> Array.get(object, i)); } - throw new PreconditionViolationException( - "Cannot convert instance of " + object.getClass().getName() + " into a Stream: " + object); + return tryConvertToStreamByReflection(object); + } + + private static Stream tryConvertToStreamByReflection(Object object) { + return findIteratorMethod(object.getClass()) // + .map(method -> (Iterator) invokeMethod(method, object)) // + .map(iterator -> spliteratorUnknownSize(iterator, ORDERED)) // + .map(spliterator -> stream(spliterator, false)) // + .orElseThrow(() -> new PreconditionViolationException(String.format( + "Cannot convert instance of %s into a Stream: %s", object.getClass().getName(), object))); + } + + private static Optional findIteratorMethod(Class type) { + return ReflectionSupport.findMethod(type, "iterator") // + .filter(method -> method.getReturnType() == Iterator.class); } /** diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/predicates/IsTestFactoryMethodTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/predicates/IsTestFactoryMethodTests.java index f7b7534e38be..22c295d74864 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/predicates/IsTestFactoryMethodTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/predicates/IsTestFactoryMethodTests.java @@ -66,7 +66,8 @@ void invalidFactoryMethods(String methodName) { var issue = getOnlyElement(discoveryIssues); assertThat(issue.severity()).isEqualTo(DiscoveryIssue.Severity.WARNING); assertThat(issue.message()).isEqualTo( - "@TestFactory method '%s' must return a single org.junit.jupiter.api.DynamicNode or a Stream, Collection, Iterable, Iterator, or array of org.junit.jupiter.api.DynamicNode. " + "@TestFactory method '%s' must return a single org.junit.jupiter.api.DynamicNode or a " + + "Stream, Collection, Iterable, Iterator, Iterator provider, or array of org.junit.jupiter.api.DynamicNode. " + "It will not be executed.", method.toGenericString()); assertThat(issue.source()).contains(MethodSource.from(method)); @@ -83,7 +84,8 @@ void suspiciousFactoryMethods(String methodName) { assertThat(issue.severity()).isEqualTo(DiscoveryIssue.Severity.INFO); assertThat(issue.message()).isEqualTo( "The declared return type of @TestFactory method '%s' does not support static validation. " - + "It must return a single org.junit.jupiter.api.DynamicNode or a Stream, Collection, Iterable, Iterator, or array of org.junit.jupiter.api.DynamicNode.", + + "It must return a single org.junit.jupiter.api.DynamicNode or a " + + "Stream, Collection, Iterable, Iterator, Iterator provider, or array of org.junit.jupiter.api.DynamicNode.", method.toGenericString()); assertThat(issue.source()).contains(MethodSource.from(method)); } diff --git a/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/KotlinDynamicTests.kt b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/KotlinDynamicTests.kt new file mode 100644 index 000000000000..ba0dec561903 --- /dev/null +++ b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/KotlinDynamicTests.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ +package org.junit.jupiter.api + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DynamicTest.dynamicTest +import java.math.BigDecimal +import java.math.BigDecimal.ONE +import java.math.MathContext +import java.math.BigInteger as BigInt +import java.math.RoundingMode as Rounding + +/** + * Unit tests for JUnit Jupiter [TestFactory] use in kotlin classes. + * + * @since 5.12 + */ +class KotlinDynamicTests { + @Nested + inner class SequenceReturningTestFactoryTests { + @TestFactory + fun `Dynamic tests returned as Kotlin sequence`() = + generateSequence(0) { it + 2 } + .map { dynamicTest("$it should be even") { assertEquals(0, it % 2) } } + .take(10) + + @TestFactory + fun `Consecutive fibonacci nr ratios, should converge to golden ratio as n increases`(): Sequence { + val scale = 5 + val goldenRatio = + (ONE + 5.toBigDecimal().sqrt(MathContext(scale + 10, Rounding.HALF_UP))) + .divide(2.toBigDecimal(), scale, Rounding.HALF_UP) + + fun shouldApproximateGoldenRatio( + cur: BigDecimal, + next: BigDecimal + ) = next.divide(cur, scale, Rounding.HALF_UP).let { + dynamicTest("$cur / $next = $it should approximate the golden ratio in $scale decimals") { + assertEquals(goldenRatio, it) + } + } + return generateSequence(BigInt.ONE to BigInt.ONE) { (cur, next) -> next to cur + next } + .map { (cur) -> cur.toBigDecimal() } + .zipWithNext(::shouldApproximateGoldenRatio) + .drop(14) + .take(10) + } + } +} diff --git a/jupiter-tests/src/test/kotlin/org/junit/jupiter/params/ParameterizedTestKotlinSequenceIntegrationTests.kt b/jupiter-tests/src/test/kotlin/org/junit/jupiter/params/ParameterizedTestKotlinSequenceIntegrationTests.kt new file mode 100644 index 000000000000..800407845e11 --- /dev/null +++ b/jupiter-tests/src/test/kotlin/org/junit/jupiter/params/ParameterizedTestKotlinSequenceIntegrationTests.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ +package org.junit.jupiter.params + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.provider.Arguments.arguments +import org.junit.jupiter.params.provider.FieldSource +import org.junit.jupiter.params.provider.MethodSource +import java.time.Month + +/** + * Tests for Kotlin compatibility of ParameterizedTest + */ +object ParameterizedTestKotlinSequenceIntegrationTests { + @ParameterizedTest + @MethodSource("dataProvidedByKotlinSequenceMethod") + fun `a method source can be supplied by a Sequence-returning method`( + value: Int, + month: Month + ) { + assertEquals(value, month.value) + } + + @JvmStatic + private fun dataProvidedByKotlinSequenceMethod() = dataProvidedByKotlinSequenceField + + @JvmStatic + val dataProvidedByKotlinSequenceField = + sequenceOf( + arguments(1, Month.JANUARY), + arguments(3, Month.MARCH), + arguments(8, Month.AUGUST), + arguments(5, Month.MAY), + arguments(12, Month.DECEMBER) + ) + + @ParameterizedTest + @FieldSource("dataProvidedByKotlinSequenceField") + fun `a field source can be supplied by a Sequence-typed field`( + value: Int, + month: Month + ) { + assertEquals(value, month.value) + } +} diff --git a/platform-tests/src/test/java/org/junit/platform/commons/util/CollectionUtilsTests.java b/platform-tests/src/test/java/org/junit/platform/commons/util/CollectionUtilsTests.java index 9e6f01daccbb..749f62161073 100644 --- a/platform-tests/src/test/java/org/junit/platform/commons/util/CollectionUtilsTests.java +++ b/platform-tests/src/test/java/org/junit/platform/commons/util/CollectionUtilsTests.java @@ -25,6 +25,7 @@ import java.util.Iterator; import java.util.List; import java.util.Set; +import java.util.Spliterator; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.DoubleStream; import java.util.stream.IntStream; @@ -139,6 +140,7 @@ class StreamConversion { Collection.class, // Iterable.class, // Iterator.class, // + IteratorProvider.class, // Object[].class, // String[].class, // int[].class, // @@ -160,10 +162,11 @@ static Stream objectsConvertibleToStreams() { Stream.of("cat", "dog"), // DoubleStream.of(42.3), // IntStream.of(99), // - LongStream.of(100000000), // + LongStream.of(100_000_000), // Set.of(1, 2, 3), // Arguments.of((Object) new Object[] { 9, 8, 7 }), // - new int[] { 5, 10, 15 }// + new int[] { 5, 10, 15 }, // + new IteratorProvider(1, 2, 3, 4, 5)// ); } @@ -174,6 +177,8 @@ static Stream objectsConvertibleToStreams() { Object.class, // Integer.class, // String.class, // + UnusableIteratorProvider.class, // + Spliterator.class, // int.class, // boolean.class // }) @@ -242,16 +247,10 @@ void toStreamWithLongStream() { } @Test - @SuppressWarnings({ "unchecked", "serial" }) + @SuppressWarnings({ "unchecked" }) void toStreamWithCollection() { var collectionStreamClosed = new AtomicBoolean(false); - Collection input = new ArrayList<>() { - - { - add("foo"); - add("bar"); - } - + var input = new ArrayList<>(List.of("foo", "bar")) { @Override public Stream stream() { return super.stream().onClose(() -> collectionStreamClosed.set(true)); @@ -287,6 +286,25 @@ void toStreamWithIterator() { assertThat(result).containsExactly("foo", "bar"); } + @Test + @SuppressWarnings("unchecked") + void toStreamWithIteratorProvider() { + var input = new IteratorProvider("foo", "bar"); + + var result = (Stream) CollectionUtils.toStream(input); + + assertThat(result).containsExactly("foo", "bar"); + } + + @Test + void throwWhenIteratorNamedMethodDoesNotReturnAnIterator() { + var o = new UnusableIteratorProvider("Test"); + var e = assertThrows(PreconditionViolationException.class, () -> CollectionUtils.toStream(o)); + + assertEquals("Cannot convert instance of %s into a Stream: %s".formatted( + UnusableIteratorProvider.class.getName(), o), e.getMessage()); + } + @Test @SuppressWarnings("unchecked") void toStreamWithArray() { @@ -355,4 +373,26 @@ public Object convert(Object source, ParameterContext context) throws ArgumentCo } } } + + /** + * An interface that has a method with name 'iterator', returning a java.util/Iterator as a return type + */ + private record IteratorProvider(Object... elements) { + + @SuppressWarnings("unused") + Iterator iterator() { + return Arrays.stream(elements).iterator(); + } + } + + /** + * An interface that has a method with name 'iterator', but does not return java.util/Iterator as a return type + */ + private record UnusableIteratorProvider(Object... elements) { + + @SuppressWarnings("unused") + Object iterator() { + return Arrays.stream(elements).iterator(); + } + } }