Skip to content

Commit

Permalink
Revise stacktrace pruning (#3364)
Browse files Browse the repository at this point in the history
Resolves #3299.
  • Loading branch information
juliette-derancourt authored Jun 28, 2023
1 parent 649c9a8 commit 309a696
Show file tree
Hide file tree
Showing 13 changed files with 224 additions and 162 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ JUnit repository on GitHub.
* New `selectMethod()` and `selectNestedMethod()` variants in `DiscoverySelectors` that
accept a `Class<?>...` argument of parameter types as a type-safe alternative to
providing the names of parameter types as a comma-delimited string.

* Stack trace pruning has been revised and now only removes calls from the `org.junit`,
`jdk.internal.reflect`, and `sun.reflect` packages. Please refer to the
<<../user-guide/index.adoc#stacktrace-pruning, User Guide>> for details.
* New `getAncestors()` method in `TestDescriptor`.

[[release-notes-5.10.0-RC1-junit-jupiter]]
=== JUnit Jupiter
Expand Down
19 changes: 7 additions & 12 deletions documentation/src/docs/asciidoc/user-guide/running-tests.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1138,18 +1138,13 @@ Since version 1.10, the JUnit Platform provides built-in support for pruning sta
produced by failing tests. This feature can be enabled or disabled via the
`junit.platform.stacktrace.pruning.enabled` _configuration parameter_.

By default, all calls from the `org.junit`, `java`, and `jdk` packages are removed from the
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 call from the JUnit Platform Launcher will be removed.

NOTE: Since they provide necessary insights to understand a test failure, calls to
`{Assertions}` or `{Assumptions}` will never be excluded from stack traces even though
they are part of the `org.junit` package.
If enabled, all calls from the `org.junit`, `jdk.internal.reflect`, and `sun.reflect`
packages are removed from the stack trace, unless they are subsequent to the test itself
or any of its ancestors. For that reason, calls to `{Assertions}` or `{Assumptions}` will
never be excluded.

In addition, all elements prior to and including the first call from the JUnit Platform
Launcher will be removed.

[[stacktrace-pruning-pattern]]
==== Pattern Matching Syntax
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ 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 @@ -42,6 +42,9 @@ public final class ExceptionUtils {

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

private static final Predicate<String> STACK_TRACE_ELEMENT_FILTER = ClassNamePatternFilterUtils //
.excludeMatchingClassNames("org.junit.*,jdk.internal.reflect.*,sun.reflect.*");

private ExceptionUtils() {
/* no-op */
}
Expand Down Expand Up @@ -92,38 +95,44 @@ public static String readStackTrace(Throwable throwable) {
}

/**
* 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.
* Prune the stack trace of the supplied {@link Throwable} by removing
* elements from the {@code org.junit}, {@code jdk.internal.reflect} and
* {@code sun.reflect} packages. If an element matching one of the supplied
* class names is encountered, all following elements will be kept regardless.
*
* <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}
* @param testClassNames the test class names that should stop the pruning
* if encountered; never {@code null}
*
* @since 5.10
*/
@API(status = INTERNAL, since = "5.10")
public static void pruneStackTrace(Throwable throwable, Predicate<String> stackTraceElementFilter) {
public static void pruneStackTrace(Throwable throwable, List<String> testClassNames) {
Preconditions.notNull(throwable, "Throwable must not be null");
Preconditions.notNull(stackTraceElementFilter, "Predicate must not be null");
Preconditions.notNull(testClassNames, "List of test class names must not be null");

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

Collections.reverse(stackTrace);

for (StackTraceElement element : stackTrace) {
for (int i = 0; i < stackTrace.size(); i++) {
StackTraceElement element = stackTrace.get(i);
String className = element.getClassName();
if (className.startsWith(JUNIT_PLATFORM_LAUNCHER_PACKAGE_PREFIX)) {

if (testClassNames.contains(className)) {
// Include all elements called by the test
prunedStackTrace.addAll(stackTrace.subList(i, stackTrace.size()));
break;
}
else if (className.startsWith(JUNIT_PLATFORM_LAUNCHER_PACKAGE_PREFIX)) {
prunedStackTrace.clear();
}
else if (stackTraceElementFilter.test(className)) {
else if (STACK_TRACE_ELEMENT_FILTER.test(className)) {
prunedStackTrace.add(element);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,26 @@ default String getLegacyReportingName() {
*/
Set<? extends TestDescriptor> getChildren();

/**
* Get the immutable set of all <em>ancestors</em> of this descriptor.
*
* <p>An <em>ancestors</em> is the parent of this descriptor or the parent
* of one of its parents, recursively.
*
* @see #getParent()
*/
@API(status = STABLE, since = "5.10")

This comment has been minimized.

Copy link
@sbrannen

sbrannen Jun 29, 2023

Member

1.10 😉

This comment has been minimized.

Copy link
@juliette-derancourt

juliette-derancourt Jun 29, 2023

Author Member

Oopsie, thanks! 🙏

default Set<? extends TestDescriptor> getAncestors() {
if (!getParent().isPresent()) {
return Collections.emptySet();
}
TestDescriptor parent = getParent().get();
Set<TestDescriptor> ancestors = new LinkedHashSet<>();
ancestors.add(parent);
ancestors.addAll(parent.getAncestors());
return Collections.unmodifiableSet(ancestors);
}

/**
* Get the immutable set of all <em>descendants</em> of this descriptor.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,50 +174,6 @@ public class LauncherConstants {
@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";

/**
* Default pattern for stack trace pruning which matches the
* {@code org.junit}, {@code java}, and {@code jdk} base packages as well
* as any of their subpackages.
*
* @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,9 +12,7 @@

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 @@ -179,9 +177,7 @@ private static EngineExecutionListener selectExecutionListener(EngineExecutionLi
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 new StackTracePruningEngineExecutionListener(engineExecutionListener);
}
return engineExecutionListener;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,49 +10,61 @@

package org.junit.platform.launcher.core;

import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;

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;
import org.junit.platform.engine.support.descriptor.ClassSource;
import org.junit.platform.engine.support.descriptor.MethodSource;

/**
* Prunes the stack trace in case of a failed event.
*
* @since 1.10
* @see org.junit.platform.commons.util.ExceptionUtils#pruneStackTrace(Throwable, Predicate)
* @see org.junit.platform.commons.util.ExceptionUtils#pruneStackTrace(Throwable, List)
*/
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) {
StackTracePruningEngineExecutionListener(EngineExecutionListener delegate) {
super(delegate);
this.stackTraceElementFilter = ClassNamePatternFilterUtils.excludeMatchingClassNames(pruningPattern) //
.or(ALWAYS_INCLUDED_STACK_TRACE_ELEMENTS::contains);
}

@Override
public void executionFinished(TestDescriptor testDescriptor, TestExecutionResult testExecutionResult) {
List<String> testClassNames = getTestClassNames(testDescriptor);
if (testExecutionResult.getThrowable().isPresent()) {
Throwable throwable = testExecutionResult.getThrowable().get();

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

private void pruneStackTrace(Throwable throwable) {
ExceptionUtils.pruneStackTrace(throwable, stackTraceElementFilter);
private static List<String> getTestClassNames(TestDescriptor testDescriptor) {
return testDescriptor.getAncestors() //
.stream() //
.map(TestDescriptor::getSource) //
.filter(Optional::isPresent) //
.map(Optional::get) //
.map(source -> {
if (source instanceof ClassSource) {
return ((ClassSource) source).getClassName();
}
else if (source instanceof MethodSource) {
return ((MethodSource) source).getClassName();
}
else {
return null;
}
}) //
.filter(Objects::nonNull) //
.collect(Collectors.toList());
}

}
Loading

0 comments on commit 309a696

Please sign in to comment.