1. 概述

在单元测试中设置数据通常是一个手动过程,涉及大量样板代码。当测试包含多个字段、关系和集合的复杂类时尤其如此。更重要的是,这些值本身往往并不重要,我们真正需要的是值的存在。这通常通过类似 person.setName("test name") 的代码实现。

本教程将介绍 Instancio 如何通过创建完全填充的对象来帮助生成单元测试数据。我们将涵盖对象的创建、自定义以及测试失败时的复现方法。

2. 关于 Instancio

Instancio 是一个测试数据生成器,用于自动化单元测试中的数据设置。其目标是通过尽可能消除手动数据设置,使单元测试更简洁、更易维护。 简单来说,我们向 Instancio 提供一个类,它会返回一个填充了可复现随机数据的完整对象。

3. Maven 依赖

首先从 Maven Central 添加依赖。由于示例使用 JUnit 5,我们导入 instancio-junit

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

或者,对于独立使用、JUnit 4 或其他测试框架,可使用 instancio-core

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

4. 生成对象

使用 Instancio 可以创建多种类型的对象,包括:

  • 简单值(如字符串、日期、数字)
  • 常规 POJO(包括 Java records)
  • 集合、映射和流
  • 通过类型令牌创建任意泛型类型

Instancio 在填充对象时使用合理的默认值:

  • ✅ 非空值
  • ✅ 非空字符串
  • ✅ 正数
  • ✅ 包含少量元素的非空集合

API 入口是 Instancio.create()Instancio.of() 方法。创建 POJO:

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

创建集合和流:

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

使用 TypeToken 创建任意泛型类型:

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

接下来看看如何自定义生成的数据。

5. 自定义对象

编写单元测试时,我们经常需要创建不同状态的对象。状态通常取决于被测试的功能。例如,验证正常路径需要有效对象,验证验证错误需要无效对象

使用 Instancio 可以:

  • 通过 set()supply()generate() 方法自定义值
  • 使用 ignore() 方法忽略特定字段和类
  • 使用 withNullable() 方法允许生成 null 值
  • 使用 subtype() 方法为抽象类型指定实现

5.1. 选择器

Instancio 使用选择器指定要自定义的字段和类。上述所有方法都接受选择器作为第一个参数。可通过 Select 类的静态方法创建选择器。

例如,通过以下方式选择特定字段:

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

也可通过类或谓词选择类型:

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

all() 方法基于严格类相等(仅匹配 Collection 声明,不匹配 ListSet),而 types().of() 会匹配 Collection 及其子类型。

5.2. 使用 set()

set() 方法用于设置非随机(预期)值:

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

常见误区:为什么不在对象创建后使用常规 setter(如 phone.setCountryCode("49"))?
原因set() 方法会为所有生成的 Phone 实例设置国家代码。由于 Student 包含 List<Phone> 字段,使用常规 setter 需要遍历列表。此外,对于不可变类(如 Java records),创建后无法修改对象。

5.3. 使用 supply()

supply() 方法有两种变体:使用 Supplier 分配非随机值,或使用 Generator 生成随机值:

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();

出生日期由 Generator lambda 提供。Generator 是一个函数式接口,提供带种子的 Random 实例,保证对象完全可复现。

5.4. 使用 generate()

通过 generate() 方法可使用内置数据生成器自定义值。Instancio 为常用 Java 类型提供生成器(字符串、数字、集合、数组、日期等)。

gen 变量提供对可用生成器的访问,每个生成器都提供流畅的 API:

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. 使用 ignore()

使用 ignore() 方法可跳过某些字段或类的填充。例如测试持久化 Student 到数据库时,需要生成 id 为 null 的对象:

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

5.6. 使用 withNullable()

虽然 Instancio 倾向于生成完全填充的对象,但有时需要验证某些可选字段为 null 时的行为:

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

⚠️ 传统方法痛点:使用静态数据需要为 null 和非 null 值创建单独的测试方法,或使用参数化测试。当可选字段较多时非常耗时。上述方法允许用单个测试方法验证不同输入组合。

5.7. 使用 subtype()

subtype() 方法可为抽象类型指定实现,或为具体类型指定子类。例如 ContactInfo 类声明 List<Phone> 字段:

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

未指定时 Instancio 默认使用 ArrayList,可通过 subtype() 覆盖此行为。

6. 使用模型

Instancio 模型是通过 API 表达的对象模板。从模型创建的对象将继承所有模型属性。通过调用 toModel() 创建模型:

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();

定义模型后,可在所有测试方法中复用。每个测试方法可将模型作为基础并按需自定义。

例如测试需要选修 10 门课程且成绩均为 A 或 B 的学生:

@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);
    
    // 模型定义的其他数据:
    assertThat(student.getEnrollmentYear()).isLessThan(Year.now());
    assertThat(student.getContactInfo().getEmail()).matches("^[a-zA-Z0-9][email protected]$");
    // ...

另一个测试需要包含不及格课程的学生:

@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);
}

此测试是 @ParameterizedTest@InstancioSource 会自动填充方法参数指定的对象。

7. 使用 Instancio JUnit 5 扩展

使用随机数据的常见担忧是测试可能因特定数据集而失败。无论根本原因如何,Instancio 生成完全可复现的数据,使用 InstancioExtension 可轻松复现失败测试。

7.1. 复现测试失败

示例:为学生注册新课程。但 EnrollmentService 在学生有 F 成绩时抛出异常。以下测试可能通过也可能失败:

@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();
    }
}

若测试失败,会输出错误信息(包含测试方法名和种子值):

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

使用报告的种子值复现失败:

@Seed(1234)
@Test
void whenGivenNoFailingGrades_thenShouldEnrollStudentInCourse() {
    // 测试代码不变
}

此例中失败由数据设置错误导致。修复方法:排除 F 成绩的生成:

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

注册服务成功测试了所有有效成绩,且仅通过单个测试方法实现。

7.2. 注入自定义设置

扩展的另一功能是通过 @WithSettings 注入 Settings。例如默认不生成空集合,但某些测试场景需要空集合:

@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);
    }
}

注入的设置将应用于此类中创建的所有对象。调用 lock() 使 Settings 实例不可变,防止测试方法意外修改共享设置。

8. 结论

本文介绍了如何使用 Instancio 自动生成测试数据以消除手动数据设置。我们还展示了如何使用模型为单个测试方法创建自定义对象而无需样板代码。最后介绍了 JUnit 5 的 InstancioExtension 如何帮助复现失败测试。

更多详情请查看 Instancio 用户指南GitHub 项目主页

本文示例代码可在 GitHub 获取。


原始标题:Generate Unit Test Data in Java Using Instancio