Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE

1. Overview

Setting up data in unit tests is typically a manual process involving many boilerplate code. This is especially true when testing complex classes that contain many fields, relationships, and collections. What’s more, the values themselves are often unimportant. Instead, what we usually need is the presence of a value. This is typically expressed with code like person.setName(“test name”).

In this tutorial, we’ll look at how Instancio can help generate unit test data by creating fully-populated objects. We’ll cover how objects can be created, customized, and reproduced in case of test failure.

2. About Instancio

Instancio is a test data generator for automating data setup in unit tests. Its goal is to make unit tests more concise and maintainable by eliminating manual data setup as much as possible. In a nutshell, we provide Instancio with a class, and it provides us with a fully-populated object filled with reproducible, randomly generated data.

3. Maven Dependencies

First, let’s add the dependency from Maven Central. Since we’ll be using JUnit 5 for our examples, we’ll import instancio-junit:

<dependency>
    <groupId>org.instancio</groupId>
    <artifactId>instancio-junit</artifactId>
    <version>2.9.0</version>
    <scope>test</scope>
</dependency>

Alternatively, the instancio-core dependency is available for using Instancio standalone, with JUnit 4, or other testing frameworks:

<dependency>
    <groupId>org.instancio</groupId>
    <artifactId>instancio-core</artifactId>
    <version>2.6.0</version>
    <scope>test</scope>
</dependency>

4. Generating Objects

Using Instancio, we can create different types of objects, including:

  • Simple values, such as Strings, dates, numbers
  • Regular POJOs, including Java records
  • Collections, Maps, and Streams
  • Arbitrary generic types using type tokens

Instancio uses sensible defaults when populating objects. Generated objects have:

  • non-null values
  • non-empty Strings
  • positive numbers
  • non-empty collections containing a few elements

The entry-point to the API are Instancio.create() and Instancio.of() methods. Using these methods, we can create POJOs:

Student student = Instancio.create(Student.class);

Collections and Streams:

List<Student> list = Instancio.ofList(Student.class).size(10).create();
Stream<Student> stream = Instancio.of(Student.class).stream().limit(10);

Arbitrary generic types using a TypeToken:

Pair<List<Foo>, List<Bar>> pairOfLists = Instancio.create(new TypeToken<Pair<List<Foo>, List<Bar>>>() {});

Next, let’s look at how generated data can be customized.

5. Customizing Objects

When writing unit tests, we often need to create objects in various states. The state typically depends on the functionality being tested. For example, we may need a valid object for verifying the happy path and an invalid object for verifying validation errors.

Using Instancio, we can:

  • customize generated values as needed via set(), supply(), and generate() methods
  • ignore certain fields and classes using ignore() method
  • allow null values to be generated using the withNullable() method
  • specify implementations for abstract types using the subtype() method

5.1. Selectors

Instancio uses selectors to specify which fields and classes to customize. All the methods listed above accept a selector as the first argument. We can create selectors using static methods provided by the Select class.

For example, we can select a particular field using a method reference, field name, or a predicate using the following methods:

Select.field(Address::getCity)
Select.field(Address.class, "city")
Select.fields().matching("c.*y").declaredIn(Address.class) // matches city, country
Select.fields(field -> field.getDeclaredAnnotations().length > 0)

We can also select types by specifying the class or using a predicate:

Select.all(Collection.class)
Select.types().of(Collection.class)
Select.types(klass -> klass.getPackage().getName().startsWith("com.example"))

The first method, all(), is based on strict class equality. In other words, it’ll match Collection declarations but not List or Set. The second method, on the other hand, will match Collection and its subtypes.

Let’s look at some examples in action. We’ll use selectors to customize objects. For convenience, we’ll assume a static import Select.*.

5.2. Using set()

The set() method is simply for setting non-random (expected) values:

Student student = Instancio.of(Student.class)
  .set(field(Phone::getCountryCode), "+49")
  .create();

A common question is why not use a regular setter, for example, phone.setCountryCode(“49”), after the object has been created? Unlike a regular setter, the set() method, as shown above, will set the country code on all generated Phone instances. Since our Student contains a List<Phone> field, using a regular setter would require us to iterate through the list.

Another reason is that sometimes we may work with immutable classes, for example, Java records. In such cases, it would not be possible to modify an object after it has been created.

5.3. Using supply()

The supply() method has two variants: one for assigning non-random values using a Supplier and another for generating random values using a Generator.

The following snippet illustrates both variants:

Student student = Instancio.of(Student.class)
  .supply(all(LocalDateTime.class), () -> LocalDateTime.now())
  .supply(field(Student::getDateOfBirth), random -> LocalDate.now().minusYears(18 + random.intRange(0, 60)))
  .create();

In this example, the date of birth is provided by a Generator lambda. Generator is a functional interface that provides us with a seeded instance of Random. Using this instance guarantees that the object will be reproducible in its entirety.

5.4. Using generate()

With the generate() method, we can customize values via built-in data generators. Instancio provides generators for the most commonly used Java types. This includes Strings, numeric types, collections, arrays, dates, and so on.

In the following example, the gen variable provides access to the available generators. Each one provides a fluent API to customize its values:

Student student = Instancio.of(Student.class)
  .generate(field(Student::getEnrollmentYear), gen -> gen.temporal().year().past())
  .generate(field(ContactInfo::getEmail), gen -> gen.text().pattern("#a#a#a#a#a#[email protected]"))
  .create();

5.5. Using ignore()

We can use the ignore() method if we don’t want certain fields or classes to be populated. Let’s assume we want to test persisting an instance of Student to the database. In this case, we want to generate an object with a null id.

We can accomplish this as follows:

Student student = Instancio.of(Student.class)
  .ignore(field(Student::getId))
  .create();

5.6. Using withNullable()

While Instancio is eager to generate fully-populated objects, sometimes this is not desirable. For example, we may want to verify that our code works correctly when some optional fields are null. We can accomplish this using the withNullable() method. As the name suggests, Instancio generates either an actual value or a null randomly.

Student student = Instancio.of(Student.class)
  .withNullable(field(Student::getEmergencyContact))
  .withNullable(field(ContactInfo::getEmail))
  .create();

With the traditional approach of using static data, we’d need to create separate test methods for null and non-null values. Alternatively, we could use parameterized tests. This can get time-consuming, especially if there are many optional fields. Generating objects, as shown above, allows us to have a single test method that verifies different permutations of inputs.

5.7. Using subtype()

The subtype() method allows us to specify an implementation for an abstract type or a subclass for a concrete type. Let’s take a look at the following example, where the ContactInfo class declares a List<Phone> field:

Student student = Instancio.of(Student.class)
  .subtype(field(ContactInfo::getPhones), LinkedList.class)
  .create();

Without specifying the list type explicitly, Instancio would use ArrayList as the default List implementation. We can override this behavior by specifying the subtype.

6. Using Models

An Instancio Model is an object template expressed via the API. Objects created from a model will have all the model’s properties. A model can be created by calling the toModel() method, as shown in the following example:

Model<Student> studentModel = Instancio.of(Student.class)
  .generate(field(Student::getDateOfBirth), gen -> gen.temporal().localDate().past())
  .generate(field(Student::getEnrollmentYear), gen -> gen.temporal().year().past())
  .generate(field(ContactInfo::getEmail), gen -> gen.text().pattern("#a#a#a#a#a#[email protected]"))
  .generate(field(Phone::getCountryCode), gen -> gen.string().prefix("+").digits().maxLength(2))
  .toModel();

With the model defined, we can now use it across all our test methods. Each test method can utilize the template as the base and apply customizations as needed.

Let’s assume we’re testing a method that requires a student who has taken ten courses and achieved either grade A or B in all the courses. We can use the Model defined above and customize the number of courses and grades:

@Test
void whenGivenGoodGrades_thenCreatedStudentShouldHaveExpectedGrades() {
    final int numOfCourses = 10;
    Student student = Instancio.of(studentModel)
      .generate(all(Grade.class), gen -> gen.oneOf(Grade.A, Grade.B))
      .generate(field(Student::getCourseGrades), gen -> gen.map().size(numOfCourses))
      .create();

    Map<Course, Grade> courseGrades = student.getCourseGrades();

    assertThat(courseGrades.values()).hasSize(numOfCourses)
      .containsAnyOf(Grade.A, Grade.B)
      .doesNotContain(Grade.C, Grade.D, Grade.F);
    
    // Remaining data is defined by the model:
    assertThat(student.getEnrollmentYear()).isLessThan(Year.now());
    assertThat(student.getContactInfo().getEmail()).matches("^[a-zA-Z0-9][email protected]$");
    // ...

We may also have another test that requires a student with a failed course. To achieve this, we need to populate the Map<Course, Grade> field of the Student to contain a course with a grade F.  Once again, we use our student Model as the base and override the properties we’re interested in:

@InstancioSource
@ParameterizedTest
void whenGivenFailingGrade_thenStudentShouldHaveAFailedCourse(Course failedCourse) {
    Student student = Instancio.of(studentModel)
      .generate(field(Student::getCourseGrades), gen -> gen.map().with(failedCourse, Grade.F))
      .create();

    Map<Course, Grade> courseGrades = student.getCourseGrades();
    assertThat(courseGrades).containsEntry(failedCourse, Grade.F);
}

In this example, we used the Map generator’s with(key, value) method to add the expected entry into the generated map.

Please note that this test method is a @ParameterizedTest. When @InstancioSource is used with a parameterized test, Instancio automatically provides populated objects that are specified as method arguments. We can specify as many arguments as needed.

Next, let’s look at using the Instancio extension for JUnit 5.

7. Using Instancio JUnit 5 Extension

A common concern about using random data is that a test may fail due to a particular data set that was generated. The failure could be due to an error in the setup code or a bug in the production code. Regardless of the root cause, Instancio generates fully-reproducible data, and using the InstancioExtension makes reproducing failed tests easy.

7.1. Reproducing Test Failures

To illustrate this with an example, we’ll enroll our Student in a new course. However, our EnrollmentService throws an exception if a student has at least one course with a grade of F. Therefore, the following test can either pass or fail, depending on the grades that were generated:

@ExtendWith(InstancioExtension.class)
class ReproducingFailedTest {

    EnrollmentService enrollmentService = new EnrollmentService();

    @Test
    void whenGivenNoFailingGrades_thenShouldEnrollStudentInCourse() {
        Course course = Instancio.create(Course.class);
        Student student = Instancio.create(Student.class);

        boolean isEnrolled = enrollmentService.enrollStudent(student, course);

        assertThat(isEnrolled).isTrue();
    }
}

If the test happens to fail, it will produce an error message reporting the test method’s name and seed value, for example:

timestamp = 2023-01-24T13:50:12.436704221, Instancio = Test method ‘enrollStudent’ failed with seed: 1234

Using the reported seed value, we can reproduce the failure by placing the @Seed(1234) annotation on the test method. Doing so will result in previously generated data being produced again, resulting in the same outcome:

@Seed(1234)
@Test
void whenGivenNoFailingGrades_thenShouldEnrollStudentInCourse() {
    // test code unchanged
}

In this example, the failure was caused by an error in the data setup. Therefore, we can simply exclude grade F from being generated in order to fix our test:

Student student = Instancio.of(Student.class)
  .generate(all(Grade.class), gen -> gen.enumOf(Grade.class).excluding(Grade.F))
  .create();

The enrollment service is being successfully tested against all valid grades, and we have achieved this using a single test method.

We can apply exactly the same workflow to handle a test failure caused by production code.

7.2. Injecting Custom Settings

Another feature the extension provides is Settings injection using @WithSettings annotation. For example, by default, Instancio does not generate empty collections. However, we may have test scenarios that require empty collections. We can use custom settings to override the default behavior as follows:

@ExtendWith(InstancioExtension.class)
class CustomSettingsTest {

    @WithSettings
    private static final Settings settings = Settings.create()
      .set(Keys.COLLECTION_MIN_SIZE, 0)
      .set(Keys.COLLECTION_MAX_SIZE, 3)
      .lock();

    @Test
    void whenGivenInjectedSettings_shouldUseCustomSettings() {
        ContactInfo info = Instancio.create(ContactInfo.class);

        List<Phone> phones = info.getPhones();
        assertThat(phones).hasSizeBetween(0, 3);
    }
}

The injected settings will be applied to all objects created within this test class. Although not required, we also call the lock() method to make the Settings instance immutable. This ensures that no test method will inadvertently modify the shared settings.

8. Conclusion

In this article, we looked at eliminating manual data setup in tests by auto-generating the data using Instancio. We also saw how Models could be used to create customized objects for individual test methods without boilerplate code. Finally, we looked at how the InstancioExtension for JUnit 5 helps reproduce failed tests.

For more details, check out the Instancio User Guide and the project home on GitHub.

As usual, the sample code used in this article is available over on GitHub.

Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE
res – REST with Spring (eBook) (everywhere)
Comments are closed on this article!