DEV Community

Willian Ferreira Moya
Willian Ferreira Moya

Posted on • Originally published at springmasteryhub.com

Simplify Your Tests and Save Time: A Guide to Test Data Builders

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
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
Enter fullscreen mode Exit fullscreen mode

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);
    }
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
Enter fullscreen mode Exit fullscreen mode

To this:

@Test
    public void settingUpCustomer() {
        Customer customer = CustomerBuilder.aCustomer().withName("Jane Doe").build();
    }
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

We can add the static imports for the builder’s methods so it becomes more clean:

Customer customer = aCustomer().withProduct(aProduct().thatIsInactive()).build();
Enter fullscreen mode Exit fullscreen mode

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:

Willian Moya (@WillianFMoya) / X (twitter.com)

Willian Ferreira Moya | LinkedIn

Top comments (2)

Collapse
 
respect17 profile image
Kudzai Murimi

Thanks for sharing

Collapse
 
zauriel profile image
Rodrigo Díaz de Vivar - (El Cid Capeador)

Awesome article and I can clearly see the advantages of test builders. Surely I'll use them from now 😁👍