From 35e8d0522450e023d02cb907c6ae2d65bcaa7702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mih=C3=A1ly=20Verh=C3=A1s?= Date: Fri, 17 Nov 2023 02:32:35 +0100 Subject: [PATCH] Create interaction between @Stopwatch and @Issue (#689 / #743) If a test is annotated with both @Issue and @Stopwatch, the test's run time is included in the `IssueTestCase`. Closes: #689 PR: #743 --- docs/issue.adoc | 14 ++++++ .../junitpioneer/jupiter/IssueTestCase.java | 48 ++++++++++++++++--- .../jupiter/StopwatchExtension.java | 21 +++++--- .../IssueExtensionExecutionListener.java | 17 ++++--- .../jupiter/issue/IssueTestCaseBuilder.java | 12 ++++- .../jupiter/IssueTestCaseTests.java | 12 ++++- .../IssueExtensionExecutionListenerTests.java | 5 +- .../issue/IssueExtensionIntegrationTests.java | 33 ++++++++++--- 8 files changed, 134 insertions(+), 28 deletions(-) diff --git a/docs/issue.adoc b/docs/issue.adoc index 4221211cf..86ecbf4e2 100644 --- a/docs/issue.adoc +++ b/docs/issue.adoc @@ -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]. diff --git a/src/main/java/org/junitpioneer/jupiter/IssueTestCase.java b/src/main/java/org/junitpioneer/jupiter/IssueTestCase.java index 967c41055..4da434a0a 100644 --- a/src/main/java/org/junitpioneer/jupiter/IssueTestCase.java +++ b/src/main/java/org/junitpioneer/jupiter/IssueTestCase.java @@ -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; @@ -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 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 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() { @@ -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 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 + '}'; } } diff --git a/src/main/java/org/junitpioneer/jupiter/StopwatchExtension.java b/src/main/java/org/junitpioneer/jupiter/StopwatchExtension.java index 793efc4a2..323bb146c 100644 --- a/src/main/java/org/junitpioneer/jupiter/StopwatchExtension.java +++ b/src/main/java/org/junitpioneer/jupiter/StopwatchExtension.java @@ -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. @@ -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) { @@ -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; } } diff --git a/src/main/java/org/junitpioneer/jupiter/issue/IssueExtensionExecutionListener.java b/src/main/java/org/junitpioneer/jupiter/issue/IssueExtensionExecutionListener.java index ee859d90d..023a054ee 100644 --- a/src/main/java/org/junitpioneer/jupiter/issue/IssueExtensionExecutionListener.java +++ b/src/main/java/org/junitpioneer/jupiter/issue/IssueExtensionExecutionListener.java @@ -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; @@ -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. @@ -56,13 +56,18 @@ public void reportingEntryPublished(TestIdentifier testIdentifier, ReportEntry e if (!active) return; - String testId = testIdentifier.getUniqueId(); - Map 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); } } diff --git a/src/main/java/org/junitpioneer/jupiter/issue/IssueTestCaseBuilder.java b/src/main/java/org/junitpioneer/jupiter/issue/IssueTestCaseBuilder.java index f8a2657eb..992612f6a 100644 --- a/src/main/java/org/junitpioneer/jupiter/issue/IssueTestCaseBuilder.java +++ b/src/main/java/org/junitpioneer/jupiter/issue/IssueTestCaseBuilder.java @@ -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; @@ -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; } @@ -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)); } } diff --git a/src/test/java/org/junitpioneer/jupiter/IssueTestCaseTests.java b/src/test/java/org/junitpioneer/jupiter/IssueTestCaseTests.java index d47be26b7..9ff18cbb8 100644 --- a/src/test/java/org/junitpioneer/jupiter/IssueTestCaseTests.java +++ b/src/test/java/org/junitpioneer/jupiter/IssueTestCaseTests.java @@ -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(); } } diff --git a/src/test/java/org/junitpioneer/jupiter/issue/IssueExtensionExecutionListenerTests.java b/src/test/java/org/junitpioneer/jupiter/issue/IssueExtensionExecutionListenerTests.java index 43e48d10b..3a92f8754 100644 --- a/src/test/java/org/junitpioneer/jupiter/issue/IssueExtensionExecutionListenerTests.java +++ b/src/test/java/org/junitpioneer/jupiter/issue/IssueExtensionExecutionListenerTests.java @@ -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; @@ -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); @@ -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 diff --git a/src/test/java/org/junitpioneer/jupiter/issue/IssueExtensionIntegrationTests.java b/src/test/java/org/junitpioneer/jupiter/issue/IssueExtensionIntegrationTests.java index 1dd20615d..dac718bfc 100644 --- a/src/test/java/org/junitpioneer/jupiter/issue/IssueExtensionIntegrationTests.java +++ b/src/test/java/org/junitpioneer/jupiter/issue/IssueExtensionIntegrationTests.java @@ -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. @@ -44,10 +46,17 @@ void testIssueCases() { List 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)); @@ -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");