From 8a9d199452486e041e3f7b9b640161998a0c07e7 Mon Sep 17 00:00:00 2001 From: Hans Zuidervaart Date: Sun, 2 Jul 2023 13:31:05 +0200 Subject: [PATCH] Extension of to stream converter method. Examples of benefits: - Kotlin Sequence support for @TestFactory - Kotlin Sequence support for @MethodSource - Classes that expose an Iterator returning method, can be converted to a stream. Issue: #3376 I hereby agree to the terms of the JUnit Contributor License Agreement. --- .../asciidoc/user-guide/writing-tests.adoc | 2 +- .../descriptor/TestFactoryTestDescriptor.java | 2 +- .../commons/util/CollectionUtils.java | 40 +++++++++++-- .../junit/jupiter/api/KotlinDynamicTests.kt | 56 +++++++++++++++++++ .../aggregator/KotlinParameterizedTests.kt | 40 +++++++++++++ .../commons/util/CollectionUtilsTests.java | 55 +++++++++++++++++- 6 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 jupiter-tests/src/test/kotlin/org/junit/jupiter/api/KotlinDynamicTests.kt create mode 100644 jupiter-tests/src/test/kotlin/org/junit/jupiter/params/aggregator/KotlinParameterizedTests.kt diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 2dc985d9a872..94f06a1bb61a 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -2416,7 +2416,7 @@ 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`, `Collection`, `Iterable`, `Iterator`, an `Iterator` providing class or array of `DynamicNode` instances. 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-engine/src/main/java/org/junit/jupiter/engine/descriptor/TestFactoryTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/TestFactoryTestDescriptor.java index 1a86ba2e8d9a..547005f7c494 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/TestFactoryTestDescriptor.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/TestFactoryTestDescriptor.java @@ -131,7 +131,7 @@ private Stream toDynamicNodeStream(Object testFactoryMethodResult) private JUnitException invalidReturnTypeException(Throwable cause) { String message = String.format( - "@TestFactory method [%s] must return a single %2$s or a Stream, Collection, Iterable, Iterator, or array of %2$s.", + "@TestFactory method [%s] must return a single %2$s or a Stream, Collection, Iterable, Iterator, Iterator-source or array of %2$s.", getTestMethod().toGenericString(), DynamicNode.class.getName()); return new JUnitException(message, cause); } 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 20061af9fd2e..3687358b1c93 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 @@ -18,6 +18,7 @@ import static org.apiguardian.api.API.Status.INTERNAL; import java.lang.reflect.Array; +import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -27,6 +28,7 @@ import java.util.ListIterator; import java.util.Optional; import java.util.Set; +import java.util.Spliterator; import java.util.function.Consumer; import java.util.stream.Collector; import java.util.stream.DoubleStream; @@ -35,7 +37,9 @@ import java.util.stream.Stream; import org.apiguardian.api.API; +import org.junit.platform.commons.JUnitException; import org.junit.platform.commons.PreconditionViolationException; +import org.junit.platform.commons.function.Try; /** * Collection of utilities for working with {@link Collection Collections}. @@ -122,7 +126,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);
@@ -162,7 +166,11 @@ 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())//
+				|| Arrays.stream(type.getMethods())//
+						.filter(m -> m.getName().equals("iterator"))//
+						.map(Method::getReturnType)//
+						.anyMatch(returnType -> returnType == Iterator.class));
 	}
 
 	/**
@@ -178,6 +186,7 @@ public static boolean isConvertibleToStream(Class type) {
 	 * 
  • {@link Iterator}
  • *
  • {@link Object} array
  • *
  • primitive array
  • + *
  • An object that contains a method with name `iterator` returning an Iterator object
  • * * * @param object the object to convert into a stream; never {@code null} @@ -224,8 +233,31 @@ 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) { + Preconditions.notNull(object, "Object must not be null"); + try { + String name = "iterator"; + Method method = object.getClass().getMethod(name); + if (method.getReturnType() == Iterator.class) { + return stream(() -> tryIteratorToSpliterator(object, method), ORDERED, false); + } + else { + throw new PreconditionViolationException( + "Method with name 'iterator' does not return " + Iterator.class.getName()); + } + } + catch (NoSuchMethodException | IllegalStateException e) { + throw new PreconditionViolationException(// + "Cannot convert instance of " + object.getClass().getName() + " into a Stream: " + object, e); + } + } + + private static Spliterator tryIteratorToSpliterator(Object object, Method method) { + return Try.call(() -> spliteratorUnknownSize((Iterator) method.invoke(object), ORDERED))// + .getOrThrow(e -> new JUnitException("Cannot invoke method " + method.getName() + " onto " + object, e));// } /** 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..8de54f6b6519 --- /dev/null +++ b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/KotlinDynamicTests.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2015-2024 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/aggregator/KotlinParameterizedTests.kt b/jupiter-tests/src/test/kotlin/org/junit/jupiter/params/aggregator/KotlinParameterizedTests.kt new file mode 100644 index 000000000000..aa2064af05c3 --- /dev/null +++ b/jupiter-tests/src/test/kotlin/org/junit/jupiter/params/aggregator/KotlinParameterizedTests.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2015-2024 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.aggregator + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments.arguments +import org.junit.jupiter.params.provider.MethodSource +import java.time.Month + +/** + * Tests for ParameterizedTest kotlin compatibility + */ +object KotlinParameterizedTests { + @ParameterizedTest + @MethodSource("dataProvidedByKotlinSequence") + fun `a method source can be supplied by a Sequence returning method`( + value: Int, + month: Month + ) { + assertEquals(value, month.value) + } + + @JvmStatic + private fun dataProvidedByKotlinSequence() = + sequenceOf( + arguments(1, Month.JANUARY), + arguments(3, Month.MARCH), + arguments(8, Month.AUGUST), + arguments(5, Month.MAY), + arguments(12, Month.DECEMBER) + ) +} 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 ddc08e339f93..ff1fc4c1a806 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 @@ -26,6 +26,8 @@ import java.util.Iterator; import java.util.List; import java.util.Set; +import java.util.Spliterator; +import java.util.Spliterators; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.DoubleStream; import java.util.stream.IntStream; @@ -140,6 +142,7 @@ class StreamConversion { Collection.class, // Iterable.class, // Iterator.class, // + IteratorProvider.class, // Object[].class, // String[].class, // int[].class, // @@ -161,10 +164,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 }, // + IteratorProvider.of(new Integer[] { 1, 2, 3, 4, 5 })// ); } @@ -175,6 +179,8 @@ static Stream objectsConvertibleToStreams() { Object.class, // Integer.class, // String.class, // + IteratorProviderNotUsable.class, // + Spliterator.class, // int.class, // boolean.class // }) @@ -243,7 +249,7 @@ void toStreamWithLongStream() { } @Test - @SuppressWarnings({ "unchecked", "serial" }) + @SuppressWarnings({ "unchecked" }) void toStreamWithCollection() { var collectionStreamClosed = new AtomicBoolean(false); Collection input = new ArrayList<>() { @@ -288,6 +294,24 @@ void toStreamWithIterator() { assertThat(result).containsExactly("foo", "bar"); } + @Test + @SuppressWarnings("unchecked") + void toStreamWithIteratorProvider() { + final var input = IteratorProvider.of(new String[] { "foo", "bar" }); + + final var result = (Stream) CollectionUtils.toStream(input); + + assertThat(result).containsExactly("foo", "bar"); + } + + @Test + void throwWhenIteratorNamedMethodDoesNotReturnAnIterator() { + var o = IteratorProviderNotUsable.of(new String[] { "Test" }); + var e = assertThrows(PreconditionViolationException.class, () -> CollectionUtils.toStream(o)); + + assertEquals("Method with name 'iterator' does not return java.util.Iterator", e.getMessage()); + } + @Test @SuppressWarnings("unchecked") void toStreamWithArray() { @@ -356,4 +380,29 @@ 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 interface IteratorProvider { + + @SuppressWarnings("unused") + Iterator iterator(); + + static IteratorProvider of(T[] elements) { + return () -> Spliterators.iterator(Arrays.spliterator(elements)); + } + } + + /** + * An interface that has a method with name 'iterator', but does not return java.util/Iterator as a return type + */ + private interface IteratorProviderNotUsable { + @SuppressWarnings("unused") + Object iterator(); + + static IteratorProviderNotUsable of(T[] elements) { + return () -> Spliterators.iterator(Arrays.spliterator(elements)); + } + } }