diff --git a/docs/docs-nav.yml b/docs/docs-nav.yml index 51d418c67..1ef31bebb 100644 --- a/docs/docs-nav.yml +++ b/docs/docs-nav.yml @@ -10,6 +10,8 @@ url: /docs/range-sources/ - title: "@RepeatFailedTest" url: /docs/repeat-failed-test/ + - title: "Report entries" + url: /docs/report-entries/ - title: "TempDirectory" url: /docs/temp-directory/ - title: "Vintage @Test" diff --git a/docs/report-entries.adoc b/docs/report-entries.adoc new file mode 100644 index 000000000..b83354de2 --- /dev/null +++ b/docs/report-entries.adoc @@ -0,0 +1,80 @@ +:page-title: Report entries +:page-description: JUnit Jupiter extension to report with annotations. + +You can use `@ReportEntry` as a simple way to declaratively add metadata to test methods. + +From the https://https://junit.org/junit5/docs/current/user-guide/#writing-tests-dependency-injection[JUnit 5 documentation]: + +> In JUnit Jupiter you should use `TestReporter` where you used to print information to `stdout` or `stderr` in JUnit 4. +> Using `@RunWith(JUnitPlatform.class)` will output all reported entries to `stdout`. +> In addition, some IDEs print report entries to `stdout` or display them in the user interface for test results. + +To see how `@ReportEntry` helps, let's first take a look at the conventional approach and then at this extension. + +== Standard Use of `TestReporter` + +From the https://https://junit.org/junit5/docs/current/user-guide/#writing-tests-dependency-injection[JUnit 5 documentation]: + +> If a constructor or method parameter is of type `TestReporter`, the `TestReporterParameterResolver` will supply an instance of `TestReporter`. +> The `TestReporter` can be used to publish additional data about the current test run. +> The data can be consumed via the `reportingEntryPublished()` method in a `TestExecutionListener`, allowing it to be viewed in IDEs or included in reports. + +So, you would use it like this: + +[source,java] +---- +@Test +void reportingTest(TestReporter reporter) { + reporter.publishEntry("Hello World!"); + // YOUR TEST CODE HERE +} +---- + +You can have a look at https://junit.org/junit5/docs/current/api/org.junit.jupiter.api/org/junit/jupiter/api/TestReporter.html[the official documentation for more details]. + +== With the extension + +It can be argued that publishing data about your tests should not be in your test code because it is simply not part of your test. +It is meta-data. + +So it makes sense that you should be able to add that meta-data to your test declaratively with annotations and have JUnit take care of the publishing. +This is what this extension is for! + +You can write... + +[source,java] +---- +@Test +@ReportEntry("Hello World!") +void reportingTest() { + // YOUR TEST CODE HERE +} +---- + +...and the extension will publish your report entry for you! + +You can declare multiple report entries on the same test (the annotation is repeatable). + +[source,java] +---- +@Test +@ReportEntry("foo") +@ReportEntry("bar") +void reportingTest() { + // YOUR TEST CODE HERE +} +---- + +Just like `TestReporter::publishEntry` accepts a single string as value or a key/value pair, `@ReportEntry` accepts either a single string as value or a key/value pair: + +[source,java] +---- +@Test +@ReportEntry(key = "line1", value = "Once upon a midnight dreary") +@ReportEntry(key = "line2", value = "While I pondered weak and weary") +void edgarAllanPoe() { + // YOUR TEST CODE HERE +} +---- + +Again, just like `TestReporter::publishEntry`, if no key is given it defaults to `"value"` (yes, that's not a mixup). diff --git a/src/main/java/org/junitpioneer/jupiter/ReportEntries.java b/src/main/java/org/junitpioneer/jupiter/ReportEntries.java new file mode 100644 index 000000000..7a68ace86 --- /dev/null +++ b/src/main/java/org/junitpioneer/jupiter/ReportEntries.java @@ -0,0 +1,24 @@ +/* + * Copyright 2015-2020 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 + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import org.junit.jupiter.api.extension.ExtendWith; + +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(ReportEntryExtension.class) +public @interface ReportEntries { + + ReportEntry[] value(); + +} diff --git a/src/main/java/org/junitpioneer/jupiter/ReportEntry.java b/src/main/java/org/junitpioneer/jupiter/ReportEntry.java new file mode 100644 index 000000000..811db3f06 --- /dev/null +++ b/src/main/java/org/junitpioneer/jupiter/ReportEntry.java @@ -0,0 +1,48 @@ +/* + * Copyright 2015-2020 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 + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter; + +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Publish the specified key-value pair to be consumed by an + * {@code org.junit.platform.engine.EngineExecutionListener} + * in order to supply additional information to the reporting + * infrastructure. This is funtionally identical to calling + * {@link org.junit.jupiter.api.extension.ExtensionContext#publishReportEntry(String, String) ExtensionContext::publishReportEntry} + * from within the test method. + */ +@Repeatable(ReportEntries.class) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(ReportEntryExtension.class) +public @interface ReportEntry { + + /** + * Specifies the key of the pair that's to be published as a report entry. + * Defaults to {@code "value"} and can't be blank. + * + * @see org.junit.jupiter.api.extension.ExtensionContext#publishReportEntry(String, String) ExtensionContext::publishReportEntry + */ + String key() default "value"; + + /** + * Specifies the value of the pair that's to be published as a report entry. + * Can't be blank. + * + * @see org.junit.jupiter.api.extension.ExtensionContext#publishReportEntry(String, String) ExtensionContext::publishReportEntry + */ + String value(); + +} diff --git a/src/main/java/org/junitpioneer/jupiter/ReportEntryExtension.java b/src/main/java/org/junitpioneer/jupiter/ReportEntryExtension.java new file mode 100644 index 000000000..f2aa1e43a --- /dev/null +++ b/src/main/java/org/junitpioneer/jupiter/ReportEntryExtension.java @@ -0,0 +1,36 @@ +/* + * Copyright 2015-2020 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 + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter; + +import static java.lang.String.format; + +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.api.extension.ExtensionContext; + +class ReportEntryExtension implements BeforeEachCallback { + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + Utils + .findRepeatableAnnotation(context, ReportEntry.class) + .peek(ReportEntryExtension::verifyKeyValueAreNotBlank) + .forEach(entry -> context.publishReportEntry(entry.key(), entry.value())); + } + + private static void verifyKeyValueAreNotBlank(ReportEntry entry) { + if (entry.key().isEmpty() || entry.value().isEmpty()) { + String message = "Report entries can't have blank key or value: { key=\"%s\", value=\"%s\" }"; + throw new ExtensionConfigurationException(format(message, entry.key(), entry.value())); + } + } + +} diff --git a/src/main/java/org/junitpioneer/jupiter/Utils.java b/src/main/java/org/junitpioneer/jupiter/Utils.java index 094681638..1a56b10b0 100644 --- a/src/main/java/org/junitpioneer/jupiter/Utils.java +++ b/src/main/java/org/junitpioneer/jupiter/Utils.java @@ -58,6 +58,19 @@ public static Optional findAnnotation(ExtensionContext .orElse(Optional.empty()); } + /** + * Returns the specified repeatable annotation if it either is either present or + * meta-present on the test method belonging to the specified {@code context}. + */ + public static Stream findRepeatableAnnotation(ExtensionContext context, + Class annotationType) { + return Stream + .of(context.getElement(), context.getTestClass().map(Class::getEnclosingClass)) + .filter(Optional::isPresent) + .map(Optional::get) + .flatMap(el -> AnnotationSupport.findRepeatableAnnotations(el, annotationType).stream()); + } + /** * A {@link Collectors#toSet() toSet} collector that throws an {@link IllegalStateException} * on duplicate elements (according to {@link Object#equals(Object) equals}). diff --git a/src/test/java/org/junit/jupiter/engine/AbstractJupiterTestEngineTests.java b/src/test/java/org/junit/jupiter/engine/AbstractJupiterTestEngineTests.java index 68e5b7e1a..6a4e8009d 100644 --- a/src/test/java/org/junit/jupiter/engine/AbstractJupiterTestEngineTests.java +++ b/src/test/java/org/junit/jupiter/engine/AbstractJupiterTestEngineTests.java @@ -11,10 +11,12 @@ package org.junit.jupiter.engine; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod; import static org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder.request; import org.junit.platform.engine.ExecutionRequest; import org.junit.platform.engine.TestDescriptor; +import org.junit.platform.engine.TestExecutionResult; import org.junit.platform.engine.UniqueId; import org.junit.platform.engine.test.event.ExecutionEventRecorder; import org.junit.platform.launcher.LauncherDiscoveryRequest; @@ -32,6 +34,10 @@ protected ExecutionEventRecorder executeTestsForClass(Class testClass) { return executeTests(request().selectors(selectClass(testClass)).build()); } + protected ExecutionEventRecorder executeTestsForMethod(Class testClass, String methodName) { + return executeTests(request().selectors(selectMethod(testClass.getName(), methodName)).build()); + } + protected ExecutionEventRecorder executeTests(LauncherDiscoveryRequest request) { TestDescriptor testDescriptor = discoverTests(request); ExecutionEventRecorder eventRecorder = new ExecutionEventRecorder(); @@ -39,6 +45,15 @@ protected ExecutionEventRecorder executeTests(LauncherDiscoveryRequest request) return eventRecorder; } + protected Throwable getFirstFailuresThrowable(ExecutionEventRecorder recorder) { + return recorder + .getFailedTestFinishedEvents() + .get(0) + .getPayload(TestExecutionResult.class) + .flatMap(TestExecutionResult::getThrowable) + .orElseThrow(AssertionError::new); + } + protected TestDescriptor discoverTests(LauncherDiscoveryRequest request) { return engine.discover(request, UniqueId.forEngine(engine.getId())); } diff --git a/src/test/java/org/junitpioneer/jupiter/ReportEntryExtensionTest.java b/src/test/java/org/junitpioneer/jupiter/ReportEntryExtensionTest.java new file mode 100644 index 000000000..f8c41fbe7 --- /dev/null +++ b/src/test/java/org/junitpioneer/jupiter/ReportEntryExtensionTest.java @@ -0,0 +1,132 @@ +/* + * Copyright 2015-2020 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 + * + * http://www.eclipse.org/legal/epl-v20.html + */ + +package org.junitpioneer.jupiter; + +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.AbstractMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; +import org.junit.platform.engine.test.event.ExecutionEvent; +import org.junit.platform.engine.test.event.ExecutionEventRecorder; + +public class ReportEntryExtensionTest extends AbstractJupiterTestEngineTests { + + @Test + void explicitKey_keyAndValueAreReported() { + ExecutionEventRecorder recorder = executeTestsForMethod(ReportEntriesTest.class, "explicitKey"); + + List> reportEntries = reportEntries(recorder); + assertThat(reportEntries).hasSize(1); + Map reportEntry = reportEntries.get(0); + assertThat(reportEntry).hasSize(1); + assertThat(reportEntry).containsExactly(entryOf("Crow2", "While I pondered weak and weary")); + } + + @Test + void implicitKey_keyIsNamedValue() { + ExecutionEventRecorder recorder = executeTestsForMethod(ReportEntriesTest.class, "implicitKey"); + + List> reportEntries = reportEntries(recorder); + assertThat(reportEntries).hasSize(1); + assertThat(reportEntries.get(0)).satisfies(reportEntry -> { + assertThat(reportEntry).hasSize(1); + assertThat(reportEntry).containsExactly(entryOf("value", "Once upon a midnight dreary")); + }); + } + + @Test + void emptyKey_fails() { + ExecutionEventRecorder recorder = executeTestsForMethod(ReportEntriesTest.class, "emptyKey"); + + assertThat(recorder.getFailedTestFinishedEvents()).hasSize(1); + assertThat(getFirstFailuresThrowable(recorder).getMessage()) + .contains("Report entries can't have blank key or value", + "Over many a quaint and curious volume of forgotten lore"); + } + + @Test + void emptyValue_fails() { + ExecutionEventRecorder recorder = executeTestsForMethod(ReportEntriesTest.class, "emptyValue"); + + assertThat(recorder.getFailedTestFinishedEvents()).hasSize(1); + assertThat(getFirstFailuresThrowable(recorder).getMessage()) + .contains("Report entries can't have blank key or value", "While I nodded, nearly napping"); + } + + @Test + void repeatedAnnotation_logEachKeyValuePairAsIndividualEntry() { + ExecutionEventRecorder recorder = executeTestsForMethod(ReportEntriesTest.class, "repeatedAnnotation"); + + List> reportEntries = reportEntries(recorder); + + assertAll("Verifying report entries " + reportEntries, // + () -> assertThat(reportEntries).hasSize(3), + () -> assertThat(reportEntries).extracting(entry -> entry.size()).containsExactlyInAnyOrder(1, 1, 1), + () -> assertThat(reportEntries) + .extracting(entry -> entry.get("value")) + .containsExactlyInAnyOrder("suddenly there came a tapping", "As if some one gently rapping", + "rapping at my chamber door")); + } + + private static List> reportEntries(ExecutionEventRecorder recorder) { + return recorder + .eventStream() + .filter(event -> event.getType().equals(ExecutionEvent.Type.REPORTING_ENTRY_PUBLISHED)) + .map(executionEvent -> executionEvent.getPayload(org.junit.platform.engine.reporting.ReportEntry.class)) + .filter(Optional::isPresent) + .map(Optional::get) + .map(org.junit.platform.engine.reporting.ReportEntry::getKeyValuePairs) + .collect(toList()); + } + + private static Map.Entry entryOf(String key, String value) { + return new AbstractMap.SimpleEntry<>(key, value); + } + + static class ReportEntriesTest { + + @Test + @ReportEntry(key = "Crow2", value = "While I pondered weak and weary") + void explicitKey() { + } + + @Test + @ReportEntry("Once upon a midnight dreary") + void implicitKey() { + } + + @Test + @ReportEntry(key = "", value = "Over many a quaint and curious volume of forgotten lore") + void emptyKey() { + } + + @Test + @ReportEntry(key = "While I nodded, nearly napping", value = "") + void emptyValue() { + } + + @Test + @ReportEntry("suddenly there came a tapping") + @ReportEntry("As if some one gently rapping") + @ReportEntry("rapping at my chamber door") + void repeatedAnnotation() { + } + + } + +}