Better names for JUnit 5 Test

“All things are defined by names. Change the name, and you change the thing.”

Terry Pratchett

The JUnit 5 test library offers a wide range of options for extending the framework and thus simplifying the work of software developers. One of these options is the DisplayNameGenerator, which can be used to modify the generation of test names in the console output. When creating exercises for a course, I was surprised to find that there is a DisplayNameGenerator that replaces underscores with spaces, but none that handles the Camel Case form. Fortunately, this can be changed.

It is somewhat strange that only the Snake Case form is supported, as Camel Case has been the Java Convention for class, method and variable names since ever. Only constants are written in the Screaming Snake Case form. The only reason for the sporadic occurrence of underscores in method names is then often defended with better readability. With 99% Camel Case notation in the rest of the code and all the libraries used, this is a somewhat simple excuse.

The DisplayNameGenerator is used by the @DisplayNameGeneration annotation at class level. All tests within the class and its subclasses without a @DisplayName annotation then use this DisplayNameGenerator.

@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class SnakeCaseDisplayNameTest {

    @Test
    void generate_display_name_for_class() {
    }
}

The test in the example, with the non-Java-compliant name, generates the following output for its name.

generate display name for class

There are no further rules for the composition of the names, so the IndicativeSentences for test methods uses the class name and the method name, the ReplaceUnderscores and the Standard only use the method name. Our custom CamelCaseDisplayNameGenerator should also use class and method names for generation.

The custom DisplayNameGenerator must implement three methods. The first generates a name for a top level class, the second generates the name for a nested class and the third generates a name for a method.

Before the implementation begins, we write a few tests that our implementation should fulfil. Two of them are shown here as examples.

class CamelCaseDisplayNameGeneratorTest {

  private CamelCaseDisplayNameGenerator generator;

  @BeforeEach
  void setUp() {
    generator = new CamelCaseDisplayNameGenerator();
  }

  @Test
  void generateDisplayNameForClass() {
    Class<?> testClass = DummyCamelCaseTest.class;
    assertEquals("dummy camel case test", generator.generateDisplayNameForClass(testClass));
  }

  @Test
  void generateDisplayNameForNestedMethod() {
    Class<?> testClass = DummyNestedTest.class;
    Method testMethod = getMethod(testClass);
    assertEquals("dummy camel case test dummy nested test: dummy test method", generator.generateDisplayNameForMethod(testClass, testMethod));
  }
}

In the test generateDisplayNameForClass we use a dummy class DummyCamelCaseTest and compare the generated text with our expectations "dummy camel case test".

In the test generateDisplayNameForNestedMethod we use a dummy class DummyNestedTest and the method dummyTestMethod contained therein and compare the generated text with our expectations "dummy camel case test dummy nested test: dummy test method".

There are two things I don’t like about this test: the additional dummy classes and the omitted access to the dummy method by reflections. Both can be elegantly corrected with a JUnit 5 feature. All test methods can have a parameter of type TestInfo. This parameter has access to the current test class and test method.

class CamelCaseDisplayNameGeneratorTest {

    private CamelCaseDisplayNameGenerator generator;

    @BeforeEach
    void setUp() {
        generator = new CamelCaseDisplayNameGenerator();
    }

    @Test
    void generateDisplayNameForClass(TestInfo testInfo) {
        Class<?> testClass = testInfo.getTestClass().orElseThrow();
        assertEquals("camel case display name generator", generator.generateDisplayNameForClass(testClass));
    }

    @Nested
    class ForNestedClasses {
        @Test
        void generateDisplayNameForMethod(TestInfo testInfo) {
            Class<?> testClass = testInfo.getTestClass().orElseThrow();
            Method testMethod = testInfo.getTestMethod().orElseThrow();
            assertEquals("camel case display name generator for nested classes: generate display name for method with test info", generator.generateDisplayNameForMethod(testClass, testMethod));
        }
    }
}

The two tests with TestInfo parameters now test their own names. In fact, the tests could now be written even shorter, but we’ll wait until the end of this article. The only drawback with this solution is the fact that test methods without parameters cannot be tested in this way. Of course, because they all have one parameter.

The first method generateDisplayNameForClass of the CamelCaseDisplayNameGenerator is quickly implemented with a few code snippets from the FreshMarker library. The name of the class, without the unnecessary "Test" suffix, is passed to a regular expression that recognises Camel Case separators.

public class CamelCaseDisplayNameGenerator implements DisplayNameGenerator {
  private static final String LOWER_CASE_UPPER_CASES = "(\\p{javaLowerCase})(\\p{javaUpperCase}+)";

  private String transform(String input) {
    return input.replaceAll(LOWER_CASE_UPPER_CASES, "$1 $2").toLowerCase();
  }

  private String removeTestSuffix(String input) {
    return input.replaceFirst("Test$", "");
  }

  @Override
  public String generateDisplayNameForClass(Class<?> aClass) {
    return transform(removeTestSuffix(aClass.getSimpleName()));
  }
  // ...
}

The second method generateDisplayNameForNestedClass first collects all classes in the hierarchy and then concatenates their transformed names.

public class CamelCaseDisplayNameGenerator implements DisplayNameGenerator {
  //...
  private List<Class<?>> enclosingClasses(Class<?> aClass) {
    List<Class<?>> result = new ArrayList<>();
    Class<?> currentClass = aClass;
    result.add(currentClass);
    do {
      currentClass = currentClass.getEnclosingClass();
      result.add(currentClass);
    } while (currentClass.isMemberClass());
    return result.reversed();
  }

  @Override
  public String generateDisplayNameForNestedClass(Class<?> aClass) {
    return enclosingClasses(aClass).stream().map(Class::getSimpleName)
      .map(this::removeTestSuffix).map(this::transform)
      .collect(joining(" "));
  }
  //...
}

The third method generateDisplayNameForMethod first concatenates transformed class and method names and then appends the names of the parameters. The parameter list starts with the prefix " with " and with the help of the EnumeratedCollector from the stream-collector-utilities, the last parameter is appended with " and " if there is more than one parameter.

public class CamelCaseDisplayNameGenerator implements DisplayNameGenerator {
  //...
  @Override
  public String generateDisplayNameForMethod(Class<?> aClass, Method method) {
    String displayNameForClass = aClass.isMemberClass() ? generateDisplayNameForNestedClass(aClass) : generateDisplayNameForClass(aClass);
    String parameters = Stream.of(method.getParameters()).map(Parameter::getName).collect(EnumeratedCollector.enumerated(" and "));
    return displayNameForClass + ": " + transform(method.getName() + " with " + parameters);
  }
}

The CamelCaseDisplayNameGenerator is now implemented and can be used. If you’re still interested in the even more compact fest class at the end, then hopefully you like bootstrapping. The test class uses the CamelCaseDisplayNameGenerator on its own and another helpful method of the TestInfo class.

@DisplayNameGeneration(CamelCaseDisplayNameGenerator.class)
@ExtendWith(SampleExtension.class)
class CamelCaseDisplayNameGeneratorTest {

    @Nested
    class ForNestedClasses {
        @Test
        void generateDisplayNameForMethod(TestInfo testInfo) {
            assertEquals("camel case display name generator for nested classes: generate display name for method with test info", testInfo.getDisplayName());
        }

        @Test
        void generateDisplayNameForMethod(TestInfo testInfo, String value) {
            assertEquals("camel case display name generator for nested classes: generate display name for method with test info and value", testInfo.getDisplayName());
        }
    }

    @Test
    void generateDisplayNameForMethod(TestInfo testInfo) {
        assertEquals("camel case display name generator: generate display name for method with test info", testInfo.getDisplayName());
    }

    @Test
    void generateDisplayNameForMethod(TestInfo testInfo, String value) {
        assertEquals("camel case display name generator: generate display name for method with test info and value", testInfo.getDisplayName());
    }
}

The test methods check their name, which was passed to them by the TestInfo instance and previously generated by the CamelCaseDisplayNameGenerator.

Schreibe einen Kommentar