Skip to content

Commit

Permalink
Introduce @⁠SentenceFragment for IndicativeSentences DisplayNameGener…
Browse files Browse the repository at this point in the history
…ator

Closes junit-team#4347
  • Loading branch information
sbrannen committed Mar 2, 2025
1 parent 3d0b991 commit 6c0e661
Show file tree
Hide file tree
Showing 3 changed files with 162 additions and 12 deletions.
19 changes: 19 additions & 0 deletions documentation/src/test/java/example/DisplayNameGeneratorDemo.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.junit.jupiter.api.DisplayNameGenerator.IndicativeSentences.SentenceFragment;
import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores;
import org.junit.jupiter.api.IndicativeSentencesGeneration;
import org.junit.jupiter.api.Nested;
Expand Down Expand Up @@ -55,5 +56,23 @@ void if_it_is_one_of_the_following_years(int year) {

}

@Nested
@IndicativeSentencesGeneration
@SentenceFragment("A year is a leap year")
class LeapYearTests {

@Test
@SentenceFragment("if it is divisible by 4 but not by 100")
void divisibleBy4ButNotBy100() {
}

@ParameterizedTest(name = "{0}")
@ValueSource(ints = { 2016, 2020, 2048 })
@SentenceFragment("if it is one of the following years")
void validLeapYear(int year) {
}

}

}
// end::user_guide[]
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,23 @@
import static org.junit.platform.commons.support.AnnotationSupport.findAnnotation;
import static org.junit.platform.commons.support.ModifierSupport.isStatic;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;

import org.apiguardian.api.API;
import org.junit.platform.commons.logging.Logger;
import org.junit.platform.commons.logging.LoggerFactory;
import org.junit.platform.commons.support.ReflectionSupport;
import org.junit.platform.commons.util.ClassUtils;
import org.junit.platform.commons.util.Preconditions;
import org.junit.platform.commons.util.StringUtils;

/**
* {@code DisplayNameGenerator} defines the SPI for generating display names
Expand Down Expand Up @@ -303,21 +311,54 @@ private static String replaceUnderscores(String name) {
* via the {@link IndicativeSentencesGeneration @IndicativeSentencesGeneration}
* annotation.
*
* <p>If you do not want to rely on a display name generator for individual
* sentence fragments, you can supply custom text for individual fragments
* via the {@link SentenceFragment @SentenceFragment} annotation.
*
* @since 5.7
*/
@API(status = STABLE, since = "5.10")
class IndicativeSentences implements DisplayNameGenerator {

/**
* {@code @SentenceFragment} is used to configure a custom sentence fragment
* for a sentence generated by the {@link IndicativeSentences}
* {@code DisplayNameGenerator}.
*
* <p>Note that {@link DisplayName @DisplayName} always takes precedence
* over {@code @SentenceFragment}.
*
* @since 5.13
*/
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@API(status = EXPERIMENTAL, since = "5.13")
public @interface SentenceFragment {

/**
* Custom sentence fragment for the annotated class or method.
*
* @return a custom sentence fragment; never blank or consisting solely
* of whitespace
*/
String value();

}

static final DisplayNameGenerator INSTANCE = new IndicativeSentences();

private static final Logger logger = LoggerFactory.getLogger(IndicativeSentences.class);

private static final Predicate<Class<?>> notIndicativeSentences = clazz -> clazz != IndicativeSentences.class;

public IndicativeSentences() {
}

@Override
public String generateDisplayNameForClass(Class<?> testClass) {
return getGeneratorFor(testClass, emptyList()).generateDisplayNameForClass(testClass);
String sentenceFragment = getSentenceFragment(testClass);
return (sentenceFragment != null ? sentenceFragment
: getGeneratorFor(testClass, emptyList()).generateDisplayNameForClass(testClass));
}

@Override
Expand All @@ -328,22 +369,30 @@ public String generateDisplayNameForNestedClass(List<Class<?>> enclosingInstance
@Override
public String generateDisplayNameForMethod(List<Class<?>> enclosingInstanceTypes, Class<?> testClass,
Method testMethod) {
return getSentenceBeginning(testClass, enclosingInstanceTypes)
+ getFragmentSeparator(testClass, enclosingInstanceTypes)
+ getGeneratorFor(testClass, enclosingInstanceTypes).generateDisplayNameForMethod(
enclosingInstanceTypes, testClass, testMethod);

String displayName = getSentenceBeginning(testClass, enclosingInstanceTypes)
+ getFragmentSeparator(testClass, enclosingInstanceTypes);

String sentenceFragment = getSentenceFragment(testMethod);
displayName += (sentenceFragment != null ? sentenceFragment
: getGeneratorFor(testClass, enclosingInstanceTypes).generateDisplayNameForMethod(
enclosingInstanceTypes, testClass, testMethod));
return displayName;
}

private String getSentenceBeginning(Class<?> testClass, List<Class<?>> enclosingInstanceTypes) {
Class<?> enclosingClass = enclosingInstanceTypes.isEmpty() ? null
: enclosingInstanceTypes.get(enclosingInstanceTypes.size() - 1);
boolean topLevelTestClass = (enclosingClass == null || isStatic(testClass));
Optional<String> displayName = findAnnotation(testClass, DisplayName.class)//
.map(DisplayName::value).map(String::trim);

String sentenceFragment = findAnnotation(testClass, DisplayName.class)//
.map(DisplayName::value)//
.map(String::trim)//
.orElseGet(() -> getSentenceFragment(testClass));

if (topLevelTestClass) {
if (displayName.isPresent()) {
return displayName.get();
if (sentenceFragment != null) {
return sentenceFragment;
}
Class<? extends DisplayNameGenerator> generatorClass = findDisplayNameGeneration(testClass,
enclosingInstanceTypes)//
Expand Down Expand Up @@ -371,9 +420,9 @@ private String getSentenceBeginning(Class<?> testClass, List<Class<?>> enclosing
+ getFragmentSeparator(testClass, enclosingInstanceTypes)
: "");

return prefix + displayName.orElseGet(
() -> getGeneratorFor(testClass, enclosingInstanceTypes).generateDisplayNameForNestedClass(
remainingEnclosingInstanceTypes, testClass));
return prefix + (sentenceFragment != null ? sentenceFragment
: getGeneratorFor(testClass, enclosingInstanceTypes).generateDisplayNameForNestedClass(
remainingEnclosingInstanceTypes, testClass));
}

/**
Expand Down Expand Up @@ -449,6 +498,25 @@ private static Optional<IndicativeSentencesGeneration> findIndicativeSentencesGe
return findAnnotation(testClass, IndicativeSentencesGeneration.class, enclosingInstanceTypes);
}

private static String getSentenceFragment(AnnotatedElement element) {
Optional<SentenceFragment> annotation = findAnnotation(element, SentenceFragment.class);
if (annotation.isPresent()) {
String sentenceFragment = annotation.get().value().trim();

// TODO [#242] Replace logging with precondition check once we have a proper mechanism for
// handling validation exceptions during the TestEngine discovery phase.
if (StringUtils.isBlank(sentenceFragment)) {
logger.warn(() -> String.format(
"Configuration error: @SentenceFragment on [%s] must be declared with a non-blank value.",
element));
}
else {
return sentenceFragment;
}
}
return null;
}

}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.util.Stack;
import java.util.stream.Stream;

import org.junit.jupiter.api.DisplayNameGenerator.IndicativeSentences.SentenceFragment;
import org.junit.jupiter.api.extension.ContainerTemplateInvocationContext;
import org.junit.jupiter.api.extension.ContainerTemplateInvocationContextProvider;
import org.junit.jupiter.api.extension.ExtendWith;
Expand Down Expand Up @@ -175,6 +176,18 @@ void checkDisplayNameGeneratedForIndicativeGeneratorWithCustomSeparator() {
);
}

@Test
void checkDisplayNameGeneratedForIndicativeGeneratorWithCustomSentenceFragments() {
check(IndicativeGeneratorWithCustomSentenceFragmentsTestCase.class, //
"CONTAINER: A stack", //
"TEST: A stack, is instantiated with its constructor", //
"CONTAINER: A stack, when new", //
"TEST: A stack, when new, throws EmptyStackException when peeked", //
"CONTAINER: A stack, when new, after pushing an element to an empty stack", //
"TEST: A stack, when new, after pushing an element to an empty stack, is no longer empty" //
);
}

@Test
void displayNameGenerationInheritance() {
check(DisplayNameGenerationInheritanceTestCase.InnerNestedTestCase.class, //
Expand Down Expand Up @@ -504,6 +517,56 @@ void is_no_longer_empty() {

// -------------------------------------------------------------------------

@SuppressWarnings("JUnitMalformedDeclaration")
@SentenceFragment("A stack")
@IndicativeSentencesGeneration
static class IndicativeGeneratorWithCustomSentenceFragmentsTestCase {

Stack<Object> stack;

@Test
@SentenceFragment("is instantiated with its constructor")
void instantiateViaConstructor() {
new Stack<>();
}

@Nested
@SentenceFragment("when new")
class NewStackTestCase {

@BeforeEach
void createNewStack() {
stack = new Stack<>();
}

@Test
@SentenceFragment("throws EmptyStackException when peeked")
void throwsExceptionWhenPeeked() {
assertThrows(EmptyStackException.class, () -> stack.peek());
}

@Nested
@SentenceFragment("after pushing an element to an empty stack")
class ElementPushedOntoStackTestCase {

String anElement = "an element";

@BeforeEach
void pushElementOntoStack() {
stack.push(anElement);
}

@Test
@SentenceFragment("is no longer empty")
void nonEmptyStack() {
assertFalse(stack.isEmpty());
}
}
}
}

// -------------------------------------------------------------------------

@SuppressWarnings("JUnitMalformedDeclaration")
@ContainerTemplate
@ExtendWith(ContainerTemplateTestCase.Once.class)
Expand Down

0 comments on commit 6c0e661

Please sign in to comment.