Hamcrest Matcher Generator revisited

“The biggest emotion in creation is the bridge to optimism.”

Brian May

When updating some libraries that use FreshMarker, I was a bit surprised. The Hamcrest Matcher Generator library was still using Freemarker and also the library has never been published on Maven Central. Two things that should be fixed as soon as possible.

Before we get to work, let’s take another look at the Hamcrest Matcher Generator. The library first appeared five years ago and two block posts described the functionality and implementation of the library. Hamcrest is a library that allows you to write better assertions for JUnit tests. Better in this context means that the assertions are easier to read for the software developer. Hamcrest offers a Fluid API with special Matcher implementations at its core. Hamcrest provides a variety of Matchers for standard types and collections. In order to make optimum use of the library, developers can writing Matchers for their own types.

Since this work is time-consuming and monotonous, the Hamcrest Matcher Generator library is used to automatically generate Hamcrest Matcher implementations.

import static de.schegge.ancestor.PersonsMatcher.hasName;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.startsWith;

import de.schegge.ancestor.Person;
import de.schegge.processor.annotations.Hamcrest;
import org.junit.jupiter.api.Test;

@Hamcrest(Person.class)
class PersonMatcherTest {
  @Test
  void test() {
    Person person = new Person();
    person.setName("Jens Kaiser");
    assertThat(person, hasName("Jens"));
    assertThat(person, not(hasName("Fiona")));
    assertThat(person, hasName(startsWith("Kai")));
  }
}

In this old example, Hamcrest is used to test the Person class. Except for the @Hamcrest annotation, this is a typical Hamcrest unit test. The Hamcrest assertions all start with assertThat. The first parameter is the value to be tested and the second parameter is an arbitrarily complex Matcher expression.

The hasName method belongs to the PersonsMatcher, a project-specific Hamcrest matcher, and checks in the first assertion whether the Person has the name Jens. The not method belongs to the standard matchers and obviously negates the embedded Matcher. The second assertion therefore checks whether the Person does not have the name Fiona. The startsWith method is also a standard matcher and checks whether a String begins with a specified value. In the third assertion, we therefore have a hasName matcher that does not have a String as a parameter but another Matcher for String values.

To write the test in this way, we therefore need a PersonMatcher that provides at least two matcher methods for the name attribute. With many types and many attributes, this is an extensive task. Fortunately, the class is annotated with @Hamcrest.

This is where the Hamcrest Annotation Generator comes into play. As an annotation processor, it evaluates the annotation during compilation and generates a suitable Matcher for the specified type. The procedure for the software developer is therefore to create a test class, annotate it, compile it and then create the tests with the generated matchers.

The Hamcrest Annotation Generator generated the Matcher as Java source code using the Freemarker template engine in the SourceCodeGenerator class. As the APIs of FreeMarker and FreshMarker are similar, the conversion in the Java code is done quickly.

public class SourceCodeGenerator {
  private final Template template;

  public SourceCodeGenerator(String name, String version) {
    Configuration configuration = new Configuration();
    Stream.of(new StringPluginProvider(), new BooleanPluginProvider(), new NumberPluginProvider(), new TemporalPluginProvider(), new DatePluginProvider(),
        new LooperPluginProvider(), new EnumPluginProvider(), new SequencePluginProvider(), new SystemPluginProvider())
      .forEach(configuration::registerPlugin);
    template = configuration.builder().getTemplate("hamcrest", new InputStreamReader(getClass().getResourceAsStream("/template.ftl")));
  }

  public void processTemplate(Map<String, Object> root, PrintWriter out) {
    template.process(root, out);
  }
}

The actual difference in the implementation is the slightly different instantiation of the Template. The explicit plugin initialization seems a little strange, but the service locator mechanism is probably not active during compilation.There are a few other small changes in the template sources.

/**
* @author Hamcrest Matcher Generator by Jens Kaiser
* @version 1.0
*
* @see <a href="https://gitlab.com/schegge/hamcrest-matcher-generator">Hamcrest Matcher Generator</a>
*/
package ${package};

import static org.hamcrest.CoreMatchers.equalTo;

import org.hamcrest.FeatureMatcher;
import org.hamcrest.Matcher;
import de.schegge.hamcrest.OptionalMatchers;

<#list imports as import>
import ${import};
</#list>

public final class ${matcherTypeName} {
<#list methods as method>
  <#switch method.template>
    <#case "class.optionalMatcherMethod">

  public static Matcher<${sourceType}> hasOptional${method.attributeName}(final Matcher<${method.returnType}> ${method.variableName}Matcher) {
    return new FeatureMatcher<${sourceType}, ${method.returnType}>(${method.variableName}Matcher, "${method.variableName}", "${method.variableName}") {
      @Override
      protected ${method.returnType} featureValueOf(final ${sourceType} actual) {
        return actual.${method.methodName}();
      }
    };
  }
    <#case "class.optionalMethod">

  public static Matcher<${sourceType}> has${method.attributeName}(final Matcher<${method.returnType}> ${method.variableName}Matcher) {
    return hasOptional${method.attributeName}(OptionalMatchers.hasValue(${method.variableName}Matcher));
  }

  public static Matcher<${sourceType}> has${method.attributeName}(final ${method.returnType} ${method.variableName}) {
    return hasOptional${method.attributeName}(OptionalMatchers.hasValue(${method.variableName}));
  }

  public static Matcher<${sourceType}> hasEmpty${method.attributeName}() {
    return hasOptional${method.attributeName}(OptionalMatchers.isEmpty());
  }

  public static Matcher <${sourceType}> has${method.attributeName}() {
    return hasOptional${method.attributeName}(OptionalMatchers.isPresent());
  }
    <#case "class.method">

  public static Matcher<${sourceType}> has${method.attributeName}(final Matcher<${method.returnType}> ${method.variableName}Matcher) {
    return new FeatureMatcher<${sourceType}, ${method.returnType}>(${method.variableName}Matcher, "${method.variableName}", "${method.variableName}") {
      @Override
      protected ${method.returnType} featureValueOf(final ${sourceType} actual) {
        return actual.${method.methodName}();
      }
    };
  }

  public static Matcher<${sourceType}> has${method.attributeName}(final ${method.returnType} ${method.variableName}) {
    return has${method.attributeName}(equalTo(${method.variableName}));
  }
  </#switch>
</#list>
}

In fact, only the <#break> tag and the empty <#default> tag have been removed in this template. Firstly, FreshMarker does not support fall-through in the switch directive and therefore does not use <#break> tag and secondly, empty <#default> tags are useless.

The customized Hamcrest Matcher Generator is now available on Maven Central and can be used in your own projects. To use the annotation processor in a Maven project, the compiler plugin must be configured. In addition, the Hamcrest Matcher Generator is required as a compile time dependency because the annotation @Hamcrest must be provided for the tests.

      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.13.0</version>
        <configuration>
          <annotationProcessorPaths>
            <annotationProcessorPath>
              <groupId>de.schegge</groupId>
              <artifactId>hamcrest-matcher-generator</artifactId>
              <version>1.0.0</version>
            </annotationProcessorPath>
          </annotationProcessorPaths>
        </configuration>
      </plugin>

Leave a Comment