Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prune stack traces produced by failing tests #3277

Merged
merged 14 commits into from
May 8, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ repository on GitHub.
test engines and their extensions.
* New dry-run mode to simulate test execution without actually running tests. Please refer
to the <<../user-guide/index.adoc#launcher-api-dry-run-mode, User Guide>> for details.
* Stack traces produced by failing tests are now pruned of calls from `org.junit`, `java`
and `jdk` by default. This feature can be disabled or configured to prune other calls
juliette-derancourt marked this conversation as resolved.
Show resolved Hide resolved
via configurations parameters. Please refer to the
<<../user-guide/index.adoc#stacktrace-pruning, User Guide>> for details.


[[release-notes-5.10.0-M1-junit-jupiter]]
Expand Down
26 changes: 26 additions & 0 deletions documentation/src/docs/asciidoc/user-guide/running-tests.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,7 @@ parameters_ used for the following features.

- <<extensions-conditions-deactivation>>
- <<launcher-api-listeners-custom-deactivation>>
- <<stacktrace-pruning>>

If the value for the given _configuration parameter_ consists solely of an asterisk
(`+++*+++`), the pattern will match against all candidate classes. Otherwise, the value
Expand Down Expand Up @@ -1094,3 +1095,28 @@ https://jdk.java.net/jmc/[JDK Mission Control].
WARNING: Flight Recorder support is currently an _experimental_ feature. You're invited to
give it a try and provide feedback to the JUnit team so they can improve and eventually
<<api-evolution, promote>> this feature.

[[stacktrace-pruning]]
=== Stack trace pruning

Since version 1.10, the JUnit Platform provides opt-out support for pruning stack traces
produced by failing tests. This feature can be enabled or disabled via the
`junit.platform.stacktrace.pruning.enabled` _configuration parameter_.
juliette-derancourt marked this conversation as resolved.
Show resolved Hide resolved

By default, all calls from `org.junit`, `java` and `jdk` packages are removed from the
juliette-derancourt marked this conversation as resolved.
Show resolved Hide resolved
stack trace. You can also configure the JUnit Platform to exclude different or additional
calls. To do this, provide a pattern for the `junit.platform.stacktrace.pruning.pattern`
_configuration parameter_ to specify which fully qualified class names should be excluded
from the stack traces.

In addition, and independently of the provided pattern, all elements prior to and
including the first JUnit Launcher call will be removed.
juliette-derancourt marked this conversation as resolved.
Show resolved Hide resolved

NOTE: Since they provide necessary insides to understand a test failure, calls to
juliette-derancourt marked this conversation as resolved.
Show resolved Hide resolved
`{Assertions}` or `{Assumptions}` will never be excluded from stack traces even though
they are part of the `org.junit` package.

[[stacktrace-pruning-pattern]]
==== Pattern Matching Syntax

Refer to <<running-tests-config-params-deactivation-pattern>> for details.
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ tasks.withType<Test>().configureEach {
server.set(uri("https://ge.junit.org"))
}
systemProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager")
systemProperty("junit.platform.stacktrace.pruning.enabled", false)
// Required until ASM officially supports the JDK 14
systemProperty("net.bytebuddy.experimental", true)
if (buildParameters.testing.enableJFR) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
Expand Down Expand Up @@ -50,21 +51,36 @@ private ClassNamePatternFilterUtils() {
* @param patterns a comma-separated list of patterns
*/
public static <T> Predicate<T> excludeMatchingClasses(String patterns) {
return excludeMatchingClasses(patterns, object -> object.getClass().getName());
}

/**
* Create a {@link Predicate} that can be used to exclude (i.e., filter out)
* fully qualified class names matching with any of the supplied patterns.
juliette-derancourt marked this conversation as resolved.
Show resolved Hide resolved
*
* @param patterns a comma-separated list of patterns
*/
public static Predicate<String> excludeMatchingClassNames(String patterns) {
return excludeMatchingClasses(patterns, Function.identity());
}

private static <T> Predicate<T> excludeMatchingClasses(String patterns, Function<T, String> classNameGetter) {
// @formatter:off
return Optional.ofNullable(patterns)
.filter(StringUtils::isNotBlank)
.map(String::trim)
.map(ClassNamePatternFilterUtils::<T>createPredicateFromPatterns)
.map(trimmedPatterns -> createPredicateFromPatterns(trimmedPatterns, classNameGetter))
.orElse(object -> true);
// @formatter:on
}

private static <T> Predicate<T> createPredicateFromPatterns(String patterns) {
private static <T> Predicate<T> createPredicateFromPatterns(String patterns,
Function<T, String> classNameProvider) {
if (DEACTIVATE_ALL_PATTERN.equals(patterns)) {
return object -> false;
}
List<Pattern> patternList = convertToRegularExpressions(patterns);
return object -> patternList.stream().noneMatch(it -> it.matcher(object.getClass().getName()).matches());
return object -> patternList.stream().noneMatch(it -> it.matcher(classNameProvider.apply(object)).matches());
}

private static List<Pattern> convertToRegularExpressions(String patterns) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@

import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Deque;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;

import org.apiguardian.api.API;

Expand All @@ -31,6 +40,8 @@
@API(status = INTERNAL, since = "1.0")
public final class ExceptionUtils {

private static final String JUNIT_PLATFORM_LAUNCHER_PACKAGE_PREFIX = "org.junit.platform.launcher.";

private ExceptionUtils() {
/* no-op */
}
Expand Down Expand Up @@ -80,4 +91,78 @@ public static String readStackTrace(Throwable throwable) {
return stringWriter.toString();
}

/**
* Prune the stack trace of the supplied {@link Throwable} by filtering its
* elements using the supplied {@link Predicate}, except for
* {@code org.junit.jupiter.api.Assertions} and
* {@code org.junit.jupiter.api.Assumptions} that will always remain
* present.
*
* <p>Additionally, all elements prior to and including the first
* JUnit Launcher call will be removed.
*
* @param throwable the {@code Throwable} whose stack trace should be
* pruned; never {@code null}
* @param stackTraceElementFilter the {@code Predicate} used to filter
* elements of the stack trace; never {@code null}
*
* @since 5.10
*/
@API(status = INTERNAL, since = "5.10")
public static void pruneStackTrace(Throwable throwable, Predicate<String> stackTraceElementFilter) {
juliette-derancourt marked this conversation as resolved.
Show resolved Hide resolved
Preconditions.notNull(throwable, "Throwable must not be null");
Preconditions.notNull(stackTraceElementFilter, "Predicate must not be null");

List<StackTraceElement> stackTrace = Arrays.asList(throwable.getStackTrace());
List<StackTraceElement> prunedStackTrace = new ArrayList<>();

Collections.reverse(stackTrace);

for (StackTraceElement element : stackTrace) {
String className = element.getClassName();
if (className.startsWith(JUNIT_PLATFORM_LAUNCHER_PACKAGE_PREFIX)) {
prunedStackTrace.clear();
}
else if (stackTraceElementFilter.test(className)) {
prunedStackTrace.add(element);
}
}

Collections.reverse(prunedStackTrace);
throwable.setStackTrace(prunedStackTrace.toArray(new StackTraceElement[0]));
}

/**
* Find all causes and suppressed exceptions in the backtrace of the
* supplied {@link Throwable}.
*
* @param rootThrowable the {@code Throwable} to explore; never {@code null}
* @return an immutable list of all throwables found, including the supplied
* one; never {@code null}
*
* @since 5.10
*/
@API(status = INTERNAL, since = "5.10")
public static List<Throwable> findNestedThrowables(Throwable rootThrowable) {
juliette-derancourt marked this conversation as resolved.
Show resolved Hide resolved
Preconditions.notNull(rootThrowable, "Throwable must not be null");

Set<Throwable> visited = new LinkedHashSet<>();
Deque<Throwable> toVisit = new ArrayDeque<>();
toVisit.add(rootThrowable);

while (!toVisit.isEmpty()) {
Throwable current = toVisit.remove();
boolean isFirstVisit = visited.add(current);
if (isFirstVisit) {
Throwable cause = current.getCause();
if (cause != null) {
toVisit.add(cause);
}
toVisit.addAll(Arrays.asList(current.getSuppressed()));
}
}

return Collections.unmodifiableList(new ArrayList<>(visited));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,59 @@ public class LauncherConstants {
@API(status = EXPERIMENTAL, since = "1.10")
public static final String DRY_RUN_PROPERTY_NAME = "junit.platform.execution.dryRun.enabled";

/**
* Property name used to enable or disable stack trace pruning.
*
* <p>By default, stack trace pruning is enabled.
*
* @see org.junit.platform.launcher.core.EngineExecutionOrchestrator
*/
@API(status = EXPERIMENTAL, since = "1.10")
public static final String STACKTRACE_PRUNING_ENABLED_PROPERTY_NAME = "junit.platform.stacktrace.pruning.enabled";

/**
* Property name used to provide patterns to remove elements from stack traces.
*
* <h4>Pattern Matching Syntax</h4>
*
* <p>If the property value consists solely of an asterisk ({@code *}), all
* elements will be removed. Otherwise, the property value will be treated
* as a comma-separated list of patterns where each individual pattern will be
* matched against the fully qualified class name (<em>FQCN</em>) of the stack trace
* element. Any dot ({@code .}) in a pattern will match against a dot ({@code .})
* or a dollar sign ({@code $}) in a FQCN. Any asterisk ({@code *}) will match
* against one or more characters in a FQCN. All other characters in a pattern
* will be matched one-to-one against a FQCN.
*
* <h4>Examples</h4>
*
* <ul>
* <li>{@code *}: remove all elements.
* <li>{@code org.junit.*}: remove every element with the {@code org.junit}
* base package and any of its subpackages.
* <li>{@code *.MyClass}: remove every element whose simple class name is
* exactly {@code MyClass}.
* <li>{@code *System*, *Dev*}: exclude every element whose FQCN contains
* {@code System} or {@code Dev}.
* <li>{@code org.example.MyClass, org.example.TheirClass}: remove
* elements whose FQCN is exactly {@code org.example.MyClass} or
* {@code org.example.TheirClass}.
* </ul>
*
* @see #STACKTRACE_PRUNING_DEFAULT_PATTERN
*/
@API(status = EXPERIMENTAL, since = "1.10")
public static final String STACKTRACE_PRUNING_PATTERN_PROPERTY_NAME = "junit.platform.stacktrace.pruning.pattern";
marcphilipp marked this conversation as resolved.
Show resolved Hide resolved

/**
* Default pattern for stack trace pruning which matches {@code org.junit},
* {@code java} and {@code jdk} base packages, and any of their subpackages.
juliette-derancourt marked this conversation as resolved.
Show resolved Hide resolved
*
* @see #STACKTRACE_PRUNING_PATTERN_PROPERTY_NAME
*/
@API(status = EXPERIMENTAL, since = "1.10")
public static final String STACKTRACE_PRUNING_DEFAULT_PATTERN = "org.junit.*,java.*,jdk.*";

private LauncherConstants() {
/* no-op */
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

import static org.apiguardian.api.API.Status.INTERNAL;
import static org.junit.platform.launcher.LauncherConstants.DRY_RUN_PROPERTY_NAME;
import static org.junit.platform.launcher.LauncherConstants.STACKTRACE_PRUNING_DEFAULT_PATTERN;
import static org.junit.platform.launcher.LauncherConstants.STACKTRACE_PRUNING_ENABLED_PROPERTY_NAME;
import static org.junit.platform.launcher.LauncherConstants.STACKTRACE_PRUNING_PATTERN_PROPERTY_NAME;
import static org.junit.platform.launcher.core.ListenerRegistry.forEngineExecutionListeners;

import java.util.Optional;
Expand Down Expand Up @@ -155,20 +158,34 @@ public void execute(LauncherDiscoveryResult discoveryResult, EngineExecutionList
Preconditions.notNull(discoveryResult, "discoveryResult must not be null");
Preconditions.notNull(engineExecutionListener, "engineExecutionListener must not be null");

ConfigurationParameters configurationParameters = discoveryResult.getConfigurationParameters();
EngineExecutionListener listener = selectExecutionListener(engineExecutionListener, configurationParameters);

for (TestEngine testEngine : discoveryResult.getTestEngines()) {
TestDescriptor engineDescriptor = discoveryResult.getEngineTestDescriptor(testEngine);
if (engineDescriptor instanceof EngineDiscoveryErrorDescriptor) {
engineExecutionListener.executionStarted(engineDescriptor);
engineExecutionListener.executionFinished(engineDescriptor,
listener.executionStarted(engineDescriptor);
listener.executionFinished(engineDescriptor,
TestExecutionResult.failed(((EngineDiscoveryErrorDescriptor) engineDescriptor).getCause()));
}
else {
execute(engineDescriptor, engineExecutionListener, discoveryResult.getConfigurationParameters(),
testEngine);
execute(engineDescriptor, listener, configurationParameters, testEngine);
}
}
}

private static EngineExecutionListener selectExecutionListener(EngineExecutionListener engineExecutionListener,
ConfigurationParameters configurationParameters) {
boolean stackTracePruningEnabled = configurationParameters.getBoolean(STACKTRACE_PRUNING_ENABLED_PROPERTY_NAME) //
.orElse(true);
if (stackTracePruningEnabled) {
String pruningPattern = configurationParameters.get(STACKTRACE_PRUNING_PATTERN_PROPERTY_NAME) //
.orElse(STACKTRACE_PRUNING_DEFAULT_PATTERN);
return new StackTracePruningEngineExecutionListener(engineExecutionListener, pruningPattern);
}
return engineExecutionListener;
}

private ListenerRegistry<TestExecutionListener> buildListenerRegistryForExecution(
TestExecutionListener... listeners) {
if (listeners.length == 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright 2015-2023 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
*
* https://www.eclipse.org/legal/epl-v20.html
*/

package org.junit.platform.launcher.core;

import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;

import org.junit.platform.commons.util.ClassNamePatternFilterUtils;
import org.junit.platform.commons.util.ExceptionUtils;
import org.junit.platform.engine.EngineExecutionListener;
import org.junit.platform.engine.TestDescriptor;
import org.junit.platform.engine.TestExecutionResult;

/**
* Prunes the stack trace in case of a failed event.
*
* @since 1.10
* @see org.junit.platform.commons.util.ExceptionUtils#pruneStackTrace(Throwable, Predicate)
*/
class StackTracePruningEngineExecutionListener extends DelegatingEngineExecutionListener {

private static final List<String> ALWAYS_INCLUDED_STACK_TRACE_ELEMENTS = Arrays.asList( //
"org.junit.jupiter.api.Assertions", //
"org.junit.jupiter.api.Assumptions" //
);

private final Predicate<String> stackTraceElementFilter;

StackTracePruningEngineExecutionListener(EngineExecutionListener delegate, String pruningPattern) {
super(delegate);
this.stackTraceElementFilter = ClassNamePatternFilterUtils.excludeMatchingClassNames(pruningPattern) //
.or(ALWAYS_INCLUDED_STACK_TRACE_ELEMENTS::contains);
}

@Override
public void executionFinished(TestDescriptor testDescriptor, TestExecutionResult testExecutionResult) {
marcphilipp marked this conversation as resolved.
Show resolved Hide resolved
if (testExecutionResult.getThrowable().isPresent()) {
Throwable throwable = testExecutionResult.getThrowable().get();

ExceptionUtils.findNestedThrowables(throwable).forEach(this::pruneStackTrace);
}
super.executionFinished(testDescriptor, testExecutionResult);
}

private void pruneStackTrace(Throwable throwable) {
ExceptionUtils.pruneStackTrace(throwable, stackTraceElementFilter);
}

}
Loading