Skip to content

Commit

Permalink
Add @ReportEntry for declarative TestReporter entries
Browse files Browse the repository at this point in the history
This commit adds the basic features of a @ReportEntry extension. It
allows annotating a test case with one or multiple key/value pairs,
which are then published via `TestReporter`.

PR: #183
Closes: #134 

[ci skip-release]
  • Loading branch information
Michael1993 committed Mar 26, 2020
1 parent 595298c commit 46fe56e
Show file tree
Hide file tree
Showing 8 changed files with 350 additions and 0 deletions.
2 changes: 2 additions & 0 deletions docs/docs-nav.yml
Expand Up @@ -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"
Expand Down
80 changes: 80 additions & 0 deletions 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).
24 changes: 24 additions & 0 deletions 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();

}
48 changes: 48 additions & 0 deletions 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();

}
36 changes: 36 additions & 0 deletions 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()));
}
}

}
13 changes: 13 additions & 0 deletions src/main/java/org/junitpioneer/jupiter/Utils.java
Expand Up @@ -58,6 +58,19 @@ public static <A extends Annotation> Optional<A> findAnnotation(ExtensionContext
.orElse(Optional.empty());
}

/**
* Returns the specified repeatable annotation if it either is either <em>present</em> or
* <em>meta-present</em> on the test method belonging to the specified {@code context}.
*/
public static <A extends Annotation> Stream<A> findRepeatableAnnotation(ExtensionContext context,
Class<A> 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}).
Expand Down
Expand Up @@ -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;
Expand All @@ -32,13 +34,26 @@ 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();
engine.execute(new ExecutionRequest(testDescriptor, eventRecorder, request.getConfigurationParameters()));
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()));
}
Expand Down
132 changes: 132 additions & 0 deletions 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<Map<String, String>> reportEntries = reportEntries(recorder);
assertThat(reportEntries).hasSize(1);
Map<String, String> 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<Map<String, String>> 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<Map<String, String>> 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<Map<String, String>> 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<String, String> 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() {
}

}

}

0 comments on commit 46fe56e

Please sign in to comment.