Introduction
Writing tests requires setting up the data for each scenario, which can be both crucial and time-consuming.
This time-consuming task can discourage developers from writing as many scenarios. They can even skip writing tests entirely especially if they are in a hurry.
Also, the setup step can make your test scenario hard to read and understand due to the many setup lines that the scenario has.
The test data builders technique helps you fix these problems. Creating an effective and useful way to create and reuse data, and can make things easier to read.
What Are Test Data Builders?
Test data builders are a technique similar to the builder pattern from the design pattern catalog. The difference is that when you don’t need to fill in all data before building the object. The builder already fills your object with data. And you can modify the default data using the methods of the pattern.
This way you’ll create your objects fast only modifying what you need for your test scenario work.
The Struggle is Real: The Messy World of Manual Test Data Setup
Let’s suppose you have this Customer class:
public class Customer {
private String name;
private LocalDate birthday;
private List<Address> addresses;
private List<Document> documents;
private List<Contact> contacts;
private List<Product> products;
public Customer(String name, LocalDate birthday, List<Address> addresses, List<Document> documents, List<Contact> contacts, List<Product> products) {
validateCustomer(name, documents, contacts, products);
this.name = name;
this.birthday = birthday;
this.addresses = addresses;
this.documents = documents;
this.contacts = contacts;
this.products = products;
}
private void validateCustomer(String name, List<Document> documents, List<Contact> contacts, List<Product> products) {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("Customer name cannot be null or empty.");
}
if (documents.isEmpty()) {
throw new IllegalArgumentException("Customer must have at least one document.");
}
if (contacts.isEmpty()) {
throw new IllegalArgumentException("Customer must have at least one contact.");
}
if (products.isEmpty()) {
throw new IllegalArgumentException("Customer must have at least one product.");
}
}
// Getters and setters for all attributes
}
It’s an example of a class with many dependencies with other objects. It can be bigger than that but for this example, this class will be fine. Besides the length of the class, there are some rules to create it which adds additional complexity. Example:
- it must have at least one address
- it must have at least one document
- it must have at least one contact
- etc.
To create a customer that has active products, we first need to satisfy all the dependencies. Then respect these validation rules, and then add products to this customer.
An observation here is that, in some cases, your class can even be immutable so you cannot change the attributes after.
Here’s what the setup for this class will look like:
@Test
public void test() {
List<Address> addresses = List.of(new Address("Main Street", 123, "12345", "Cityville", "USA"));
List<Document> documents = List.of(new Document("123456789", IDENTITY_CARD, true));
List<Contact> contacts = List.of(new Contact("123-456-7890", PHONE));
List<ProductItem> productItems = List.of((new ProductItem("DEF456", "Item Description", BigDecimal.valueOf(10.00), now(), now())));
List<Product> products = List.of(new Product("ABC123", "Product Description", ACTIVE, now(), now(), productItems));
Customer customer = new Customer("John Doe", of(1980, 1, 1), addresses, documents, contacts, products);
}
That’s an exhaustive and not very descriptive setup, right?
I mean, what is this customer for? What are these dependencies that he has been telling us?
We need to instantiate and create all the dependencies objects to create a customer.
Also, the product has some dependencies on its own, so it’s one more thing to worry about and set up before having a product ready to attribute to the customer.
After ALL this exhaustive setup, we can execute our scenario.
As I said, it’s time-consuming, and it’s the tip of the iceberg.
If another developer wanted to test if the customer has some specific documents, in his test class he was creating the customer again. He will have to set up all the customer fields again, only to test his specific scenario.
Look how the code below is similar to the one presented before:
@Test
public void otherClassTest() {
List<Address> addresses = List.of(new Address("Main Street", 123, "12345", "Cityville", "USA"));
List<Document> documents = List.of(new Document("123456789", DRIVER_LICENSE, true));
List<Contact> contacts = List.of(new Contact("test@test.com", EMAIL));
List<ProductItem> productItems = List.of((new ProductItem("OTHER ITEM", "Item Description", BigDecimal.valueOf(10.00), now(), now())));
List<Product> products = List.of(new Product("CDE123", "Product Description", ACTIVE, now(), now(), productItems));
Customer customer = new Customer("Jane Doe", of(1985, 1, 1), addresses, documents, contacts, products);
}
It’s a duplicated code and also does not describe what it has different from the previous one or for the scenario that will be tested.
But it’s not the developer's fault, maybe he didn’t know that the customer was created in another test class, because there’s so much to code to look into it. And the test data creation is not centered.
Usually, this setup is extracted from a method inside the test class to a “createCustomer” method, which will be used in the class. But is not easily visible to all developers. That will end up writing some duplicated “createCustomer” method on another test class.
Build It Once, Use It Everywhere: The Power of Test Data Builders
Start writing some test data builders. By using this pattern, you can create data for your test way faster and more concisely.
To create a test data builder you should create a builder for the domain you are going to use in the tests. With the difference that by default your domain is already populated with some valid data. So if you use the build method you get a valid domain to use in your tests.
Example:
public class CustomerBuilder {
private String name = "Default Name";
private LocalDate birthday = LocalDate.of(1980, 1, 1);
private AddressBuilder addressBuilder = anAddress();
private DocumentBuilder documentBuilder = aDocument();
private ContactBuilder contactBuilder = aContact();
private ProductBuilder productBuilder = aProduct();
private List<Product> products = new ArrayList<>();
private List<Contact> contacts = new ArrayList<>();
private List<Address> addresses = new ArrayList<>();
private List<Document> documents = new ArrayList<>();
private CustomerBuilder() {
}
public static CustomerBuilder aCustomer() {
return new CustomerBuilder();
}
public CustomerBuilder withName(String name) {
this.name = name;
return this;
}
// ... other methods for setting attributes
public CustomerBuilder withAddress(AddressBuilder addressBuilder) {
this.addressBuilder = addressBuilder;
addresses.add(addressBuilder.build());
return this;
}
public CustomerBuilder withDocument(DocumentBuilder documentBuilder) {
this.documentBuilder = documentBuilder;
documents.add(documentBuilder.build());
return this;
}
public CustomerBuilder withProduct(ProductBuilder productBuilder) {
this.productBuilder = productBuilder;
products.add(productBuilder.build());
return this;
}
// ... similar methods for other builders
public Customer build() throws IllegalArgumentException {
return new Customer(
name,
birthday,
addresses.isEmpty() ? List.of(addressBuilder.build()): addresses,
documents.isEmpty() ? List.of(documentBuilder.build()) : documents,
contacts.isEmpty() ? List.of(contactBuilder.build()) : contacts,
products.isEmpty() ? List.of(productBuilder.build()) : products);
}
}
Then you can modify the default values using the "with" methods of the builder. So your setup code will go from this:
@Test
public void otherClassTest() {
List<Address> addresses = List.of(new Address("Main Street", 123, "12345", "Cityville", "USA"));
List<Document> documents = List.of(new Document("123456789", DRIVER_LICENSE, true));
List<Contact> contacts = List.of(new Contact("test@test.com", EMAIL));
List<ProductItem> productItems = List.of((new ProductItem("OTHER ITEM", "Item Description", BigDecimal.valueOf(10.00), now(), now())));
List<Product> products = List.of(new Product("CDE123", "Product Description", ACTIVE, now(), now(), productItems));
Customer customer = new Customer("Jane Doe", of(1985, 1, 1), addresses, documents, contacts, products);
}
To this:
@Test
public void settingUpCustomer() {
Customer customer = CustomerBuilder.aCustomer().withName("Jane Doe").build();
}
Much simpler and you only have to modify the values you need, the builder will take care of the rest.
All developers can reuse this data builder class to help set up your test. It’s way faster simpler and easier to read and understand.
Now let’s suppose that you need to create a customer that has an inactive product. You could do something like this:
Customer customer = CustomerBuilder.aCustomer()
.withProduct(ProductBuilder.aProduct().withStatus(INACTIVE))
.build();
It’s nice. However, another advantage of this technique is that you can make this setup more semantical too.
By making some changes in the Product test data builder. Instead of passing the status as a parameter, to make it more semantical we can define a method like this:
Customer customer = CustomerBuilder.aCustomer()
.withProduct(ProductBuilder.aProduct().thatIsInactive())
.build();
We can add the static imports for the builder’s methods so it becomes more clean:
Customer customer = aCustomer().withProduct(aProduct().thatIsInactive()).build();
Done! Now your test setup is easy to write and easy to understand. The example above will use a customer that has an inactive product.
Conclusion
In this article, we saw how test data builders can make developer life easier. How to facilitate your test scenario creation, and improve the readability of your tests. It can take some time to create this for all the classes in your project. But it’ll pay off.
Review your tests, create data builders for your objects, and experience the improved developer experience.
Have you spent much time lately setting up your tests? Feel free to share your experiences and solutions.
Follow me on social media to learn more about efficient testing and improving applications:
Top comments (2)
Thanks for sharing
Awesome article and I can clearly see the advantages of test builders. Surely I'll use them from now 😁👍