- Context
- The Test: verbose
- The Matcher: concise
- The Builder: eloquent
- Improvements
- Conclusion
- 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 theMatcher
- 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.