Eloquent JUnit: from Matchers to Builders (EN)

L'URL courte de cet article est : http://inagua.ch/el8ct

VERSION FRANCAISE

  1. Context
  2. The Test: verbose
  3. The Matcher: concise
  4. The Builder: eloquent
  5. Improvements
  6. Conclusion
  7. Sources

Context

I had to make an evolution on a legacy code handling a large number of nested Java objects. The code was instancing several thousands of the class Demande (“Request”) from a ResultSet.

This class Demande was characterized by:

  • plenty of attributs, like dates or amounts,
  • and a lot of nested objects. Under the root Demande, there were 9 sub-levels with as much of 20 attributes each.

So, I began to write tests to guarantee non-regression…

Note: As the project is close source, the prevent privacy issues and dependence on internal dependencies, the rest of the article will use a class Colleague as example. The source code can be found on https://github.com/seiza/junit-matchers-builders

The Test: verbose

To keep it simple, I started writing tests with assertEquals. Each attribute need one line of test. In my case my tests ended up like that:

assertEquals(colleague.getName(), "Jacques");
assertNull(colleague.getService());
assertEquals(colleague.getAge(), 0);
assertNull(colleague.getCurrentProject());
assertEquals(colleague.getSalary().longValue(), 100000L);

This is readable, but quite verbose and repetitive…

The Matcher: concise

We should be able to refactor those tests and make them more expressive.

I choosed the concept of Matcher we can found in hamcrest. This design pattern allows the tests to be written with a single line:

assertThat(myCat.hasJump(), is(true));

where is(...) is a Matcher.

In our case, that should be:

assertThat(colleague, is(IsColleagueMatcher.colleagueWith("Jacques", 0, null, null, "100000")));

or like that with a static import:

import static ch.inagua.spikes.matchers.matcher.IsColleagueMatcher.colleagueWith;
// ...
assertThat(colleague, is(colleagueWith("Jacques", 0, null, null, "100000")));

Here is the code of the Colleague matcher:

public class IsColleagueMatcher extends TypeSafeMatcher<Colleague> {

   private final String name;
   private final int age;
   private final String service;
   private final String currentProject;
   private final BigDecimal salary;

   /**
    * Constructor, private!... @see {@link #colleagueWith(String, int, String, String, String)}
    */
   private IsColleagueMatcher(String name, int age, String service, String currentProject, String salary) {
      this.name = name;
      this.age = age;
      this.service = service;
      this.currentProject = currentProject;
      this.salary = new BigDecimal(salary);
   }

   /**
    * Static method to return an instance of the matcher
    */
   @Factory
   public static IsColleagueMatcher colleagueWith(String name, int age, String service, String currentProject, String salary) {
      return new IsColleagueMatcher(name, age, service, currentProject, new BigDecimal(salary));
   }

   /**
    * toString method for the Expected (values given to the factory above)
    */
   public void describeTo(Description description) {
      description.appendText("colleague with properties [" //
         + "name=" + name //
         + ", age=" + age //
         + ", service=" + service //
         + ", currentProject=" + currentProject //
         + ", salary=" + salary //
         + "]");
   }

   /**
    * toString method for the Actual / tested instance of the object
    */
   @Override
   protected void describeMismatchSafely(Colleague colleague, Description description) {
      description.appendText("was [" //
         + (StringUtils.equals(name, colleague.getName()) ? "" : "name=" + colleague.getName()) //
         + (age == colleague.getAge() ? "" : ", age=" + colleague.getAge()) //
         + (StringUtils.equals(service, colleague.getService()) ? "" : ", service=" + colleague.getService()) //
         + (StringUtils.equals(currentProject, colleague.getCurrentProject()) ? "" : ", currentProject=" + colleague.getCurrentProject()) //
         + (areBigDecimalEquals(salary, colleague.getSalary()) ? "" : ", salary=" + colleague.getSalary()) //
         + "]");
   }

   /**
    * Do the comparison
    */
   @Override
   protected boolean matchesSafely(Colleague colleague) {
      return true //
         && StringUtils.equals(name, colleague.getName())//
         && age == colleague.getAge()//
         && StringUtils.equals(service, colleague.getService())//
         && StringUtils.equals(currentProject, colleague.getCurrentProject())//
         && areBigDecimalEquals(salary, colleague.getSalary())//
      ;
   }

   /**
    * Private stuff
    */
   private boolean areBigDecimalEquals(BigDecimal bd1, BigDecimal bd2) {
      if (bd1 == null && bd2 == null) return true;
      if (bd1 != null) return bd1.equals(bd2);
      return bd2.equals(bd1);
   }

}

Remember the assert syntax:

assertThat(colleague, is(colleagueWith("Jacques", 0, null, null, "100000")));

The Matcher gives us conciseness but the syntax has some limitations:

  • What is the meaning of the parameters? For example, what is the meaning of the third parameter having null null??
  • All parameters have to be provided. For example, we would like to not provide the 3rd and 4th parameters with null value.

Technical

To write a Matcher, as the root class is org.hamcrest.TypeSafeMatcher, we need the hamcrest library:
In order to be able to write your own Matcher you need to add the dependency to the hamcrest library to you project. With maven to have to add this to your POM:

<dependency>
   <groupId>org.hamcrest</groupId>
   <artifactId>hamcrest-all</artifactId>
   <scope>test</scope>
   <version>1.3</version>
</dependency>

The Builder: eloquent

To answer to the first problem, I’ve decided to adapt the Builder design pattern.

This design pattern allows to create an instance of a class (the Builder) on which we can chain calls in order to fill its attributes. Once you have setup only wanted attributes, you have to call build() to get an instance of the desired object.

This the code of the Builder of our Colleague class:

public class ColleagueBuilder {

   private ColleagueBuilder() {
   }

   public static ColleagueBuilder builder() {
      return new ColleagueBuilder();
   }

   public Colleague build() {
      final Colleague colleague = new Colleague();
      colleague.setName(name);
      colleague.setAge(age);
      colleague.setService(service);
      colleague.setCurrentProject(currentProject);
      colleague.setSalary(salary);
      return colleague;
   }

   private String name;
   private int age;
   private String service;
   private String currentProject;
   private BigDecimal salary;

   public ColleagueBuilder name(String name) {
      this.name = name;
      return this;
   }

   public ColleagueBuilder service(String service) {
      this.service = service;
      return this;
   }

   public ColleagueBuilder age(int age) {
      this.age = age;
      return this;
   }

   public ColleagueBuilder currentProject(String currentProject) {
      this.currentProject = currentProject;
      return this;
   }

   public ColleagueBuilder salary(BigDecimal salary) {
      this.salary = salary;
      return this;
   }

}

With this builder, we can create an instance of Colleague like this:

Colleague c = ColleagueBuilder.builder().name("Batman").age(33).build();

This is a formidable and seductive concision.

Note: There are several open source libraries to generate Builders, but writing your own is a good way to learn.

In our case, we will use this pattern as follows:

  • The Builder will be used to fill the Matcher
  • No need to use the build() method

This gives the following code:

public class IsColleagueBuilderMatcher extends TypeSafeMatcher<Colleague> {

   //
   // MACTHER PART
   //

   /**
    * Constructor, private!... @see {@link #colleagueWith()}
    */
   private IsColleagueBuilderMatcher() {
   }

   /**
    * Static method to return an instance of the matcher
    */
   @Factory
   public static IsColleagueBuilderMatcher colleagueWith() {
      return new IsColleagueBuilderMatcher();
   }

   /**
    * toString method for the Expected (values given to the factory above)
    */
   public void describeTo(Description description) {
      description.appendText("colleague with properties [" //
         + "name=" + name //
         + ", age=" + age //
         + ", service=" + service //
         + ", currentProject=" + currentProject //
         + ", salary=" + salary //
         + "]");
   }

   /**
    * toString method for the Actual / tested instance of the object
    */
   @Override
   protected void describeMismatchSafely(Colleague colleague, Description description) {
      description.appendText("was [" //
         + (StringUtils.equals(name, colleague.getName()) ? "" : "name=" + colleague.getName()) //
         + (age == colleague.getAge() ? "" : ", age=" + colleague.getAge()) //
         + (StringUtils.equals(service, colleague.getService()) ? "" : ", service=" + colleague.getService()) //
         + (StringUtils.equals(currentProject, colleague.getCurrentProject()) ? "" : ", currentProject=" + colleague.getCurrentProject()) //
         + (areBigDecimalEquals(salary, colleague.getSalary()) ? "" : ", salary=" + colleague.getSalary()) //
         + "]");
   }

   /**
    * Do the comparison!
    */
   @Override
   protected boolean matchesSafely(Colleague colleague) {
      return true //
         && StringUtils.equals(name, colleague.getName())//
         && age == colleague.getAge()//
         && StringUtils.equals(service, colleague.getService())//
         && StringUtils.equals(currentProject, colleague.getCurrentProject())//
         && areBigDecimalEquals(salary, colleague.getSalary())//
      ;
   }

   /**
    * Private stuff
    */
   private boolean areBigDecimalEquals(BigDecimal bd1, BigDecimal bd2) {
      if (bd1 == null && bd2 == null) return true;
      if (bd1 != null) return bd1.equals(bd2);
      return bd2.equals(bd1);
   }

   //
   // BUILDER part
   //

   private String name;
   private int age;
   private String service;
   private String currentProject;
   private BigDecimal salary;

   /**
    * Setter for name
    */
   public IsColleagueBuilderMatcher name(String name) {
      this.name = name;
      return this;
   }

   /**
    * Setter for age
    */
   public IsColleagueBuilderMatcher age(int age) {
      this.age = age;
      return this;
   }

   /**
    * Setter for service
    */
   public IsColleagueBuilderMatcher service(String service) {
      this.service = service;
      return this;
   }

   /**
    * Setter for currentProject
    */
   public IsColleagueBuilderMatcher currentProject(String currentProject) {
      this.currentProject = currentProject;
      return this;
   }

   /**
    * Setter for salary
    */
   public IsColleagueBuilderMatcher salary(String salary) {
      this.salary = new BigDecimal(salary);
      return this;
   }

}

And the test becomes:

assertThat(colleague, is(colleagueWith().name("Batman").age(33)));

So sexy, no?!

Update. Warning

It is important to use the assertThat from hamcrest instead of the JUnit one (Otherwise the describeMismatchSafely and matchesSafely methods of the Matcher won’t be called!). This is done by the proper import:

import static org.hamcrest.MatcherAssert.assertThat;

Interestingly, as the default values of the attributes are null, we do not need to call any methods to set null values anymore. Second problem solved.

Thus, this code:

assertThat(colleague, is(colleagueWith().name("Batman").age(33).service(null)));

becomes now:

assertThat(colleague, is(colleagueWith().name("Batman").age(33)));

Improvements

Disclaimer, this is my own vision.

Who says DSL, says easy to read

Many of us consider tests as documentation (maybe the best one as it is executed). So it is important to keep them easy to read, and expressing only features of your application code. All the plumbing need to be hidden.

In the previous code, the salary is a perfect example. The Colleague class has the salary as a BigDecimal but the Matcher uses a String.

So instead of:

assertThat(colleague, is(colleagueWith().salary(new BigDecimal("100000"))));

we have:

assertThat(colleague, is(colleagueWith().salary("100000")));

The Matcher plays the role of a proxy that hides plumbing.

Naming

In order to ease matcher use (which can have a lot of methods), I prefix the methods allowing to set attributes with ‘_’. Like this, my IDE will group them together during completion.

The following code:

public IsDemandeWithProperties codeDocument(String codeDocument) {
   this.codeDocument = codeDocument;
   return this;
}
// ...
assertThat(demande, is(demandeWith().codeDocument("ABC")));

Becomes:

public IsDemandeWithProperties _codeDocument(String codeDocument) {
   this.codeDocument = codeDocument;
   return this;
}
// ...
assertThat(demande, is(demandeWith()._codeDocument("ABC")));

Only display failing attributes

When a test fails, the whole attributes are displayed, for both Actual and Expected. With all those logs, it is difficult to find the attributes responsible for the failure of our tests.

For that, we will override the original describeMismatchSafely(Colleague, Description) method, responsible for displaying the Actual when there is a failure:

@Override
protected void describeMismatchSafely(Colleague colleague, Description description) {
   description.appendText("was [" //
      + "name=" + colleague.getName() //
      + ", age=" + colleague.getAge() //
      + ", service=" + colleague.getService() //
   + ", currentProject=" + colleague.getCurrentProject() //
   + ", salary=" + colleague.getSalary() //
   + "]");
}

who generates the following error message:

Expected: is colleague with properties [name=Batman, age=0, service=null, currentProject=null, salary=100000]
but: was [name=Jacques, age=0, service=null, currentProject=null, salary=100000]

This is the new code for this method to display only the parameters having wrong value:

@Override
protected void describeMismatchSafely(Colleague colleague, Description description) {
   description.appendText("was [" //
   + (StringUtils.equals(name, colleague.getName()) ? "" : "name=" + colleague.getName()) //
   + (age == colleague.getAge() ? "" : ", age=" + colleague.getAge()) //
   + (StringUtils.equals(service, colleague.getService()) ? "" : ", service=" + colleague.getService()) //
   + (StringUtils.equals(currentProject, colleague.getCurrentProject()) ? "" : ", currentProject=" + colleague.getCurrentProject()) //
   + (areBigDecimalEquals(salary, colleague.getSalary()) ? "" : ", salary=" + colleague.getSalary()) //
   + "]");
}

The method displays now the following error message:

Expected: is colleague with properties [name=Batman, age=0, service=null, currentProject=null, salary=100000]
but: was [name=Jacques]

[UPDATE 27/01/2017] Check only wanted attributes

At this stage, the Matcher checks all the attributes. But, sometimes, you want to only check some of those attributes, to highlight the intention of the test.
For example, if the test should only modify one or two attributes of the object under test, we would like to build a Mather with only those two attributes to focus the attention of the reader and precise the intent of the test.

Currently, all the attributes of our tested object having a non null value and present in the Matcher will provide a failure because they will be compared to null values.

To handle this, I added a boolean parameter to the @Factory method of the Matcher, called ignoreNullProperties. If it equals to false, the behavior is unchanged, otherwise we will not assert on properties not set.

I choose to use a parameter for the factory method to highlight this option to the users of the Matcher.

This can be done by refactoring the @Factory and machesSafely methods like that:

@Override
protected boolean matchesSafely(Colleague colleague) {
   return true //
      && matchWithNull(name, StringUtils.equals(name, colleague.getName()))//
      && matchWithNull(age, age == colleague.getAge())//
      && matchWithNull(service, StringUtils.equals(service, colleague.getService()))//
      && matchWithNull(currentProject, StringUtils.equals(currentProject, colleague.getCurrentProject()))//
      && matchWithNull(salary, areBigDecimalEquals(salary, colleague.getSalary()))//
   ;
}

private boolean matchWithNull(Object property, boolean isEqual) {
  return property == null && ignoreNullProperties || isEqual;
}

//
// BUILDER part
//

private final boolean ignoreNullProperties;

private IsColleagueBuilderMatcher(boolean ignoreNullProperties) {
  this.ignoreNullProperties = ignoreNullProperties;
}

@Factory
public static IsColleagueBuilderMatcher colleagueWith(boolean ignoreNullProperties) {
  return new IsColleagueBuilderMatcher(ignoreNullProperties);
}

Conclusion

The implementation of this Builder Matcher allows the emergence of an elegant DSL (Domain Specific Language), who makes tests more readable, and therefore easier to maintain. This is enforced by the fact that matchers encapsulate logic the check often swarmed in the tests classes.

Sources

The sources of this article are also on Github:

The Java maven project contains the following files:

matchers
 + src/main/java
    + ch.inagua.spikes.matchers
       + models
          - Colleague                  // Class to test
       + services
          - ColleagueBuilder           // Design Pattern Builder
          - Recruiter                  // Uses Builder
 + src/test/java
    + ch.inagua.spikes.matchers
       + matchers
          - IsColleagueBuilderMatcher  // Matcher with Builder
          - IsColleagueMatcher         // Matcher v0
       + services
          - RecruiterTest              // Test using the Matcher
 + pom.xml                             // Contains needed dep endencies
 + README.md

You can build it with maven and edit it with your favorite Java IDE.

A mvn test will show you the failing test.

L'URL courte de cet article est : http://inagua.ch/el8ct

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

*


*

Vous pouvez utiliser ces balises et attributs HTML : <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>