diff --git a/cucumber-bom/pom.xml b/cucumber-bom/pom.xml index 9575012ba3..dd520b2a6c 100644 --- a/cucumber-bom/pom.xml +++ b/cucumber-bom/pom.xml @@ -21,7 +21,7 @@ 0.9.0 29.0.1 2.3.0 - 14.4.0 + 14.4.1-SNAPSHOT 6.1.2 0.1.1 0.6.0 diff --git a/cucumber-core/pom.xml b/cucumber-core/pom.xml index 1e191a1298..bf24f0bdad 100644 --- a/cucumber-core/pom.xml +++ b/cucumber-core/pom.xml @@ -125,6 +125,10 @@ com.fasterxml.jackson.datatype jackson-datatype-jdk8 + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + org.xmlunit @@ -260,6 +264,7 @@ com.fasterxml.jackson.core:jackson-core com.fasterxml.jackson.core:jackson-annotations com.fasterxml.jackson.datatype:jackson-datatype-jdk8 + com.fasterxml.jackson.datatype:jackson-datatype-jsr310 @@ -303,6 +308,14 @@ META-INF/services/** + + com.fasterxml.jackson.datatype:jackson-datatype-jsr310 + + **/module-info.class + META-INF/MANIFEST.MF + META-INF/services/** + + diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/Jackson.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/Jackson.java index e300f11468..3f03639acf 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/Jackson.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/Jackson.java @@ -8,12 +8,14 @@ import com.fasterxml.jackson.databind.cfg.ConstructorDetector; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import static com.fasterxml.jackson.annotation.JsonInclude.Value.construct; final class Jackson { public static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() .addModule(new Jdk8Module()) + .addModule(new JavaTimeModule()) .defaultPropertyInclusion(construct( Include.NON_ABSENT, Include.NON_ABSENT)) diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/SourceReferenceFormatter.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/SourceReferenceFormatter.java new file mode 100644 index 0000000000..8d5c8a6c0a --- /dev/null +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/SourceReferenceFormatter.java @@ -0,0 +1,42 @@ +package io.cucumber.core.plugin; + +import io.cucumber.messages.types.Location; +import io.cucumber.messages.types.SourceReference; + +import java.util.Optional; +import java.util.function.Function; + +final class SourceReferenceFormatter { + private final Function uriFormatter; + + SourceReferenceFormatter(Function uriFormatter) { + this.uriFormatter = uriFormatter; + } + + Optional format(SourceReference sourceReference) { + if (sourceReference.getJavaMethod().isPresent()) { + return sourceReference.getJavaMethod() + .map(javaMethod -> String.format( + "%s.%s(%s)", + javaMethod.getClassName(), + javaMethod.getMethodName(), + String.join(",", javaMethod.getMethodParameterTypes()))); + } + if (sourceReference.getJavaStackTraceElement().isPresent()) { + return sourceReference.getJavaStackTraceElement() + .map(javaStackTraceElement -> String.format( + "%s.%s(%s%s)", + javaStackTraceElement.getClassName(), + javaStackTraceElement.getMethodName(), + javaStackTraceElement.getFileName(), + sourceReference.getLocation().map(Location::getLine).map(line -> ":" + line).orElse(""))); + } + if (sourceReference.getUri().isPresent()) { + return sourceReference.getUri() + .map(uri -> uriFormatter.apply(uri) + sourceReference.getLocation() + .map(location -> ":" + location.getLine()) + .orElse("")); + } + return Optional.empty(); + } +} diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/UsageFormatter.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/UsageFormatter.java index 877e31b368..95b214d3fb 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/UsageFormatter.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/UsageFormatter.java @@ -1,24 +1,21 @@ package io.cucumber.core.plugin; +import io.cucumber.messages.types.Envelope; import io.cucumber.plugin.ConcurrentEventListener; import io.cucumber.plugin.Plugin; import io.cucumber.plugin.event.EventPublisher; -import io.cucumber.plugin.event.PickleStepTestStep; -import io.cucumber.plugin.event.Result; -import io.cucumber.plugin.event.Status; -import io.cucumber.plugin.event.TestRunFinished; -import io.cucumber.plugin.event.TestStepFinished; +import io.cucumber.query.Query; +import io.cucumber.query.Repository; import java.io.IOException; import java.io.OutputStream; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; + +import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_GHERKIN_DOCUMENTS; +import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_STEP_DEFINITIONS; +import static java.util.Objects.requireNonNull; /** * Formatter to measure performance of steps. Includes average and median step @@ -26,211 +23,107 @@ */ public final class UsageFormatter implements Plugin, ConcurrentEventListener { - final Map> usageMap = new LinkedHashMap<>(); - private final UTF8OutputStreamWriter out; + private final MessagesToUsageWriter writer; - /** - * Constructor - * - * @param out {@link Appendable} to print the result - */ @SuppressWarnings("WeakerAccess") // Used by PluginFactory public UsageFormatter(OutputStream out) { - this.out = new UTF8OutputStreamWriter(out); + this.writer = MessagesToUsageWriter.builder(Jackson.OBJECT_MAPPER::writeValue) + .build(out); } @Override public void setEventPublisher(EventPublisher publisher) { - publisher.registerHandlerFor(TestStepFinished.class, this::handleTestStepFinished); - publisher.registerHandlerFor(TestRunFinished.class, event -> finishReport()); - } - - void handleTestStepFinished(TestStepFinished event) { - if (event.getTestStep() instanceof PickleStepTestStep && event.getResult().getStatus().is(Status.PASSED)) { - PickleStepTestStep testStep = (PickleStepTestStep) event.getTestStep(); - addUsageEntry(event.getResult(), testStep); - } + publisher.registerHandlerFor(Envelope.class, this::write); } - void finishReport() { - List stepDefContainers = new ArrayList<>(); - for (Map.Entry> usageEntry : usageMap.entrySet()) { - StepDefContainer stepDefContainer = new StepDefContainer( - usageEntry.getKey(), - createStepContainers(usageEntry.getValue())); - stepDefContainers.add(stepDefContainer); - } - + private void write(Envelope event) { try { - Jackson.OBJECT_MAPPER.writeValue(out, stepDefContainers); - out.close(); + writer.write(event); } catch (IOException e) { - throw new RuntimeException(e); + throw new IllegalStateException(e); } - } - private void addUsageEntry(Result result, PickleStepTestStep testStep) { - List stepContainers = usageMap.computeIfAbsent(testStep.getPattern(), k -> new ArrayList<>()); - StepContainer stepContainer = findOrCreateStepContainer(testStep.getStepText(), stepContainers); - StepDuration stepDuration = new StepDuration(result.getDuration(), - testStep.getUri() + ":" + testStep.getStepLine()); - stepContainer.getDurations().add(stepDuration); - } - - private List createStepContainers(List stepContainers) { - for (StepContainer stepContainer : stepContainers) { - stepContainer.putAllAggregatedDurations(createAggregatedDurations(stepContainer)); - } - return stepContainers; - } - - private StepContainer findOrCreateStepContainer(String stepNameWithArgs, List stepContainers) { - for (StepContainer container : stepContainers) { - if (stepNameWithArgs.equals(container.getName())) { - return container; + // TODO: Plugins should implement the closable interface + // and be closed by Cucumber + if (event.getTestRunFinished().isPresent()) { + try { + writer.close(); + } catch (IOException e) { + throw new IllegalStateException(e); } } - StepContainer stepContainer = new StepContainer(stepNameWithArgs); - stepContainers.add(stepContainer); - return stepContainer; - } - - private Map createAggregatedDurations(StepContainer stepContainer) { - Map aggregatedResults = new LinkedHashMap<>(); - List rawDurations = getRawDurations(stepContainer.getDurations()); - - Double average = calculateAverage(rawDurations); - aggregatedResults.put("average", average); - - Double median = calculateMedian(rawDurations); - aggregatedResults.put("median", median); - - return aggregatedResults; - } - - private List getRawDurations(List stepDurations) { - List rawDurations = new ArrayList<>(); - - for (StepDuration stepDuration : stepDurations) { - rawDurations.add(stepDuration.duration); - } - return rawDurations; } /** - * Calculate the average of a list of duration entries + * Creates a usage report for step definitions based on a test run. + *

+ * Note: Messages are first collected and only written once the stream is + * closed. */ - Double calculateAverage(List durationEntries) { - double sum = 0.0; - for (Double duration : durationEntries) { - sum = sum + duration; - } - if (sum == 0) { - return 0.0; - } - - return sum / durationEntries.size(); - } - - /** - * Calculate the median of a list of duration entries - */ - Double calculateMedian(List durationEntries) { - if (durationEntries.isEmpty()) { - return 0.0; - } - Collections.sort(durationEntries); - int middle = durationEntries.size() / 2; - if (durationEntries.size() % 2 == 1) { - return durationEntries.get(middle); - } else { - double total = durationEntries.get(middle - 1) + durationEntries.get(middle); - return total / 2; - } - } - - /** - * Container of Step Definitions (patterns) - */ - static class StepDefContainer { - - private final String source; - private final List steps; - - StepDefContainer(String source, List steps) { - this.source = source; - this.steps = steps; - } - - /** - * The StepDefinition (pattern) - */ - public String getSource() { - return source; - } - - /** - * A list of Steps - */ - public List getSteps() { - return steps; - } - - } - - /** - * Container for usage-entries of steps - */ - static class StepContainer { - - private final String name; - private final Map aggregatedDurations = new HashMap<>(); - private final List durations = new ArrayList<>(); - - StepContainer(String name) { - this.name = name; - } - - public String getName() { - return name; - } - - void putAllAggregatedDurations(Map aggregatedDurations) { - this.aggregatedDurations.putAll(aggregatedDurations); - } - - public Map getAggregatedDurations() { - return aggregatedDurations; - } - - List getDurations() { - return durations; - } - - } - - private static double durationToSeconds(Duration duration) { - return (double) duration.toNanos() / TimeUnit.SECONDS.toNanos(1); - } - - static class StepDuration { - - private final double duration; - private final String location; - - StepDuration(Duration duration, String location) { - this.duration = durationToSeconds(duration); - this.location = location; + public static final class MessagesToUsageWriter implements AutoCloseable { + + private final OutputStreamWriter out; + private final Repository repository = Repository.builder() + .feature(INCLUDE_GHERKIN_DOCUMENTS, true) + .feature(INCLUDE_STEP_DEFINITIONS, true) + .build(); + private final Query query = new Query(repository); + private final Serializer serializer; + private boolean streamClosed = false; + + public MessagesToUsageWriter(OutputStream out, Serializer serializer) { + this.out = new OutputStreamWriter( + requireNonNull(out), + StandardCharsets.UTF_8); + this.serializer = requireNonNull(serializer); + } + + public void write(Envelope envelope) throws IOException { + if (streamClosed) { + throw new IOException("Stream closed"); + } + repository.update(envelope); + } + + public static Builder builder(Serializer serializer) { + return new Builder(serializer); + } + + public static final class Builder { + private final Serializer serializer; + + private Builder(Serializer serializer) { + this.serializer = requireNonNull(serializer); + } + + public MessagesToUsageWriter build(OutputStream out) { + requireNonNull(out); + return new MessagesToUsageWriter(out, serializer); + } } - - public double getDuration() { - return duration; + + @Override + public void close() throws IOException { + if (streamClosed) { + return; + } + try { + UsageReportWriter.UsageReport report = new UsageReportWriter(query).createUsageReport(); + serializer.writeValue(out, report); + } finally { + try { + out.close(); + } finally { + streamClosed = true; + } + } } - - public String getLocation() { - return location; + + @FunctionalInterface + public interface Serializer { + + void writeValue(Writer writer, Object value) throws IOException; + } - } - } diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/UsageReportWriter.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/UsageReportWriter.java new file mode 100644 index 0000000000..c97217b8d9 --- /dev/null +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/UsageReportWriter.java @@ -0,0 +1,247 @@ +package io.cucumber.core.plugin; + +import io.cucumber.messages.Convertor; +import io.cucumber.messages.types.Location; +import io.cucumber.messages.types.PickleStep; +import io.cucumber.messages.types.SourceReference; +import io.cucumber.messages.types.StepDefinition; +import io.cucumber.messages.types.StepDefinitionPattern; +import io.cucumber.messages.types.TestStepFinished; +import io.cucumber.query.Query; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static io.cucumber.messages.types.StepDefinitionPatternType.CUCUMBER_EXPRESSION; +import static java.util.Comparator.naturalOrder; +import static java.util.Objects.requireNonNull; +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.mapping; +import static java.util.stream.Collectors.toList; + +final class UsageReportWriter { + + private final Query query; + private final Function uriFormatter = s -> s; + private final SourceReferenceFormatter sourceReferenceFormatter; + + UsageReportWriter(Query query) { + this.query = query; + this.sourceReferenceFormatter = new SourceReferenceFormatter(uriFormatter); + } + + UsageReport createUsageReport() { + Map> testStepsFinishedByStepDefinition = query.findAllTestStepFinished() + .stream() + .collect(groupingBy(findUnambiguousStepDefinitionBy(), LinkedHashMap::new, + mapping(createStepDuration(), toList()))); + + // Add unused step definitions + query.findAllStepDefinitions().forEach(stepDefinition -> testStepsFinishedByStepDefinition + .computeIfAbsent(stepDefinition, sd -> new ArrayList<>())); + + List stepDefinitionUsages = testStepsFinishedByStepDefinition.entrySet() + .stream() + .map(entry -> createStepContainer(entry.getKey(), entry.getValue())) + .collect(toList()); + return new UsageReport(stepDefinitionUsages); + } + + private StepDefinitionUsage createStepContainer(StepDefinition stepDefinition, List stepUsages) { + Statistics aggregatedDurations = createDurationStatistics(stepUsages); + String pattern = stepDefinition.getPattern().getSource(); + String location = sourceReferenceFormatter.format(stepDefinition.getSourceReference()).orElse(""); + return new StepDefinitionUsage(pattern, location, aggregatedDurations, stepUsages); + } + + private static Statistics createDurationStatistics(List stepUsages) { + if (stepUsages.isEmpty()) { + return null; + } + Duration sum = stepUsages.stream() + .map(StepUsage::getDuration) + .reduce(Duration::plus) + // Can't happen + .orElse(Duration.ZERO); + + Duration min = stepUsages.stream() + .map(StepUsage::getDuration) + .min(naturalOrder()) + // Can't happen + .orElse(Duration.ZERO); + + Duration max = stepUsages.stream() + .map(StepUsage::getDuration) + .max(naturalOrder()) + // Can't happen + .orElse(Duration.ZERO); + + Duration average = sum.dividedBy(stepUsages.size()); + + Duration median = getMedian(stepUsages); + + return new Statistics(sum, average, median, min, max); + } + + private static Duration getMedian(List stepUsages) { + long size = stepUsages.size(); + long medianItems = size % 2 == 0 ? 2 : 1; + long medianIndex = size % 2 == 0 ? (size / 2) - 1 : size / 2; + return stepUsages.stream() + .map(StepUsage::getDuration) + .sorted() + .skip(medianIndex) + .limit(medianItems) + .reduce(Duration::plus) + .orElse(Duration.ZERO) + .dividedBy(medianItems); + } + + private Function createStepDuration() { + return testStepFinished -> query + .findTestStepBy(testStepFinished) + .flatMap(query::findPickleStepBy) + .map(pickleStep -> createStepDuration(testStepFinished, pickleStep)) + .orElseGet(() -> new StepUsage("", Duration.ZERO, "")); + } + + private StepUsage createStepDuration(TestStepFinished testStepFinished, PickleStep pickleStep) { + String text = pickleStep.getText(); + String location = findLocationOf(testStepFinished); + Duration duration = Convertor.toDuration(testStepFinished.getTestStepResult().getDuration()); + return new StepUsage(text, duration, location); + } + + private String findLocationOf(TestStepFinished testStepFinished) { + return query.findPickleBy(testStepFinished) + .map(pickle -> uriFormatter.apply(pickle.getUri()) + query.findLocationOf(pickle) + .map(Location::getLine) + .map(line -> ":" + line) + .orElse("")) + .orElse(""); + } + + private Function findUnambiguousStepDefinitionBy() { + return testStepFinished -> query.findTestStepBy(testStepFinished) + .flatMap(query::findUnambiguousStepDefinitionBy) + .orElseGet(UsageReportWriter::createDummyStepDefinition); + } + + private static StepDefinition createDummyStepDefinition() { + return new StepDefinition("", new StepDefinitionPattern("", CUCUMBER_EXPRESSION), SourceReference.of("")); + } + + static final class UsageReport { + private final List stepDefinitions; + + UsageReport(List stepDefinitions) { + this.stepDefinitions = requireNonNull(stepDefinitions); + } + + public List getStepDefinitions() { + return stepDefinitions; + } + } + + /** + * Container for usage-entries of steps + */ + static final class StepDefinitionUsage { + + private final String expression; + private final String location; + private final Statistics duration; + private final List steps; + + StepDefinitionUsage( + String expression, String location, Statistics duration, List steps + ) { + this.expression = requireNonNull(expression); + this.location = requireNonNull(location); + this.duration = duration; + this.steps = requireNonNull(steps); + } + + public String getExpression() { + return expression; + } + + public Statistics getDuration() { + return duration; + } + + public List getSteps() { + return steps; + } + + public String getLocation() { + return location; + } + } + + static final class Statistics { + private final Duration sum; + private final Duration average; + private final Duration median; + private final Duration min; + private final Duration max; + + Statistics(Duration sum, Duration average, Duration median, Duration min, Duration max) { + this.sum = sum; + this.average = average; + this.median = median; + this.min = min; + this.max = max; + } + + public Duration getSum() { + return sum; + } + + public Duration getAverage() { + return average; + } + + public Duration getMedian() { + return median; + } + + public Duration getMin() { + return min; + } + + public Duration getMax() { + return max; + } + } + + static final class StepUsage { + + private final String text; + private final Duration duration; + private final String location; + + StepUsage(String text, Duration duration, String location) { + this.text = requireNonNull(text); + this.duration = requireNonNull(duration); + this.location = requireNonNull(location); + } + + public Duration getDuration() { + return duration; + } + + public String getLocation() { + return location; + } + + public String getText() { + return text; + } + } + +} diff --git a/cucumber-core/src/test/java/io/cucumber/core/plugin/UsageFormatterTest.java b/cucumber-core/src/test/java/io/cucumber/core/plugin/UsageFormatterTest.java index 00cc379a35..0323d71bce 100644 --- a/cucumber-core/src/test/java/io/cucumber/core/plugin/UsageFormatterTest.java +++ b/cucumber-core/src/test/java/io/cucumber/core/plugin/UsageFormatterTest.java @@ -1,262 +1,270 @@ package io.cucumber.core.plugin; -import io.cucumber.plugin.event.PickleStepTestStep; -import io.cucumber.plugin.event.Result; -import io.cucumber.plugin.event.Status; -import io.cucumber.plugin.event.TestCase; -import io.cucumber.plugin.event.TestStep; -import io.cucumber.plugin.event.TestStepFinished; +import io.cucumber.core.backend.StubStepDefinition; +import io.cucumber.core.feature.TestFeatureParser; +import io.cucumber.core.gherkin.Feature; +import io.cucumber.core.options.RuntimeOptionsBuilder; +import io.cucumber.core.runner.StepDurationTimeService; +import io.cucumber.core.runtime.Runtime; +import io.cucumber.core.runtime.StubBackendSupplier; +import io.cucumber.core.runtime.StubFeatureSupplier; +import io.cucumber.core.runtime.TimeServiceEventBus; import org.json.JSONException; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import java.io.ByteArrayOutputStream; -import java.io.OutputStream; +import java.io.File; import java.time.Duration; -import java.time.Instant; -import java.util.Collections; -import java.util.List; -import java.util.Map; +import java.util.UUID; -import static java.util.Arrays.asList; -import static java.util.Collections.singletonList; -import static org.hamcrest.CoreMatchers.containsString; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.core.Is.is; -import static org.hamcrest.core.IsEqual.equalTo; -import static org.hamcrest.number.IsCloseTo.closeTo; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static io.cucumber.core.plugin.PrettyFormatterStepDefinition.oneReference; +import static io.cucumber.core.plugin.PrettyFormatterStepDefinition.twoReference; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.skyscreamer.jsonassert.JSONAssert.assertEquals; class UsageFormatterTest { - public static final double EPSILON = 0.001; - @Test - void resultWithPassedStep() { - OutputStream out = new ByteArrayOutputStream(); - UsageFormatter usageFormatter = new UsageFormatter(out); - TestStep testStep = mockTestStep(); - Result result = new Result(Status.PASSED, Duration.ofMillis(12345L), null); - - usageFormatter - .handleTestStepFinished(new TestStepFinished(Instant.EPOCH, mock(TestCase.class), testStep, result)); - - Map> usageMap = usageFormatter.usageMap; - assertThat(usageMap.size(), is(equalTo(1))); - List durationEntries = usageMap.get("stepDef"); - assertThat(durationEntries.size(), is(equalTo(1))); - assertThat(durationEntries.get(0).getName(), is(equalTo("step"))); - assertThat(durationEntries.get(0).getDurations().size(), is(equalTo(1))); - assertThat(durationEntries.get(0).getDurations().get(0).getDuration(), is(closeTo(12.345, EPSILON))); - } - - private PickleStepTestStep mockTestStep() { - PickleStepTestStep testStep = mock(PickleStepTestStep.class, Mockito.RETURNS_MOCKS); - when(testStep.getPattern()).thenReturn("stepDef"); - when(testStep.getStepText()).thenReturn("step"); - return testStep; - } - - @Test - void resultWithPassedAndFailedStep() { - OutputStream out = new ByteArrayOutputStream(); - UsageFormatter usageFormatter = new UsageFormatter(out); - TestStep testStep = mockTestStep(); - - Result passed = new Result(Status.PASSED, Duration.ofSeconds(12345L), null); - usageFormatter - .handleTestStepFinished(new TestStepFinished(Instant.EPOCH, mock(TestCase.class), testStep, passed)); - - Result failed = new Result(Status.FAILED, Duration.ZERO, null); - usageFormatter - .handleTestStepFinished(new TestStepFinished(Instant.EPOCH, mock(TestCase.class), testStep, failed)); - - Map> usageMap = usageFormatter.usageMap; - assertThat(usageMap.size(), is(equalTo(1))); - List durationEntries = usageMap.get("stepDef"); - assertThat(durationEntries.size(), is(equalTo(1))); - assertThat(durationEntries.get(0).getName(), is(equalTo("step"))); - assertThat(durationEntries.get(0).getDurations().size(), is(equalTo(1))); - assertThat(durationEntries.get(0).getDurations().get(0).getDuration(), is(closeTo(12345.0, EPSILON))); + void writes_empty_report() throws JSONException { + Feature feature = TestFeatureParser.parse("path/test.feature", "" + + "Feature: feature name\n"); + + StepDurationTimeService timeService = new StepDurationTimeService(Duration.ofMillis(1000)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Runtime.builder() + .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID)) + .withFeatureSupplier(new StubFeatureSupplier(feature)) + .withAdditionalPlugins(timeService, new UsageFormatter(out)) + .withRuntimeOptions(new RuntimeOptionsBuilder().setMonochrome().build()) + .withBackendSupplier(new StubBackendSupplier()) + .build() + .run(); + + String expected = "" + + "{\n" + + " \"stepDefinitions\": []\n" + + "}"; + assertJsonEquals(expected, out); } @Test - void resultWithZeroDuration() { - OutputStream out = new ByteArrayOutputStream(); - UsageFormatter usageFormatter = new UsageFormatter(out); - TestStep testStep = mockTestStep(); - Result result = new Result(Status.PASSED, Duration.ZERO, null); - - usageFormatter - .handleTestStepFinished(new TestStepFinished(Instant.EPOCH, mock(TestCase.class), testStep, result)); - - Map> usageMap = usageFormatter.usageMap; - assertThat(usageMap.size(), is(equalTo(1))); - List durationEntries = usageMap.get("stepDef"); - assertThat(durationEntries.size(), is(equalTo(1))); - assertThat(durationEntries.get(0).getName(), is(equalTo("step"))); - assertThat(durationEntries.get(0).getDurations().size(), is(equalTo(1))); - assertThat(durationEntries.get(0).getDurations().get(0).getDuration(), is(equalTo(0.0))); + void writes_unused_report() throws JSONException { + Feature feature = TestFeatureParser.parse("path/test.feature", "" + + "Feature: feature name\n" + + " Scenario: scenario name\n" + + " Given first step\n"); + + StepDurationTimeService timeService = new StepDurationTimeService(Duration.ofMillis(1000)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Runtime.builder() + .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID)) + .withFeatureSupplier(new StubFeatureSupplier(feature)) + .withAdditionalPlugins(timeService, new UsageFormatter(out)) + .withRuntimeOptions(new RuntimeOptionsBuilder().setMonochrome().build()) + .withBackendSupplier(new StubBackendSupplier( + new StubStepDefinition("first step", oneReference()), + new StubStepDefinition("second step", twoReference()))) + .build() + .run(); + + String featureFile = new File("").toURI() + "path/test.feature"; + String expected = "" + + "{\n" + + " \"stepDefinitions\": [\n" + + " {\n" + + " \"expression\": \"first step\",\n" + + " \"location\": \"io.cucumber.core.plugin.PrettyFormatterStepDefinition.one()\",\n" + + " \"duration\": {\n" + + " \"sum\": 1.000000000,\n" + + " \"average\": 1.000000000,\n" + + " \"median\": 1.000000000,\n" + + " \"min\": 1.000000000,\n" + + " \"max\": 1.000000000\n" + + " },\n" + + " \"steps\": [\n" + + " {\n" + + " \"text\": \"first step\",\n" + + " \"duration\": 1.000000000,\n" + + " \"location\": \"path/test.feature:2\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " {\n" + + " \"expression\": \"second step\",\n" + + " \"location\": \"io.cucumber.core.plugin.PrettyFormatterStepDefinition.two()\",\n" + + " \"steps\": []\n" + + " }\n" + + " ]\n" + + "}"; + assertJsonEquals(expected.replaceAll("path/test.feature", featureFile), out); } - // Note: Duplicate of above test @Test - void resultWithNullDuration() { - OutputStream out = new ByteArrayOutputStream(); - UsageFormatter usageFormatter = new UsageFormatter(out); - PickleStepTestStep testStep = mockTestStep(); - Result result = new Result(Status.PASSED, Duration.ZERO, null); - - usageFormatter - .handleTestStepFinished(new TestStepFinished(Instant.EPOCH, mock(TestCase.class), testStep, result)); - - Map> usageMap = usageFormatter.usageMap; - assertThat(usageMap.size(), is(equalTo(1))); - List durationEntries = usageMap.get("stepDef"); - assertThat(durationEntries.size(), is(equalTo(1))); - assertThat(durationEntries.get(0).getName(), is(equalTo("step"))); - assertThat(durationEntries.get(0).getDurations().size(), is(equalTo(1))); - assertThat(durationEntries.get(0).getDurations().get(0).getDuration(), is(equalTo(0.0))); + void writes_usage_report() throws JSONException { + Feature feature = TestFeatureParser.parse("path/test.feature", "" + + "Feature: feature name\n" + + " Scenario: scenario name\n" + + " Given first step\n"); + + StepDurationTimeService timeService = new StepDurationTimeService(Duration.ofMillis(1000)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Runtime.builder() + .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID)) + .withFeatureSupplier(new StubFeatureSupplier(feature)) + .withAdditionalPlugins(timeService, new UsageFormatter(out)) + .withRuntimeOptions(new RuntimeOptionsBuilder().setMonochrome().build()) + .withBackendSupplier(new StubBackendSupplier( + new StubStepDefinition("first step", oneReference()))) + .build() + .run(); + + String featureFile = new File("").toURI() + "path/test.feature"; + String expected = "" + + "{" + + " \"stepDefinitions\": [\n" + + " {\n" + + " \"expression\": \"first step\",\n" + + " \"location\": \"io.cucumber.core.plugin.PrettyFormatterStepDefinition.one()\",\n" + + " \"duration\": {\n" + + " \"sum\": 1.000000000,\n" + + " \"average\": 1.000000000,\n" + + " \"median\": 1.000000000,\n" + + " \"min\": 1.000000000,\n" + + " \"max\": 1.000000000\n" + + " },\n" + + " \"steps\": [\n" + + " {\n" + + " \"text\": \"first step\",\n" + + " \"duration\": 1.000000000,\n" + + " \"location\": \"path/test.feature:2\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + assertJsonEquals(expected.replaceAll("path/test.feature", featureFile), out); } @Test - @Disabled("TODO") - void doneWithoutUsageStatisticStrategies() throws JSONException { - OutputStream out = new ByteArrayOutputStream(); - UsageFormatter usageFormatter = new UsageFormatter(out); - UsageFormatter.StepContainer stepContainer = new UsageFormatter.StepContainer("a step"); - UsageFormatter.StepDuration stepDuration = new UsageFormatter.StepDuration(Duration.ofNanos(1234567800L), - "location.feature"); - stepContainer.getDurations().addAll(singletonList(stepDuration)); - usageFormatter.usageMap.put("a (.*)", singletonList(stepContainer)); - - usageFormatter.finishReport(); - - String json = "" + - "[\n" + - " {\n" + - " \"source\": \"a (.*)\",\n" + - " \"steps\": [\n" + - " {\n" + - " \"name\": \"a step\",\n" + - " \"aggregatedDurations\": {\n" + - " \"median\": 1.2345678,\n" + - " \"average\": 1.2345678\n" + + void writes_usage_with_median() throws JSONException { + Feature feature = TestFeatureParser.parse("path/test.feature", "" + + "Feature: feature name\n" + + " Scenario: scenario 1\n" + + " Given first step\n" + + " Scenario: scenario 2\n" + + " Given first step\n" + + " Scenario: scenario 3\n" + + " Given first step\n"); + + StepDurationTimeService timeService = new StepDurationTimeService( + Duration.ofMillis(1000), + Duration.ofMillis(2000), + Duration.ofMillis(4000)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Runtime.builder() + .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID)) + .withFeatureSupplier(new StubFeatureSupplier(feature)) + .withAdditionalPlugins(timeService, new UsageFormatter(out)) + .withRuntimeOptions(new RuntimeOptionsBuilder().setMonochrome().build()) + .withBackendSupplier(new StubBackendSupplier( + new StubStepDefinition("first step", oneReference()))) + .build() + .run(); + + String featureFile = new File("").toURI() + "path/test.feature"; + String expected = "" + + "{\n" + + " \"stepDefinitions\": [\n" + + " {\n" + + " \"expression\": \"first step\",\n" + + " \"location\": \"io.cucumber.core.plugin.PrettyFormatterStepDefinition.one()\",\n" + + " \"duration\": {\n" + + " \"sum\": 7.000000000,\n" + + " \"average\": 2.333333333,\n" + + " \"median\": 2.000000000,\n" + + " \"min\": 1.000000000,\n" + + " \"max\": 4.000000000\n" + + " },\n" + + " \"steps\": [\n" + + " {\n" + + " \"text\": \"first step\",\n" + + " \"duration\": 1.000000000,\n" + + " \"location\": \"path/test.feature:2\"\n" + " },\n" + - " \"durations\": [\n" + - " {\n" + - " \"duration\": 1.2345678,\n" + - " \"location\": \"location.feature\"\n" + - " }\n" + - " ]\n" + - " }\n" + - " ]\n" + - " }\n" + - "]"; - - assertEquals(json, out.toString(), true); - } - - @Test - @Disabled("TODO") - void doneWithUsageStatisticStrategies() throws JSONException { - OutputStream out = new ByteArrayOutputStream(); - UsageFormatter usageFormatter = new UsageFormatter(out); - - UsageFormatter.StepContainer stepContainer = new UsageFormatter.StepContainer("a step"); - UsageFormatter.StepDuration stepDuration = new UsageFormatter.StepDuration(Duration.ofNanos(12345678L), - "location.feature"); - stepContainer.getDurations().addAll(singletonList(stepDuration)); - - usageFormatter.usageMap.put("a (.*)", singletonList(stepContainer)); - - usageFormatter.finishReport(); - - assertThat(out.toString(), containsString("0.012345678")); - String json = "[\n" + - " {\n" + - " \"source\": \"a (.*)\",\n" + - " \"steps\": [\n" + - " {\n" + - " \"name\": \"a step\",\n" + - " \"aggregatedDurations\": {\n" + - " \"median\": 0.012345678,\n" + - " \"average\": 0.012345678\n" + + " {\n" + + " \"text\": \"first step\",\n" + + " \"duration\": 2.000000000,\n" + + " \"location\": \"path/test.feature:4\"\n" + " },\n" + - " \"durations\": [\n" + - " {\n" + - " \"duration\": 0.012345678,\n" + - " \"location\": \"location.feature\"\n" + - " }\n" + - " ]\n" + - " }\n" + - " ]\n" + - " }\n" + - "]"; - - assertEquals(json, out.toString(), true); + " {\n" + + " \"text\": \"first step\",\n" + + " \"duration\": 4.000000000,\n" + + " \"location\": \"path/test.feature:6\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + assertJsonEquals(expected.replaceAll("path/test.feature", featureFile), out); } @Test - void calculateAverageFromList() { - OutputStream out = new ByteArrayOutputStream(); - UsageFormatter usageFormatter = new UsageFormatter(out); - Double result = usageFormatter - .calculateAverage(asList(1.0, 2.0, 3.0)); - assertThat(result, is(closeTo(2.0, EPSILON))); - } - - @Test - void calculateAverageOf() { - OutputStream out = new ByteArrayOutputStream(); - UsageFormatter usageFormatter = new UsageFormatter(out); - Double result = usageFormatter.calculateAverage(asList(1.0, 1.0, 2.0)); - assertThat(result, is(closeTo(1.33, 0.01))); - } - - @Test - void calculateAverageOfEmptylist() { - OutputStream out = new ByteArrayOutputStream(); - UsageFormatter usageFormatter = new UsageFormatter(out); - Double result = usageFormatter.calculateAverage(Collections.emptyList()); - assertThat(result, is(equalTo(0.0))); - } - - @Test - void calculateMedianOfOddNumberOfEntries() { - OutputStream out = new ByteArrayOutputStream(); - UsageFormatter usageFormatter = new UsageFormatter(out); - Double result = usageFormatter - .calculateMedian(asList(1.0, 2.0, 3.0)); - assertThat(result, is(closeTo(2.0, EPSILON))); - } - - @Test - void calculateMedianOfEvenNumberOfEntries() { - OutputStream out = new ByteArrayOutputStream(); - UsageFormatter usageFormatter = new UsageFormatter(out); - Double result = usageFormatter.calculateMedian( - asList(1.0, 3.0, 10.0, 5.0)); - assertThat(result, is(closeTo(4.0, EPSILON))); - } - - @Test - void calculateMedianOf() { - OutputStream out = new ByteArrayOutputStream(); - UsageFormatter usageFormatter = new UsageFormatter(out); - Double result = usageFormatter.calculateMedian(asList(2.0, 9.0)); - assertThat(result, is(closeTo(5.5, EPSILON))); + void writes_usage_with_median_of_two() throws JSONException { + Feature feature = TestFeatureParser.parse("path/test.feature", "" + + "Feature: feature name\n" + + " Scenario: scenario 1\n" + + " Given first step\n" + + " Scenario: scenario 2\n" + + " Given first step\n"); + + StepDurationTimeService timeService = new StepDurationTimeService( + Duration.ofMillis(2000), + Duration.ofMillis(3000)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Runtime.builder() + .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID)) + .withFeatureSupplier(new StubFeatureSupplier(feature)) + .withAdditionalPlugins(timeService, new UsageFormatter(out)) + .withRuntimeOptions(new RuntimeOptionsBuilder().setMonochrome().build()) + .withBackendSupplier(new StubBackendSupplier( + new StubStepDefinition("first step", oneReference()))) + .build() + .run(); + + String featureFile = new File("").toURI() + "path/test.feature"; + String expected = "" + + "{\n" + + " \"stepDefinitions\": [\n" + + " {\n" + + " \"expression\": \"first step\",\n" + + " \"location\": \"io.cucumber.core.plugin.PrettyFormatterStepDefinition.one()\",\n" + + " \"duration\": {\n" + + " \"sum\": 5.000000000,\n" + + " \"average\": 2.500000000,\n" + + " \"median\": 2.500000000,\n" + + " \"min\": 2.000000000,\n" + + " \"max\": 3.000000000\n" + + " },\n" + + " \"steps\": [\n" + + " {\n" + + " \"text\": \"first step\",\n" + + " \"duration\": 2.000000000,\n" + + " \"location\": \"path/test.feature:2\"\n" + + " },\n" + + " {\n" + + " \"text\": \"first step\",\n" + + " \"duration\": 3.000000000,\n" + + " \"location\": \"path/test.feature:4\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + assertJsonEquals(expected.replaceAll("path/test.feature", featureFile), out); } - @Test - void calculateMedianOfEmptyList() { - OutputStream out = new ByteArrayOutputStream(); - UsageFormatter usageFormatter = new UsageFormatter(out); - Double result = usageFormatter.calculateMedian(Collections.emptyList()); - assertThat(result, is(equalTo(0.0))); + private void assertJsonEquals(String expected, ByteArrayOutputStream actual) throws JSONException { + assertEquals(expected, new String(actual.toByteArray(), UTF_8), true); } } diff --git a/cucumber-core/src/test/java/io/cucumber/core/runner/StepDurationTimeService.java b/cucumber-core/src/test/java/io/cucumber/core/runner/StepDurationTimeService.java index 90eff51c32..289554d536 100644 --- a/cucumber-core/src/test/java/io/cucumber/core/runner/StepDurationTimeService.java +++ b/cucumber-core/src/test/java/io/cucumber/core/runner/StepDurationTimeService.java @@ -9,16 +9,19 @@ import java.time.Duration; import java.time.Instant; import java.time.ZoneId; +import java.util.Arrays; +import java.util.List; public class StepDurationTimeService extends Clock implements ConcurrentEventListener { private final ThreadLocal currentInstant = new ThreadLocal<>(); - private final Duration stepDuration; + private final List stepDuration; + private int currentStepDurationIndex; private final EventHandler stepStartedHandler = event -> handleTestStepStarted(); - public StepDurationTimeService(Duration stepDuration) { - this.stepDuration = stepDuration; + public StepDurationTimeService(Duration... stepDuration) { + this.stepDuration = Arrays.asList(stepDuration); } @Override @@ -28,7 +31,8 @@ public void setEventPublisher(EventPublisher publisher) { private void handleTestStepStarted() { Instant timeInstant = instant(); - currentInstant.set(timeInstant.plus(stepDuration)); + currentInstant.set(timeInstant.plus(stepDuration.get(currentStepDurationIndex))); + currentStepDurationIndex = (currentStepDurationIndex + 1) % stepDuration.size(); } @Override