cristianprofile / test-factory-pattern

"Eliminating redundancy and enhancing clarity in test data generation."

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Test factory pattern

Reduce redundancy and enhancing clarity in test data generation.

Imagine a world without repetitive code and with software tests that are clear, readable, and expressive. In this article we will explain a technique that brings us one step closer to that ideal.

As we delve into test writing we encounter a recurring problem that prevents us from easily creating the necessary test data to cover all possible scenarios within our code.

To address this, we will introduce a straightforward way to eliminate duplication in object construction, allowing us to write more understandable and effective tests without filling our test code with details irrelevant to the behavior we are testing.

In this article, we will use Kotlin for the example code, writing all class definitions in Java except for the last one to showcase Kotlin's more powerful solution.

Test Cases using "new"

Imagine we have a class Order with the following structure in our code:

package examples.factory;
import java.io.Serializable;
import java.net.URI;
/**
* The type Order.
*/
public record Order(String reference, String name) implements Serializable {
}

Within our test, we need to create three different orders:

  • order: with reference "reference" and name "name."
  • order2: taking the reference from order, which is "reference," and having a different name, which is "name2."
  • order3: with a different reference, "reference2," and sharing the name with order, which is "name.

package com.example.testpattern
import examples.factory.Order
import examples.factory.Orders
import examples.factory.Products
import examples.factory.Users
import org.junit.Test
import kotlin.test.assertTrue
class TestJavaUsingNew
{
@Test
fun testJavaNewBuilder() {
val order = Order("reference", "name")
val order2 = Order(order.reference, "name2")
val order3 = Order("reference2", order.name)
assertTrue (order.reference == "reference")
assertTrue (order2.reference == "reference" && order2.name == "name2")
assertTrue (order3.reference == "reference2" && order3.name == "name")
}
}

This test demonstrates the creation of Order objects with different values. As you can see the test includes many unnecessary details related to object creation. If we introduce a new field or make changes to the constructor, we would have to modify many lines of code in the test, making it tightly coupled to how objects are created. This can lead to writing fragile tests that are not resilient to changes within our application.

Improving Object Creation with Builders

The Builder pattern is a widely used design pattern in object-oriented programming designed to provide a flexible solution to challenges related to creating complex objects. Its main goal is to separate the process of building a complex object from its representation.

In scenarios where classes require detailed and complicated setup, implementing a test data builder becomes common practice. This builder defines and sets values for each constructor parameter of the class simplifying the process of creating and configuring complex objects in test environments.

package examples.factory;
import org.instancio.Instancio;
import org.instancio.Select;
import org.instancio.settings.Settings;
import static org.instancio.settings.Keys.*;
public class Orders {
private static final Order ORDER = Instancio.of(Order.class)
.withSettings(Settings.create().set(FAIL_ON_ERROR, true))
.withSettings(Settings.create().set(MAX_DEPTH, 5))
.withSettings(Settings.create().set(COLLECTION_MIN_SIZE, 2))
.withSettings(Settings.create().set(COLLECTION_MAX_SIZE, 2))
.set(Select.field(Order::reference), "reference1")
.set(Select.field(Order::name), "name1")
.create();
private String reference = ORDER.reference();
private String name = ORDER.name();
public static Orders createOrder() {
return new Orders();
}
public Order build() {
return new Order(this.reference, this.name);
}
public Orders withReference(String reference) {
this.reference = reference ;
return this;
}
public Orders withName(String name) {
this.name = name ;
return this;
}
}

As we can see, we have created a "withxxx" method for each attribute that we want to modify, assigning default values created with the Instancio library in Java.

Instancio is a library used to simplify object creation in unit tests and for generating test data. It facilitates the creation of complex objects, such as those used in tests, by providing a more concise and readable approach to configure attribute values for those objects.

Instead of manually creating object instances and explicitly defining each of their attributes Instancio allows us to define an object of the desired class with its initial values in a simpler way.

package com.example.testpattern
import examples.factory.Orders
import org.junit.Test
import kotlin.test.assertTrue
class TestJavaBuilder {
@Test
fun testJavaBuilder() {
val orderWithReferenceBuilder = Orders.createOrder().withReference("R1")
val createOrder = orderWithReferenceBuilder.build()
val createOrder2 = orderWithReferenceBuilder.withName("name").build()
assertTrue(createOrder.reference == "R1")
assertTrue(createOrder2.reference == "R1" && createOrder2.name == "name")
}
}

Main advantages of this new approach using builders are:

  • The test code focuses only on attributes relevant to the test case. It allows assigning values only to the necessary attributes, improving code clarity.
  • Flexibility increases when creating objects with different attribute configurations.
  • Test code fragility decreases, as changes in object construction require adjustments only in the Builder, not in every place where the object is created. Test code becomes more readable and expressive, as each method in the Builder is responsible for assigning a specific value to an attribute making it easier to understand.

However, there is one aspect of the code that we can improve: the manual creation of "with" methods for each attribute we want to modify within the created builder.

Decrease Duplicate Code with Lombok

Although the implemented pattern for test data generation provides many benefits, it also has one major drawback: developers end up writing a lot of redundant code.

To address this issue of redundant code we can take advantage of Lombok. We can eliminate the default constructor getter methods and automatically create a class to generate "with" methods by annotating the class with Lombok's @With, @NoArgsConstructor, and @AllArgsConstructor annotations.

Here is an example of what a new Product class would look like using the builder pattern with Lombok's support.

package examples.factory;
import lombok.With;
import java.io.Serializable;
import java.net.URI;
/**
* The type Product.
*/
@With
public record Product(String reference, String name, Double size, URI picture) implements Serializable {
}

package examples.factory;
import static org.instancio.settings.Keys.COLLECTION_MAX_SIZE;
import static org.instancio.settings.Keys.COLLECTION_MIN_SIZE;
import static org.instancio.settings.Keys.FAIL_ON_ERROR;
import static org.instancio.settings.Keys.MAX_DEPTH;
import java.net.URI;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.With;
import org.instancio.Instancio;
import org.instancio.Select;
import org.instancio.settings.Settings;
@NoArgsConstructor
@AllArgsConstructor
@With
public class Products extends AbstractBuilder<Product> {
private static Product product = Instancio.of(Product.class)
.withSettings(Settings.create().set(FAIL_ON_ERROR, true))
.withSettings(Settings.create().set(MAX_DEPTH, 5))
.withSettings(Settings.create().set(COLLECTION_MIN_SIZE, 2))
.withSettings(Settings.create().set(COLLECTION_MAX_SIZE, 2))
.set(Select.field(Product::reference), "reference1")
.set(Select.field(Product::name), "product1")
.set(Select.field(Product::size), 12.0)
.set(Select.field(Product::picture), URI.create("https://aaa/image-603.png"))
.create();
private String reference = product.reference();
private String name = product.name();
private Double size = product.size();
private URI picture = product.picture();
public static Products createProduct() {
return new Products();
}
public Product build() {
return new Product(this.reference, this.name, this.size, this.picture);
}
}

Products class is annotated with Lombok's NoArgsConstructor, AllArgsConstructor, and With annotations, which help automatically generate no-argument constructors, all-argument constructors, and methods for cloning objects with modified fields, similar to the "with" methods created earlier but without having to write the repetitive code manually.

Bonus track: Product class is annotated with Lombok's @with that allows to copy immutable objects created using new record java classes

Now let's see an example of a test created for this class.

package com.example.testpattern
import examples.factory.Order
import examples.factory.Orders
import examples.factory.Products
import examples.factory.Users
import org.junit.Test
import kotlin.test.assertTrue
class TestLombok
{
@Test
fun testJavaLombokBuilder() {
val product = Products.createProduct().withName("pepe").build()
assertTrue (product.name.equals("pepe"))
val product2= product.
withReference("reference2").withName("angel").withReference("reference2")
assertTrue (product.name.equals("pepe"))
assertTrue (product2.name.equals("angel"))
assertTrue (product2.reference.equals("reference2"))
}
}

Decrease Code using Kotlin

For those who are not familiar with Kotlin it can be summarized as the Java of the year 2050. Created by JetBrains to write applications that run within the JVM with the power of more functional and less verbose languages than Java but without the learning complexity of languages like Scala or similar. At Profile we use it as a JDK-first choice when writing applications in our backend created with Spring. Let's look at the object construction pattern in Kotlin.

For this, we create a User class with a data class (analogous to Java's Record).

package examples.factory
data class User(val id: String, val name: String, val surname: String)

We create the factory for the User object (taking advantage of Kotlin's ability to manage default values within classes/functions to provide our builder with the values generated by Instancio).

package examples.factory
import org.instancio.Instancio
class Users() {
companion object {
val user = Instancio.of(User::class.java)
.create()
fun build(id: String = user.id , name: String = user.name, surname: String = user.surname) = User(id, name, surname)
}
}

Using our class factory we take advantage of another Kotlin feature that allows us to pass values to functions by their attribute name only passing only those attributes that need to be overridden by those generated by Instancio.(named arguments/named parameters)

The object construction by name in Kotlin is based on the use of named arguments. When you define a function in Kotlin, you can assign a name to each function parameter. Then when calling the function you can provide the arguments in any order using the name of the corresponding parameter.

In this case we call the "build" function, overriding the value of the "name" attribute defined by Instancio and assigning the value "cristian."

val user2 = Users.build(name = "cristian")

In this other case we call the "build" function, overriding the "name" attribute with "jose" and the "surname" attribute defined by Instancio with "gomez."

val user3 = Users.build(name = "jose", surname = "gomez")

For this case, we use the "copy" method of Kotlin's data class, which allows us to copy the entire object and change the properties that we define within it (if only Java Records had this feature). In this case we modify the "id" attribute associated with the user3 created with the value "33."

val userWithNewId = user3.copy(id = "33")

In this code repository we've explored various object creation patterns for testing making it more effective and expressive. We've demonstrated how these patterns can be applied in Kotlin, a language equipped with powerful tools for minimizing code and improving readability. As you navigate through your development projects we encourage you to consider adopting these patterns to simplify your tests and, lastly , enhance your software's quality. Thank you for accompanying us on this journey and feel free to experiment with these techniques in your own projects.

package com.example.testpattern
import examples.factory.Order
import examples.factory.Orders
import examples.factory.Products
import examples.factory.Users
import org.junit.Test
import kotlin.test.assertTrue
class TestKotlin
{
@Test
fun testKotlinBuilder() {
val user1 = Users.build()
val user2 = Users.build(name = "cristian")
val user3 = Users.build(name = "jose", surname = "gomez")
val userWithNewId = user3.copy(id = "33")
assertTrue (user1.name != null && user1.surname!=null && user1.id!=null )
assertTrue (user2.name == "cristian")
assertTrue (user3.name == "jose" && user3.surname == "gomez")
assertTrue (userWithNewId.id == "33" && user3.name == "jose" && user3.surname == "gomez")
}
}

About

"Eliminating redundancy and enhancing clarity in test data generation."


Languages

Language:Java 51.0%Language:Kotlin 49.0%