projectlombok / lombok

Very spicy additions to the Java programming language.
https://projectlombok.org/
Other
12.86k stars 2.38k forks source link

[FEATURE] customizing the @Builder to allow easy creation of the Test Data Builder pattern #3398

Open CamilYed opened 1 year ago

CamilYed commented 1 year ago

Describe the feature

The Problem

Nat Pryce once described such a pattern as Test Data Builder. When I once wrote an article on readable tests by example, I used Groovy instead of Java, which comes with the Spock framework.

Why I used Groovy is because it has Builder annotation, which allows me to easily create a Test Data Builder with default field values, plus what is important I can get rid of the build method when constructing objects.

How it's solved in Groovy

But as they say show me the code.

@Builder(builderStrategy = SimpleStrategy, prefix = "with")
class OrderDataSnapshotBuilder {
  String id = TestData.ORDER_ID
  String clientId = TestData.CLIENT_ID
  boolean unpaid = false
  BigDecimal deliveryCost = 40.00
  String currency = TestData.EURO_CURRENCY_CODE
  Map<Vinyl, Quantity> items = [(TestData.VINYL_CZESLAW_NIEMEN): Quantity.ONE]

  static OrderDataSnapshotBuilder aPaidOrder() {
    return new OrderDataSnapshotBuilder()
  }

  static OrderDataSnapshotBuilder anUnpaidOrder() {
    return new OrderDataSnapshotBuilder().withUnpaid(true)
  }

  OrderDataSnapshotBuilder withAmount(MoneyBuilder amount) {
    items = [(new Vinyl(TestData.VINYL_CZESLAW_NIEMEN_ID, amount.build())): Quantity.ONE]
    return this
  }

  OrderDataSnapshotBuilder withItem(ItemBuilder anItem) {
    items.removeAll { it -> it.key.vinylId().value() == anItem.productId }
    items << anItem.build()
    return this
  }

  OrderDataSnapshotBuilder withItems(ItemBuilder... anItems) {
    items.clear()
    anItems.each {
      items << it.build()
    }
    return this
  }

  @Builder(builderStrategy = SimpleStrategy, prefix = "with")
  static class ItemBuilder {
    String productId = TestData.VINYL_CZESLAW_NIEMEN_ID
    Money unitPrice = TestData.EUR_40
    int quantity = 1

    static ItemBuilder anItem() {
      return new ItemBuilder()
    }

    ItemBuilder withUnitPrice(MoneyBuilder anUnitPrice) {
      unitPrice = anUnitPrice.build()
      return this
    }

    Map<Vinyl, Quantity> build() {
      return [(new Vinyl(new VinylId(productId), unitPrice)): new Quantity(quantity)]
    }
  }

  OrderDataSnapshot build() {
    Money cost = items.entrySet().stream()
      .map(it -> it.key.unitPrice() * it.getValue())
      .reduce(Money.ZERO, Money::add)
    return new OrderDataSnapshot(
      new ClientId(clientId),
      new OrderId(id),
      cost,
      new Money(deliveryCost, Currency.getInstance(currency)),
      items,
      unpaid
    )
  }
}

As You cen see I can add my custom method to this builder what is really important!

With this approach we don't have to carry the word “builder” around. See the code below:

def "should change the item quantity for unpaid order"() {
    given:
        thereIs(anUnpaidOrder()
                .withId(ORDER_ID)
                .withClientId(CLIENT_ID)
                  .withItems(
                    anItem()
                      .withProductId(CZESLAW_NIEMEN_ALBUM_ID)
                      .withUnitPrice(euro(35.00))
                      .withQuantity(10),
                    anItem()
                      .withProductId(BOHEMIAN_RHAPSODY_ALBUM_ID)
                      .withUnitPrice(euro(55.00))
                      .withQuantity(1)
                  )
        )

    when:
       changeItemQuantity(anItemQuantityChange()
                            .withOrderId(ORDER_ID)
                            .withProductId(CZESLAW_NIEMEN_ALBUM_ID)
                            .withQuantityChange(20)
       )

    then:
        assertThatThereIsOrderWithId(ORDER_ID)
          .hasClientId(CLIENT_ID)
          .hasItemWithIdThat(CZESLAW_NIEMEN_ALBUM_ID)
              .hasUnitPrice(euro(35.00))
              .hasQuantity(20)
          .and()
          .hasItemWithIdThat(BOHEMIAN_RHAPSODY_ALBUM_ID)
              .hasUnitPrice(euro(55.00))
              .hasQuantity(1)
  }
  // @formatter:on
}

How it's solved in Java by Lombok Builder annotation

Now let's move on to Java, here you have to do a lot of tinkering using lombok, which is not satisfactory to me and my colleagues. Let me attach a snippet of the article: How to Create a Test Data Builder. Arho Huttunen

🌶️ Reduce Boilerplate With Lombok While the test data builder pattern provides a lot of benefits, there is also one major drawback. That is, we end up writing a lot of boilerplate code.

To tackle this problem with boilerplate, we can take advantage of the Lombok project. We can get rid of the default constructor, getters and automatically create a builder class by annotating the class with Lombok @Data and @Builder annotations.

@Data
@Builder
public class Order {
    private final Long orderId;
    private final Customer customer;
    private final List<OrderItem> orderItems;
    private final Double discountRate;
    private final String couponCode;
}

Lombok automatically creates a builder class and a factory method inside the class.

    OrderItem coffeeMug = OrderItem.builder().name("Coffee mug").quantity(1).build();
    OrderItem teaCup = OrderItem.builder().name("Tea cup").quantity(1).build();
    Order order = Order.builder()
            .orderItems(Arrays.asList(coffeeMug, teaCup))
            .build();

Unfortunately, for collections, you now have to pass a collection as the builder argument. Luckily, by annotating a collection field with @Singular, Lombok will generate methods for single items in the collection.

@Data
@Builder
public class Order {
    private final Long orderId;
    private final Customer customer;
    @Singular
    private final List<OrderItem> orderItems;
    private final Double discountRate;
    private final String couponCode;
}

We can now add items to a collection by calling the method multiple times.

Order order = Order.builder()
            .orderItem(OrderItem.builder().name("Coffee mug").quantity(1).build())
            .orderItem(OrderItem.builder().name("Tea cup").quantity(1).build())
            .build()

We have still lost a little in the readability, at least in my opinion. We have to carry the word “builder” around.

However, we can configure Lombok to generate a different name for the factory method and use static imports. We can also prefix the setter methods.

@Data
@Builder(builderMethodName = "anOrder", setterPrefix = "with")
public class Order {
    // ...
}

Now our builder looks a lot more like the custom builder.

 Order order = anOrder()
            .withOrderItem(anOrderItem().withName("Coffee mug").withQuantity(1).build())
            .withOrderItem(anOrderItem().withName("Tea cup").withQuantity(1).build())
            .build();

There is still one problem, though. This code now suffers from the same problem as our custom builder when we try to vary our data. In our custom builder, we added a but() method to deal with this.

Luckily, Lombok allows us to configure a toBuilder() method.

@Data
@Builder(builderMethodName = "anOrder", toBuilder = true, setterPrefix = "with")
public class Order {
    // ...
}

The difference here is that the toBuilder() is called on a constructed object, not on a builder.

    Order coffeeMugAndTeaCup = anOrder()
            .withOrderItem(anOrderItem().withName("Coffee mug").withQuantity(1).build())
            .withOrderItem(anOrderItem().withName("Tea cup").withQuantity(1).build())
            .build();

    Order orderWithDiscount = coffeeMugAndTeaCup.toBuilder().withDiscountRate(0.1).build();
    Order orderWithCouponCode = coffeeMugAndTeaCup.toBuilder().withCouponCode("HALFOFF").build();

Now Lombok builder is pretty close to the expressiveness of our custom builder.

The obvious benefit of using Lombok is that we get rid of a lot of boilerplate code. However, our code is also a little noisier because we have to write, e.g.:

    .withOrderItem(anOrderItem().withName("Coffee mug").withQuantity(1).build())

instead of:

    .with(anOrderItem().withName("Coffee mug").withQuantity(1))

There is one more issue, though. There are no safe default values for our fields. We could add default values in our production code, but it’s not a good idea to do that only for tests.

To deal with the problem of not having safe default values, we can take the idea of the object mother pattern and use that together with our Lombok-generated builders.

public class Orders {
    public static Order.OrderBuilder anOrder() {
        return Order.anOrder()
                .withOrderId(1L)
                .withCustomer(Customers.aCustomer().build())
                .withDiscountRate(0.0);
    }
}

public class Customers {
    public static Customer.CustomerBuilder aCustomer() {
        return Customer.aCustomer()
                .withCustomerId(1L)
                .withName("Unimportant")
                .withAddress(Addresses.anAddress().build());
    }
}

Instead of using the Lombok-generated factory method, we can use the factory method from the object mother. The only thing we have to do is to change the static imports. Basically we are calling Orders.anOrder() instead of Order.anOrder(), for example.

Since we cannot pass around builders as arguments, the code is still a little noisier than our custom builder. Another drawback is that our builder will now always unnecessarily construct defaults for objects whose value we override in our tests.

Describe the target audience All Java developers writing tests in Java based on Test Data Builder.

janrieke commented 1 year ago

Groovy's @Builder doesn't create a real builder, it is more like Lombok's @Accessors(chained=true). Another way to go could be @With.

CamilYed commented 1 year ago

You mean sth like this?

@AllArgsConstructor
@NoArgsConstructor
public class Order {
    @Default @With private Long orderId = 1L;
    private Customer customer = aCustomer().build();
    private List<OrderItem> orderItems = Lists.of(anOrderItem().build());
    @Default @With private Double discountRate = 10d;
    @Default @With private String couponCode = "xaxadxa";

    Order withCustomer(Customer.CustomerBuilder customer) {
       this.customer = customer.build();
       return this;
    }

    Order withOrderItems(OrderItem.OrderItemBuilder... items) {
        this.items =  Arrays.stream(items).map(OrderItem.OrderItemBuilder::build).toList();
        return this;
    }
} 
janrieke commented 1 year ago

Yes, but I think @Accessors(chained=true) would be more similar to Groovy's @Builder.

It's not as powerful as Lombok's @Builder, e.g. it's missing a @Singular feature. However, I must admit I don't really see the problem with Lombok's current builder implementation. For me, the additional builder()/build() methods add clarity, because they follow the well-established builder idiom, and not the Groovy style (which is not really common).

CamilYed commented 1 year ago

I want to have Test Data Builder no common builder - that's a difference. In my tests I want to get rid off some technical concepts - one of them is the builder method.

Try to change your perspective for a while and try to treat your some tests like live documentation. So in my tests I want to have business language, and try to hide some technical implementation details.

From business perspective:

thereIs(anOrder().withAmount(eur(40)))

vs.

thereIs(anOrder().withAmount(eur(40)).build())
Rawi01 commented 1 year ago

A builder without build() method is impossible because you need some trigger to create the actual object. The only way you can drop the method is using @Accessors(chain = true, prefix = "with") or @With(er). The former will modify the object you passed and the later will create a new object after each method call.

You can combine @With with an object mother to handle the default values and you are done. There is no need to use @Builder if you don't want to create a builder.

CamilYed commented 1 year ago

@Rawi01 Are you sure that I have to use object mother when I use @With?

@AllArgsConstructor
@NoArgsConstructor
public class Order {
    @Default @With private Long orderId = 1L;
    private Customer customer = aCustomer().build();
    private List<OrderItem> orderItems = Lists.of(anOrderItem().build());
    @Default @With private Double discountRate = 10d;
    @Default @With private String couponCode = "xaxadxa";

    Order withCustomer(Customer.CustomerBuilder customer) {
       this.customer = customer.build();
       return this;
    }

    Order withOrderItems(OrderItem.OrderItemBuilder... items) {
        this.items =  Arrays.stream(items).map(OrderItem.OrderItemBuilder::build).toList();
        return this;
    }
} 

Unfortunately id doest not work I can not mix these annotation.

It should works.

@AllArgsConstructor
@NoArgsConstructor
public class Order {
    @With private Long orderId = 1L;
    private Customer customer = aCustomer().build();
    private List<OrderItem> orderItems = Lists.of(anOrderItem().build());
    @With private Double discountRate = 10d;
    @With private String couponCode = "xaxadxa";

    Order withCustomer(Customer.CustomerBuilder customer) {
       this.customer = customer.build();
       return this;
    }

    Order withOrderItems(OrderItem.OrderItemBuilder... items) {
        this.items =  Arrays.stream(items).map(OrderItem.OrderItemBuilder::build).toList();
        return this;
    }
} 
Rawi01 commented 1 year ago

You only need a object mother if you want to add default values for your objects in your tests. You can also add your test data as default values in your class but I would not design it like that.

You marked this issue as feature request so please add a detailed description what lombok should generate for a given set of annotations (before and after snippet). If you need general coding support please use something like StackOverflow, the forum or ask ChatGPT.

CamilYed commented 1 year ago

You only need a object mother if you want to add default values for your objects in your tests. You can also add your test data as default values in your class but I would not design it like that.

In my case this class is under test source set, so I don't see any problem.

You marked this issue as feature request so please add a detailed description what lombok should generate for a given set of annotations (before and after snippet). If you need general coding support please use something like StackOverflow, the forum or ask ChatGPT.

Ok I will try give some examples

topr commented 4 months ago

@CamilYed hi, I know this isn't really an answer to your question if you like to achieve it solely with Java, but if you still happen to use a mix like Java for prod code and Groovy/Spock for tests, I've got an even nicer way of achieving the Test Data Builder pattern with a better SNR.

For a different reason but I have posted here a simple example of how to incorporate Groovy custom DSL based on a Java Lombok generated builder to have clean prod code with no default and all the defaults set inside of the Test Data Builder (to which I refer there as test fixture DSL) and then how it's used on the test side in a very readable manner (well, at least unless it doesn't have a setterPrefix set, as then it may not be so readable and not even work at all 😓

https://github.com/projectlombok/lombok/issues/3159#issuecomment-2129888213