Skip to content

Commit

Permalink
Create interaction between @stopwatch and @issue (#689 / #743)
Browse files Browse the repository at this point in the history
If a test is annotated with both @issue and @stopwatch, the test's
run time is included in the `IssueTestCase`.

Closes: #689
PR: #743
  • Loading branch information
Michael1993 committed Nov 17, 2023
1 parent eb19908 commit 35e8d05
Show file tree
Hide file tree
Showing 8 changed files with 134 additions and 28 deletions.
14 changes: 14 additions & 0 deletions docs/issue.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ The implementing class must be registered as a service.
For further information about that, see https://docs.oracle.com/javase/8/docs/api/java/util/ServiceLoader.html[the `ServiceLoader` documentation].
====

The exact API has two classes:

- `IssueTestSuite`, which represents a single issue and all related tests.
- `IssueTestCase`, which represents a single test.

`IssueTestCase` contains:

- the result of the test
- the unique identifier of the test
- the time it took to execute the test (optionally)

The time information is only available if the test is annotated with `@Stopwatch`.
For more information, see the link:/docs/stopwatch.adoc[@Stopwatch documentation].

== Thread-Safety

This extension is safe to use during https://junit.org/junit5/docs/current/user-guide/#writing-tests-parallel-execution[parallel test execution].
48 changes: 41 additions & 7 deletions src/main/java/org/junitpioneer/jupiter/IssueTestCase.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import static java.util.Objects.requireNonNull;

import java.util.Objects;
import java.util.Optional;

import org.junit.platform.engine.TestExecutionResult.Status;

Expand All @@ -31,20 +32,40 @@ public final class IssueTestCase {

private final String testId;
private final Status result;
// no `OptionalLong` because its API doesn't have `map`
private final Optional<Long> elapsedTime;

/**
* Constructor with all attributes.
*
* @param testId Unique name of the test method
* @param result Result of the execution
* @param elapsedTime The (optional) duration of test execution
*/
public IssueTestCase(String testId, Status result) {
public IssueTestCase(String testId, Status result, Optional<Long> elapsedTime) {
this.testId = requireNonNull(testId);
this.result = requireNonNull(result, NO_RESULT_EXCEPTION_MESSAGE);
this.elapsedTime = elapsedTime;
}

/**
* @param testId Unique name of the test method
* @param result Result of the execution
*/
public IssueTestCase(String testId, Status result) {
this(testId, result, Optional.empty());
}

/**
* @param testId Unique name of the test method
* @param result Result of the execution
* @param elapsedTime The duration of test execution
*/
public IssueTestCase(String testId, Status result, long elapsedTime) {
this(testId, result, Optional.of(elapsedTime));
}

/**
* Returns the unique name of the test method.
*
* @return Unique name of the test method
*/
public String testId() {
Expand All @@ -60,24 +81,37 @@ public Status result() {
return result;
}

/**
* Returns the elapsed time since the start of test methods' execution in milliseconds.
*
* @return The elapsed time in ms.
*/
public Optional<Long> elapsedTime() {
return elapsedTime;
}

@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof IssueTestCase))
return false;
IssueTestCase that = (IssueTestCase) o;
return testId.equals(that.testId) && result == that.result;
var that = (IssueTestCase) o;
return testId.equals(that.testId) && result == that.result && Objects.equals(elapsedTime, that.elapsedTime);
}

@Override
public int hashCode() {
return Objects.hash(testId, result);
return Objects.hash(testId, result, elapsedTime);
}

@Override
public String toString() {
return "IssueTestCase{" + "uniqueName='" + testId + '\'' + ", result='" + result + '\'' + '}';
var value = "IssueTestCase{" + "uniqueName='" + testId + '\'' + ", result='" + result + '\'';
if (elapsedTime.isPresent()) {
value += ", elapsedTime='" + elapsedTime.get() + " ms'";
}
return value + '}';
}

}
21 changes: 15 additions & 6 deletions src/main/java/org/junitpioneer/jupiter/StopwatchExtension.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@

package org.junitpioneer.jupiter;

import static org.junitpioneer.jupiter.issue.IssueExtensionExecutionListener.TIME_REPORT_KEY;

import java.time.Clock;

import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junitpioneer.internal.PioneerAnnotationUtils;

/**
* The StopwatchExtension implements callback methods for the {@code @Stopwatch} annotation.
Expand All @@ -33,7 +36,16 @@ public void beforeTestExecution(ExtensionContext context) {

@Override
public void afterTestExecution(ExtensionContext context) {
calculateAndReportElapsedTime(context);
long elapsedTime = calculateElapsedTime(context);
reportElapsedTime(context, elapsedTime);
}

private static void reportElapsedTime(ExtensionContext context, long elapsedTime) {
String message = String.format("Execution of '%s' took [%d] ms.", context.getDisplayName(), elapsedTime);
context.publishReportEntry(STORE_KEY, message);
if (PioneerAnnotationUtils.isAnnotationPresent(context, Issue.class)) {
context.publishReportEntry(TIME_REPORT_KEY, String.valueOf(elapsedTime));
}
}

private void storeNowAsLaunchTime(ExtensionContext context) {
Expand All @@ -44,12 +56,9 @@ private long loadLaunchTime(ExtensionContext context) {
return context.getStore(NAMESPACE).get(context.getUniqueId(), long.class);
}

private void calculateAndReportElapsedTime(ExtensionContext context) {
private long calculateElapsedTime(ExtensionContext context) {
long launchTime = loadLaunchTime(context);
long elapsedTime = clock.instant().toEpochMilli() - launchTime;

String message = String.format("Execution of '%s' took [%d] ms.", context.getDisplayName(), elapsedTime);
context.publishReportEntry(STORE_KEY, message);
return clock.instant().toEpochMilli() - launchTime;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import static org.junit.platform.engine.TestExecutionResult.Status;

import java.util.List;
import java.util.Map;
import java.util.ServiceLoader;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
Expand All @@ -37,6 +36,7 @@
public class IssueExtensionExecutionListener implements TestExecutionListener {

public static final String REPORT_ENTRY_KEY = "IssueExtension";
public static final String TIME_REPORT_KEY = "IssueExtensionTimeReport";

/**
* This listener will be active as soon as Pioneer is on the class/module path, regardless of whether {@code @Issue} is actually used.
Expand All @@ -56,13 +56,18 @@ public void reportingEntryPublished(TestIdentifier testIdentifier, ReportEntry e
if (!active)
return;

String testId = testIdentifier.getUniqueId();
Map<String, String> messages = entry.getKeyValuePairs();
var messages = entry.getKeyValuePairs();
var testId = testIdentifier.getUniqueId();
// because test IDs are unique, we can be sure that the report entries belong to the same test
var testCaseBuilder = testCases.computeIfAbsent(testId, IssueTestCaseBuilder::new);

if (messages.containsKey(REPORT_ENTRY_KEY)) {
String issueId = messages.get(REPORT_ENTRY_KEY);
// because test IDs are unique, there's no risk of overriding previously entered information
testCases.put(testId, new IssueTestCaseBuilder(testId).setIssueId(issueId));
var issueId = messages.get(REPORT_ENTRY_KEY);
testCaseBuilder.setIssueId(issueId);
}
if (messages.containsKey(TIME_REPORT_KEY)) {
var elapsedTime = Long.parseLong(messages.get(TIME_REPORT_KEY));
testCaseBuilder.setElapsedTime(elapsedTime);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@

package org.junitpioneer.jupiter.issue;

import java.util.Optional;

import org.junit.platform.engine.TestExecutionResult.Status;
import org.junitpioneer.jupiter.IssueTestCase;

class IssueTestCaseBuilder {

private final String testId;

// all of these can be null
private String issueId;
private Status result;
private Long elapsedTime;

public IssueTestCaseBuilder(String testId) {
this.testId = testId;
Expand All @@ -28,6 +33,11 @@ public IssueTestCaseBuilder setResult(Status result) {
return this;
}

public IssueTestCaseBuilder setElapsedTime(long elapsedTime) {
this.elapsedTime = elapsedTime;
return this;
}

public String getIssueId() {
return issueId;
}
Expand All @@ -38,7 +48,7 @@ public IssueTestCaseBuilder setIssueId(String issueId) {
}

public IssueTestCase build() {
return new IssueTestCase(testId, result);
return new IssueTestCase(testId, result, Optional.ofNullable(elapsedTime));
}

}
12 changes: 11 additions & 1 deletion src/test/java/org/junitpioneer/jupiter/IssueTestCaseTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,19 @@ void testToString() {
assertThat(result).isEqualTo(expected);
}

@Test
void testToStringWithTime() {
String expected = "IssueTestCase{uniqueName='myName', result='SUCCESSFUL', elapsedTime='0 ms'}";
IssueTestCase sut = new IssueTestCase("myName", Status.SUCCESSFUL, 0L);

String result = sut.toString();

assertThat(result).isEqualTo(expected);
}

@Test
public void equalsContract() {
EqualsVerifier.forClass(IssueTestCase.class).withNonnullFields("testId", "result").verify();
EqualsVerifier.forClass(IssueTestCase.class).withNonnullFields("testId", "result", "elapsedTime").verify();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junitpioneer.jupiter.issue.IssueExtensionExecutionListener.REPORT_ENTRY_KEY;
import static org.junitpioneer.jupiter.issue.IssueExtensionExecutionListener.TIME_REPORT_KEY;
import static org.junitpioneer.jupiter.issue.TestPlanHelper.createTestIdentifier;
import static org.mockito.Mockito.mock;

Expand Down Expand Up @@ -51,10 +52,12 @@ void noIssueTestCasesCreated() {
@Test
void issueTestCasesCreated() {
ReportEntry issueEntry = ReportEntry.from(REPORT_ENTRY_KEY, "#123");
ReportEntry timeEntry = ReportEntry.from(TIME_REPORT_KEY, "6");
TestIdentifier successfulTest = createTestIdentifier("successful-test");

executionListener.testPlanExecutionStarted(testPlan);
executionListener.reportingEntryPublished(successfulTest, issueEntry);
executionListener.reportingEntryPublished(successfulTest, timeEntry);
executionListener.executionStarted(successfulTest);
executionListener.executionFinished(successfulTest, TestExecutionResult.successful());
executionListener.testPlanExecutionFinished(testPlan);
Expand All @@ -68,7 +71,7 @@ void issueTestCasesCreated() {
() -> assertThat(issueTestSuite.tests().size()).isEqualTo(1));

assertThat(issueTestSuite.tests())
.containsExactly(new IssueTestCase("[test:successful-test]", Status.SUCCESSFUL));
.containsExactly(new IssueTestCase("[test:successful-test]", Status.SUCCESSFUL, 6L));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
import org.junitpioneer.jupiter.Issue;
import org.junitpioneer.jupiter.IssueTestCase;
import org.junitpioneer.jupiter.IssueTestSuite;
import org.junitpioneer.jupiter.Stopwatch;
import org.opentest4j.AssertionFailedError;

/**
* Mary Elizabeth Fyre: Do Not Stand at My Grave and Weep is in the public domain.
Expand All @@ -44,10 +46,17 @@ void testIssueCases() {

List<IssueTestSuite> issueTestSuites = StoringIssueProcessor.ISSUE_TEST_SUITES;

assertThat(issueTestSuites).hasSize(3);
assertThat(issueTestSuites).hasSize(4);
assertThat(issueTestSuites)
.extracting(IssueTestSuite::issueId)
.containsExactlyInAnyOrder("Poem #1", "Poem #2", "Poem #3");
.containsExactlyInAnyOrder("Poem #1", "Poem #2", "Poem #3", "Poem #5");
IssueTestSuite firstSuite = issueTestSuites
.stream()
.filter(issueTestSuite -> issueTestSuite.issueId().equals("Poem #1"))
.findFirst()
.orElseThrow(AssertionFailedError::new);

assertThat(firstSuite.tests()).hasSize(2);
assertThat(issueTestSuites)
.allSatisfy(issueTestSuite -> assertThat(issueTestSuite.tests())
.allSatisfy(IssueExtensionIntegrationTests::assertStatus));
Expand All @@ -60,39 +69,51 @@ private static void assertStatus(IssueTestCase testCase) {
assertThat(testCase.result()).isEqualTo(Status.ABORTED);
if (testCase.testId().contains("failing"))
assertThat(testCase.result()).isEqualTo(Status.FAILED);
if (testCase.testId().contains("Stopwatch")) {
assertThat(testCase.elapsedTime()).isNotEmpty();
} else {
assertThat(testCase.elapsedTime()).isEmpty();
}
}

static class IssueIntegrationTestCases {

@Test
@Issue("Poem #1")
@DisplayName("Do not stand at my grave and weep. I am not there. I do not sleep.")
@DisplayName("Do not stand at my grave and weep.")
void successfulTest() {
}

@Test
@Stopwatch
@Issue("Poem #1")
@DisplayName("I am not there. I do not sleep.")
void successfulWithStopwatch() {
}

@Test
@Issue("Poem #2")
@DisplayName("I am a thousand winds that blow. I am the diamond glints on snow.")
void failingTest() {
fail("supposed to fail");
}

@Test
@Issue("Poem #2")
@Issue("Poem #3")
@DisplayName("I am the sunlight on ripened grain. I am the gentle autumn rain.")
void abortedTest() {
abort();
}

@Test
@Issue("Poem #2")
@Issue("Poem #4")
@Disabled("skipped")
@DisplayName("When you awaken in the morning's hush, I am the swift uplifting rush")
void skippedTest() {
}

@Test
@Issue("Poem #3")
@Issue("Poem #5")
@DisplayName("Of quiet birds in circled flight. I am the soft stars that shine at night.")
void publishingTest(TestReporter reporter) {
reporter.publishEntry("Issue", "reporting test");
Expand Down

0 comments on commit 35e8d05

Please sign in to comment.