Setting up a test fixture is tedious and difficult. In particular, preparing test data for complex object structures is time-consuming, and redundant descriptions increase the noise of the test code, resulting in poor readability.
This article describes the ʻObject Mother pattern and the
Test Data Builder pattern`, which are typical design patterns for setting up test fixtures, and also introduces ideas for improvement.
Java + JUnit5 sample code is available on GitHub.
A certain state (environment or data) that guarantees the same result when the test is executed repeatedly is called a test fixture
. This post will focus on setting up the data needed to run the test.
As a sample for explanation, assume a book management application. Suppose you have the following test code that verifies the check
method that checks at the time of lending, with the book lending management service LoanService
as the test target (SUT: System Under Test).
SimpleLoanServiceTest.java
public class SimpleLoanServiceTest {
private static final LocalDate TODAY = LocalDate.now();
private static final LocalDate YESTERDAY = TODAY.minusDays(1);
private static final LocalDate A_WEEK_AGO = TODAY.minusDays(7);
private LoanService sut;
@BeforeEach
void setup() {
sut = new LoanService(TODAY);
}
...
@Test
void If the maximum number of loans is exceeded, it will not be possible to lend.() {
// Arrange
Book book1 = new Book("Pillow Book");
Loan loan1 = new Loan(book1, A_WEEK_AGO, TODAY);
Book book2 = new Book("The Tale of Genji");
Loan loan2 = new Loan(book2, A_WEEK_AGO, TODAY);
Loans loans = Loans.of(loan1, loan2);
User user = new User("Yamamoto", TODAY, loans);
Book[] books = new Book[]{new Book("The Tale of the Taketori"), new Book("The Tale of the Heike")};
// Act
LoanCheckResult result = sut.check(user, books);
// Assert
assertThat(result.hasError, is(true));
assertThat(result.errorCode, is("LIMIT_EXCEEDED"));
}
@Test
void If the loaned item has an expiration date, new loan will not be possible.() {
// Arrange
Book book1 = new Book("Pillow Book");
Loan loan1 = new Loan(book1, A_WEEK_AGO, TODAY);
Book book2 = new Book("The Tale of Genji");
Loan loan2 = new Loan(book2, A_WEEK_AGO, YESTERDAY);
Loans loans = Loans.of(loan1, loan2);
User user = new User("Yamamoto", TODAY, loans);
Book book = new Book("The Tale of the Taketori");
// Act
LoanCheckResult result = sut.check(user, book);
// Assert
assertThat(result.hasError, is(true));
assertThat(result.errorCode, is("UNRETURNED_BOOKS"));
}
...
The above test code has the following problems.
The code that sets up test data in the + Arrange clause is redundant and duplicated between test cases.
The Pillow Book
or the date constant TODAY
is a meaningful value in the test or just a placeholder.How can we solve these problems?
If you simply want to eliminate code duplication, you can cut out the test data setup process as a private method in the test class, but similar setup code is written in multiple test classes in duplicate. There is a risk of losing it.
ʻObject Mother is a collection of factory methods that set up test data in one place. Below is a ʻObject Mother
that provides a factory method to set up a ʻUser`.
UserMother.java
public class UserMother {
public static User aUserWithoutLoans() {
Loans loans = Loans.empty();
return new User("USER", LocalDate.MAX, loans);
}
public static User aUserBorrowing2Books() {
Loans loans = Loans.of(aLoan(), aLoan());
return new User("USER", LocalDate.MAX, loans);
}
public static User aUserBorrowing3Books() {
Loans loans = Loans.of(aLoan(), aLoan(), aLoan());
return new User("USER", LocalDate.MAX, loans);
}
...
The test case looks like this:
ObjectMotherLoanServiceTest.java
@Test
void If the maximum number of loans is exceeded, it will not be possible to lend.() {
// Arrange
User user = UserMother.aUserBorrowing2Books();
Book[] books = BookMother.books(2);
// Act
LoanCheckResult result = sut.check(user, books);
// Assert
assertThat(result.hasError, is(true));
assertThat(result.errorCode, is("LIMIT_EXCEEDED"));
}
One of the features of ʻObject Mother is that it promotes reuse across test classes and test cases by consolidating factory methods in one place, but the greatest utility is to name the
data pattern. I think it's what to do.
Proper naming can improve the readability of your test code. For example
User user = UserMother.aUserBorrowing2Books();
Noisy information (in that test case) such as user name, book title, and date is eliminated from the code, and the test condition of "any user who borrows two books" can be read at a glance. I will. On the other hand, ʻObject Mother` also has the following disadvantages.
God Class
.Test Data Builder
is a method to set test data using Builder pattern
which is one of GoF design patterns.
Let's look at an example.
UserBuilder.java
public class UserBuilder {
private String name = "DEFAULT_NAME";
private LocalDate expirationDate = LocalDate.MAX;
private Loans loans = Loans.empty();
public UserBuilder withLoans(Loans loans) {
this.loans = loans;
return this;
}
public UserBuilder withExpirationDate(LocalDate expirationDate) {
this.expirationDate = expirationDate;
return this;
}
public User build() {
return new User(name, expirationDate, loans);
}
}
The test code looks like this:
TestDataBuilderLoanServiceTest.java
@Test
void If the loaned item has an expiration date, new loan will not be possible.() {
// Arrange
Loan loan1 = new LoanBuilder().build();
Loan loan2 = new LoanBuilder().withDueDate(YESTERDAY).build();
Loans loans = Loans.of(loan1, loan2);
User user = new UserBuilder().withLoans(loans).build();
Book book = new BookBuilder().build();
// Act
LoanCheckResult result = sut.check(user, book);
// Assert
assertThat(result.hasError, is(true));
assertThat(result.errorCode, is("UNRETURNED_BOOKS"));
}
Generally, Test Data Builder
defines the default value of each property, and to create an object using the default value, just generate a builder and call the build
method as shown below.
new LoanBuilder().build();
If there is a property that you want to change from the default value, after creating the Builder, connect it with a method chain and describe it, and finally call the build
method.
User user = new UserBuilder().withLoans(loans).build();
While Test Data Builder
improves the visibility of test code by using default values and fluent API
using method chains, it also has the following drawbacks:
Based on Test Data Builder
, I will introduce a technique to improve its shortcomings.
First, since object creation with the new operator is noise, change the constructor to private and prepare a static factory method instead.
UserBuilder.java
private UserBuilder() {}
public static UserBuilder ofDefault() {
return new UserBuilder();
}
In addition to the default values, the factory methods also provide commonly used setup patterns.
UserBuilder.java
public static UserBuilder borrowing(int numOfBooks) {
UserBuilder builder = new UserBuilder();
List<Loan> loanList = new ArrayList<>();
for (int i = 0; i < numOfBooks; i++) {
Loan loan = LoanBuilder.ofDefault().build();
loanList.add(loan);
}
builder.loans = Loans.of(loanList.toArray(new Loan[0]));
return builder;
}
Writing test code using this factory method has the effect of keeping the code clean and the factory method'naming makes the intent clear. It's like designing a DSL (Domain Specific Language).
HybridLoanServiceTest.java
@Test
void If the maximum number of loans is exceeded, it will not be possible to lend.() {
// Arrange
User user = UserBuilder.borrowing(2).build();
Book[] books = BookBuilder.ofDefault().build(2);
// Act
LoanCheckResult result = sut.check(user, books);
// Assert
assertThat(result.hasError, is(true));
assertThat(result.errorCode, is("LIMIT_EXCEEDED"));
}
There is also a technique for receiving callbacks to give you more flexibility in setting up nested objects.
The following sample is an example of receiving a callback to set up a Loan
object nested in a ʻUser object using
LoanBuilder`.
UserBuilder.java
public UserBuilder borrowing(Consumer<LoanBuilder> callback) {
LoanBuilder loanBuilder = LoanBuilder.ofDefault();
callback.accept(loanBuilder);
loans.add(loanBuilder.build());
return this;
}
Such an implementation pattern is called the Loan pattern
because the UserBuilder
side creates a LoanBuilder
object and lends it to the received callback. * It was a coincidence that the word Loan
matched (^ _ ^;)
The test code sample to be used is as follows. Callbacks are written in lambda expressions.
HybridLoanServiceTest.java
@Test
void If the loaned item has an expiration date, new loan will not be possible.() {
// Arrange
User user = UserBuilder.ofDefault()
.borrowing(loanBuilder -> loanBuilder.noop())
.borrowing(loanBuilder -> loanBuilder.withDueDate(YESTERDAY))
.build();
Book book = BookBuilder.ofDefault().build();
// Act
LoanCheckResult result = sut.check(user, book);
// Assert
assertThat(result.hasError, is(true));
assertThat(result.errorCode, is("UNRETURNED_BOOKS"));
}
and
Test Data Builder`.Recommended Posts