CUBA 应用程序中的单元测试

在本指南中,您将了解如何在 CUBA 应用程序中进行自动化测试,会重点涉及如何执行单元测试以及何时应该在集成测试中使用单元测试的问题。

将要构建的内容

本指南对 CUBA 宠物诊所 示例进行增强,以演示如何对现有功能进行自动化单元测试:

  • 显示指定主人、指定类型的宠物数量

  • 为宠物计算建议的下一次检查日期

开发环境要求

您的开发环境需要满足以下条件:

下载 并解压本指南的源码,或者使用 git 克隆下来:

概述

在本指南中,您将学习如何使用无依赖的单元测试技术。 这种方法不同于我们讨论过的中间件集成测试(见 CUBA 指南: 中间件集成测试),后者创建了接近于生产的测试环境来运行测试。

在进行单元测试时,测试环境非常简单,因为基本上不需要提供 CUBA 框架的运行时。由于这个便利性,单元测试使您可以更轻松地启动被测系统并将其置于适当的状态以执行测试用例。它还避免了测试数据设置方面的一些问题,而且执行速度要快几个数量级。

单元测试

除了您在中间件集成测试指南中了解到的集成测试环境之外,还可以创建完全不启动 Spring 应用程序上下文或任何 CUBA Platform API 的测试用例。

不使用集成测试环境而以独立方式编写单元测试的主要好处是:

  • 使测试局部化

  • 测试执行速度

  • 更容易设置测试基础设施

在这种情况下,被测类直接通过构造函数实例化。由于 Spring 不参与类的实例管理,依赖注入在这种情况下不起作用。 因此,依赖(被测类所依赖的对象)必须手动实例化并直接传递到被测类中。

如果依赖了 CUBA API ,那么这些 API 就必须被模拟(Mocking)出来。

模拟 (Mocking)

模拟/存根是测试自动化中的一种常用方法,有助于模拟/控制并验证被测系统与之交互的某些外部部件,以尽可能地将 SUT 与外界隔离。

testing doc replacement

正如您在图中所看到的,有一个类作为被测系统(SUT),这个类使用了另外一个类,这里使用的另外一个类称为依赖项。

存根或模拟现在做以下事情:

为了将 SUT 与其他任何依赖项隔离开并做到测试的局部性,依赖项使用了存根(Stub)来实现,存根代替了真正的依赖项,但是在测试用例中,您可以控制存根的行为。这样,您就可以直接影响 SUT 的行为,即使它与另一个不能直接控制的依赖项有某些交互。

Mocking 框架

JUnit 本身不包含 Mocking 功能,专门的 Mocking 框架允许实例化 Stub/Mock 对象并在 JUnit 测试用例中控制其行为。在本指南中,您将了解 Mockito,它是 Java 生态系统中非常流行的 Stubbing/Mocking 框架。

一个 Mocking 示例

本示例演示如何配置一个 Stub 对象。在这个用例中,它将替换 CUBA 中 TimeSource API 的行为,原来的行为是检索当前时间戳,Stub 对象则返回 "昨天"。用 Mockito 定义 Stub/Mock 对象的行为的方法是使用下面所示的 Mockito API (3) :

MockingTest.java
@ExtendWith(MockitoExtension.class) (1)
class MockingTest {

  @Mock
  private TimeSource timeSource; (2)

  @Test
  public void theBehaviorOfTheTimeSource_canBeDefined_inATestCase() {
    // given:
    ZonedDateTime now = ZonedDateTime.now();
    ZonedDateTime yesterday = now.minusDays(1);
    // and: the timeSource Mock is configured to return yesterday when now() is called
    Mockito
        .when(timeSource.now())
        .thenReturn(yesterday); (3)
    // when: the current time is retrieved from the TimeSource Interface
    ZonedDateTime receivedTimestamp = timeSource.now(); (4)
    // then: the received Timestamp is not now
    assertThat(receivedTimestamp)
        .isNotEqualTo(now);
    // but: the received Timestamp is yesterday
    assertThat(receivedTimestamp)
        .isEqualTo(yesterday);
  }
}
1 MockitoExtension 使 @Mock 注解的属性可以自动实例化
2 TimeSource 实例使用 @Mock 进行了注解,将通过 Mockito 实例化
3 在测试用例中定义了模拟行为
4 调用方法现在返回昨天,而不是当前时间

可以从里下载完整的示例: MockingTest.java 。有了相应的行为描述后,剩下必须做的事就是将 Stub 对象的实例手动传递到要测试的类或系统中。

构造器注入

要做到这点,需要对产品代码进行一点修改。默认情况下,CUBA 和 Studio 使用字段注入。这表示,如果一个 Spring Bean A 依赖于另一个 Spring Bean B ,则通过在类型 A 中创建一个 B 类型字段来声明依赖关系,如下所示:

@Component
class SpringBeanA {

  @Inject (1)
  private SpringBeanB springBeanB; (2)

  public double doSomething() {
      int result = springBeanB.doSomeHeavyLifting(); (3)
      return result / 2;
  }
}
1 @Inject 指示 Spring 注入一个 SpringBeanB 的实例
2 这个字段声明依赖项
3 可以在 A 的业务逻辑中使用依赖项

在单元测试的上下文中,这种模式就不太合适,因为没有相应的方法来注入依赖项。基于构造函数的注入是一种注入依赖的理想方式。这种情况下,为需要注入依赖项的类定义一个专用的构造函数,并使用 @Inject 对其进行注解,同时将所需的依赖项定义为构造函数的参数。

@Component
class SpringBeanAWithConstructorInjection {

  private final SpringBeanB springBeanB;

  @Inject (1)
  public SpringBeanAWithConstructorInjection(
          SpringBeanB springBeanB (2)
  ) {
      this.springBeanB = springBeanB; (3)
  }

  public double doSomething() {
      int result = springBeanB.doSomeHeavyLifting(); (3)
      return result / 2;
  }
}
1 @Inject 指示 Spring 调用构造函数并将参数视为依赖项
2 将依赖项定义为构造函数的参数
3 手动将依赖项实例存储到一个字段

生产代码仍然会像以前一样工作,因为 Spring 两种注入都支持。在测试用例中,我们可以直接调用此构造函数,并将其传递给通过 Mockito 配置的模拟实例。

Petclinic 的功能测试

接下来,您将看到一些示例,这些示例会演示如何使用单元测试以快速且隔离的方式对 Petclinic 的功能进行自动测试。

指定 Owner 的宠物数量

第一个测试用例处理给定宠物类型及所有者的宠物数量的计算。这个功能在 UI 中会用到,当用户在相应的浏览界面中选择一个 Owner 时,在 UI 中调用此功能来显示宠物数量。

pets of type

下面的业务规则描述了这个功能:

  • 如果一只宠物的类型与请求的宠物类型匹配,则应计算此宠物,否则不计算。

实现

实现逻辑写在了 Owner 实体类中:

Pet.java
class Owner extends Person {
    // ...
    public long petsOfType(PetType petType) {
        return pets.stream()
            .filter(pet ->
                Objects.equals(petType.getName(), pet.getType().getName())
            )
            .count();
    }
}

这里使用了 Java 8 Streams API 遍历宠物列表,并过滤出与宠物类型名称匹配的宠物。然后,统计过滤出的宠物数量。

理想场景(Happy Path)测试用例

我们将从验证上述业务规则的三个测试用例开始。

OwnerTest.java
class OwnerTest {

  PetclinicData data = new PetclinicData();
  private PetType electric;
  private Owner owner;
  private PetType fire;

  @BeforeEach
  public void initTestData() {
    electric = data.electricType();
    fire = data.fireType();

    owner = new Owner();
  }

  @Test
  public void aPetWithMatchingPetType_isCounted() {
    // given:
    owner.pets = Arrays.asList( (1)
        data.petWithType(electric)
    );
    // expect:
    assertThat(owner.petsOfType(electric)) (2)
        .isEqualTo(1);
  }

  @Test
  public void aPetWithNonMatchingPetType_isNotCounted() {
    // given:
    owner.pets = Arrays.asList(
        data.petWithType(electric)
    );
    // expect:
    assertThat(owner.petsOfType(fire))
        .isEqualTo(0);
  }


  @Test
  public void twoPetsMatch_andOneNot_twoIsReturned() {
    // given:
    owner.pets = Arrays.asList(
        data.petWithType(electric),
        data.petWithType(fire),
        data.petWithType(electric)
    );
    // expect:
    assertThat(owner.petsOfType(electric))
        .isEqualTo(2);
  }
}
1 为主人配置的宠物
2 为特定类型执行 petsOfType ,并验证结果

第一个测试用例是正面测试用例,其中包含了最简单的测试夹具(Test Fixture) 设置。第二个测试用例是相同类型的,但是是负面测试,第三个测试用例是前两个测试用例的组合。

通常,使测试数据尽可能少和清晰是明智的做法,这样更能突出测试用例的真实目的。 通过这些测试,我们涵盖了理想场景下的所有代码路径(Happy Path)。

Happy Path 是一个术语,表示使用有效输入值且没有错误的情况下的所有代码执行路径。

但是,如果我们跳出理想状况再做进一步考虑,就会发现还有更多的测试用例需要编写。

极端测试用例

考虑一下该方法的一些极端情况,会发现有多种异常情况需要考虑。试想一下以下情况:

用户界面中的 LookupField 不是必填项。 如果用户没有选择类型,然后单击 OK,这时传入的 PetType 将为 null。在这种情况下,实现代码中会发生什么 ? 它将产生一个 NullPointerException

多加考虑测试中的极端情况非常重要,因为它可以暴露出实现中的问题,而这些问题只有在应用程序运行时才能发现。

现在,我们来处理这种情况,我们将创建一个测试用例来验证这个猜想:

OwnerTest.java
class OwnerTest {
  // ...
  @Test
  public void whenAskingForNull_zeroIsReturned() {
    // expect:
    assertThat(owner.petsOfType(null))
        .isEqualTo(0);
  }
}

在定义这种极端用例时,我们也定义了 API 的预期行为。在这种情况下,我们定义返回值应为零。运行测试用例会表明实际上会产生 NullPointerException

有了功能设计,基于设计编写测试用例,测试用例运行结果可以告诉我们何时达到预期状态,我们可以据此调整实现,使功能保持运转正常:

Owner.java
class Owner {
    public long petsOfType(PetType petType) {

        if (petType == null) {
            return 0L;
        }

        return pets.stream()
            .filter(pet -> Objects.equals(petType.getName(), pet.getType().getName()))
            .count();
    }
}

再次运行测试用例,我们将看到应用程序现在可以处理 "无效" 输入并做出合理的响应。

下一个类似的极端用例是主人的宠物之一没有被指定类型。由于实体模型定义中 Pet.type 不是必须的。 在这种情况下,我们要排除这些宠物。以下测试用表达了预期行为:

OwnerTest.java
class OwnerTest {
  // ...
  @Test
  public void petsWithoutType_areNotConsideredInTheCounting() {
    // given:
    Pet petWithoutType = data.petWithType(null);
    // and:
    owner.pets = Arrays.asList(
        data.petWithType(electric),
        petWithoutType
    );
    // expect:
    assertThat(owner.petsOfType(electric))
        .isEqualTo(1);
  }
}

执行测试,我们会看到现有的实现会产生 NullPointerException。 对实现进行修改以使其通过测试:

Owner.java
class Owner {
    public long petsOfType(PetType petType) {

        if (petType == null) {
            return 0L;
        }

        return pets.stream()
            .filter(pet -> Objects.nonNull(pet.getType())) (1)
            .filter(pet -> Objects.equals(petType.getName(), pet.getType().getName()))
            .count();
    }
}
1 在比较之前排除没有指定类型的宠物

至此,第一个示例单元测试就完成了。如您所见,考虑极端用例与测试 Happy Path 一样必要。这两种类型对于测试范围的完整性和可依赖的综合测试套件都很重要。

这里要注意的另一件事是,当您查看测试时,它没有涵盖从 UI 到业务逻辑(可能还有数据库) 的端到端功能,这恰好是单元测试的特点。而解决此问题的常见方法是也为其他部分单独创建单元测试。通常,还会创建一组较小的集成测试,以验证不同组件之间的正确交互。

先编写测试用例来给功能“设定目标” → 实现功能→使用测试用例来验证功能是否达标,这种方式称为 测试驱动开发 - TDD 。通过这样的工作流程,您可以获得很好的测试覆盖率以及其他一些好处。在修复错误的同时,它还为您提供了一个明确的终点,即您可以确定错误已修复。

下一次定期检查日期建议

下一个更全面的示例是 Petclinic 项目的功能,在为所选宠物计划下一次定期检查时,该功能允许用户检索信息。下一个定期检查日期是基于几个因素计算的。宠物的类型决定了检查周期以及上次定期检查的时间。

在专门的 Visit 编辑器界面中通过 UI 触发计算,以创建定期检查: VisitCreateRegularCheckup。 UI 触发的逻辑中使用了以下服务方法,其中包含用于计算的业务逻辑:

RegularCheckupService.java
public interface RegularCheckupService {

  @Validated
  LocalDate calculateNextRegularCheckupDate(
      @RequiredView("pet-with-owner-and-type") Pet pet,
      List<Visit> visitHistory
  );

}

服务的实现中定义了执行计算所需的两个依赖项。依赖在构造函数中声明,以便单元测试可以注入自己的依赖实现。

RegularCheckupServiceBean.java
@Service(RegularCheckupService.NAME)
public class RegularCheckupServiceBean implements RegularCheckupService {

  final protected TimeSource timeSource;

  final protected List<RegularCheckupDateCalculator> calculators;

  @Inject
  public RegularCheckupServiceBean(
      TimeSource timeSource,
      List<RegularCheckupDateCalculator> calculators
  ) {
    this.timeSource = timeSource;
    this.calculators = calculators;
  }

  @Override
  public LocalDate calculateNextRegularCheckupDate(
      Pet pet,
      List<Visit> visitHistory
  ) {
    // ...
  }
}

第一个依赖项是 CUBA 平台中的 TimeSource API,用于检索当前日期。第二个依赖项是 RegularCheckupDateCalculator 实例的列表。

该服务实现中的 calculateNextRegularCheckupDate 方法不包含实际的计算逻辑。此方法获取到所有可能的计算器类,从中筛选出唯一一个可以计算宠物下一个定期检查日期的计算器,然后将计算委托给它。

RegularCheckupDateCalculator 的 API 如下所示:

RegularCheckupDateCalculator.java
/**
 * API for Calculators that calculates a proposal date for the next regular checkup date
 */
public interface RegularCheckupDateCalculator {

  /**
   * defines if a calculator supports a pet instance
   * @param pet the pet to calculate the checkup date for
   * @return true if the calculator supports this pet, otherwise false
   */
  boolean supports(Pet pet);

  /**
   * calculates the next regular checkup date for the pet
   * @param pet the pet to calculate checkup date for
   * @param visitHistory the visit history of that pet
   * @param timeSource the TimeSource CUBA API
   *
   * @return the calculated regular checkup date
   */
  LocalDate calculateRegularCheckupDate(
      Pet pet,
      List<Visit> visitHistory,
      TimeSource timeSource
  );
}

这个接口定义了两个方法: supports 确定计算器是否能够计算给定宠物的日期建议。如果是,则调用第二个方法 calculateRegularCheckupDate 以执行计算。

该接口有多种实现,大多数实现只能为特定的宠物类型执行日期计算。

使用 Mocking 测试实现

要实例化 SUT: RegularCheckupService ,我们必须在构造函数中声明两个依赖项:

  • TimeSource

  • List<RegularCheckupDateCalculator>

在此测试示例中,我们将通过 MockitoTimeSource 提供一个存根(Stub) 实现。 对于计算器列表,我们将使用一个特定的测试实现,而不是使用 Mock 实例。这种实现也可以看作是存根,但不是通过 Mockito 来即时定义,而是在测试源码中专门定义了一个类 ConfigurableTestCalculator。它有两个静态配置选项:方法 supportcalculate 的返回结果。

ConfigurableTestCalculator.java
/**
 * test calculator implementation that allows to statically
 * define the calculation result
 */
public class ConfigurableTestCalculator
    implements RegularCheckupDateCalculator {

  private final boolean supports;
  private final LocalDate result;

  private ConfigurableTestCalculator(
          boolean supports,
          LocalDate result
  ) {
    this.supports = supports;
    this.result = result;
  }

  // ...

  /**
   * creates a Calculator that will answer true
   * to {@link RegularCheckupDateCalculator#supports(Pet)} for
   * test case purposes and returns the provided date as a result
   */
  static RegularCheckupDateCalculator supportingWithDate(LocalDate date) {
    return new ConfigurableTestCalculator(true, date);
  }

  @Override
  public boolean supports(Pet pet) {
    return supports;
  }

  @Override
  public LocalDate calculateRegularCheckupDate(
      Pet pet,
      List<Visit> visitHistory,
      TimeSource timeSource
  ) {
    return result;
  }
}

这里的测试实现是测试用例中进行依赖置换的另一种形式。从外部来看,使用 Mocking 框架与提供静态测试类效果相似。

由于对 RegularCheckupService 的单元测试应该以隔离的方式验证行为,该测试的重点不在于各种计算器实现,而在于验证 RegularCheckupService 服务的流程编排的正确性。

计算器的实现将在其专门的单元测试中进行测试。

测试 RegularCheckupService 中的业务流程

RegularCheckupService 的测试用例开始,以下测试用例涵盖了大部分业务流程功能:

  1. 只选择支持宠物实例的计算器来计算检查日期

  2. 如果一种宠物有多个计算器支持,则选择第一个

  3. 如果未找到计算器,则将“下个月” 作为建议的定期检查日期

您可以在下面找到测试用例的实现。

JUnit 注解 @DisplayName 用于为测试结果报告中的测试用例提供友好的描述。另外,它有助于将测试用例实现关联到测试用例描述。
RegularCheckupServiceTest.java
@ExtendWith(MockitoExtension.class)
class RegularCheckupServiceTest {
  //...
  @Mock
  private TimeSource timeSource;

  private PetclinicData data = new PetclinicData(); (1)

  @BeforeEach
  void configureTimeSourceToReturnNowCorrectly() {
    Mockito.lenient()
        .when(timeSource.now())
        .thenReturn(ZonedDateTime.now());
  }

  @Test
  @DisplayName(
      "1. only calculators that support the pet instance " +
      "will be asked to calculate the checkup date"
  )
  public void one_supportingCalculator_thisOneIsChosen() {
    // given: first calculator does not support the pet
    RegularCheckupDateCalculator threeMonthCalculator =
        notSupporting(THREE_MONTHS_AGO); (2)
    // and: second calculator supports the pet
    RegularCheckupDateCalculator lastYearCalculator =
        supportingWithDate(LAST_YEAR); (3)
    // when:
    LocalDate nextRegularCheckup = calculate( (4)
        calculators(threeMonthCalculator, lastYearCalculator)
    );
    // then: the result should be the result that the calculator that was supported
    assertThat(nextRegularCheckup)
        .isEqualTo(LAST_YEAR);
  }


  @Test
  @DisplayName(
      "2. in case multiple calculators support a pet," +
      " the first one is chosen to calculate"
  )
  public void multiple_supportingCalculators_theFirstOneIsChosen() {
    // given: two calculators are valid; the ONE_MONTH_AGO calculator is first
    List<RegularCheckupDateCalculator> calculators = calculators(
        supportingWithDate(ONE_MONTHS_AGO),
        notSupporting(THREE_MONTHS_AGO),
        supportingWithDate(TWO_MONTHS_AGO)
    );
    // when:
    LocalDate nextRegularCheckup = calculate(calculators);
    // then: the result is the one from the first calculator
    assertThat(nextRegularCheckup)
        .isEqualTo(ONE_MONTHS_AGO);
  }

  @Test
  @DisplayName(
      "3. in case no calculator was found, " +
      "next month as the proposed regular checkup date will be used"
  )
  public void no_supportingCalculators_nextMonthWillBeReturned() {
    // given: only not-supporting calculators are available for the pet
    List<RegularCheckupDateCalculator> onlyNotSupportingCalculators =
        calculators(
            notSupporting(ONE_MONTHS_AGO)
        );
    // when:
    LocalDate nextRegularCheckup = calculate(onlyNotSupportingCalculators);
    // then: the default implementation will return next month
    assertThat(nextRegularCheckup)
        .isEqualTo(NEXT_MONTH);
  }

  /*
   * instantiates the SUT with the provided calculators as dependencies
   * and executes the calculation
   */
  private LocalDate calculate(
          List<RegularCheckupDateCalculator> calculators
  ) {
    RegularCheckupService service = new RegularCheckupServiceBean(
        timeSource,
        calculators
    );

    return service.calculateNextRegularCheckupDate(
        data.petWithType(data.waterType()),
        Lists.emptyList()
    );
  }
  // ...
}
1 PetclinicData 作为测试方法中的数据持有者。由于单元测试中不存在 DB,因此它仅创建临时对象
2 notSupporting 提供了一个"不支持宠物" 的计算器 ( supports(Pet pet) 返回 false )
3 supportWithDate 提供了一个"支持宠物" 的计算器 ( supports(Pet pet) 返回 true ) 并返回传入的日期作为结果
4 calculate 方法中对服务进行实例化并执行方法

完整的源代码(包括 helper 方法和 Calculator 的测试实现) 可在示例项目中找到: RegularCheckupServiceTest.java

测试计算器

如上所述,RegularCheckupService 的单元测试不包括对各种计算器实现的测试。这样做的目的是为了利用隔离性和测试场景的本地化带来的优势。否则,RegularCheckupServiceTest 将包含所有不同计算器的测试用例以及业务流程逻辑。

将业务流程的测试用例和计算器实现分开,可以让我们创建一个比较简单的测试用例设置,以测试其中一个计算器。在此示例中,我们将了解 ElectricPetTypeCalculator 和相应的测试用例。

对于负责 Electric 类型宠物的计算器,它包含以下业务规则:

  1. 只有宠物类型的名称为 Electric 时,才应使用它

  2. Electric 宠物两次定期检查之间的间隔为一年

  3. 非定期检查的就诊不应影响计算

  4. 如果宠物在 Petclinic 系统中没有定期检查记录,则建议下个月

  5. 如果上次定期检查的时间超过一年,则应建议下个月

ElectricPetTypeCalculator 测试用例

您可以在下面找到验证这些业务规则的各种测试用例的实现。这个测试类中也使用 PetclinicData 类作为辅助测试类。

ElectricPetTypeCalculatorTest.java
@ExtendWith(MockitoExtension.class)
class ElectricPetTypeCalculatorTest {

  private PetclinicData data;
  private RegularCheckupDateCalculator calculator;

  private Pet electricPet;

  @BeforeEach
  void createTestEnvironment() {
    data = new PetclinicData();
    calculator = new ElectricPetTypeCalculator();
  }

  @BeforeEach
  void createElectricPet() {
    electricPet = data.petWithType(data.electricType());
  }

  @Nested
  @DisplayName(
      "1. it should only be used if the name of the pet type " +
      "   is 'Electric', otherwise not"
  )
  class Supports { (1)

    @Test
    public void calculator_supportsPetsWithType_Electric() {
      // expect:
      assertThat(calculator.supports(electricPet))
          .isTrue();
    }

    @Test
    public void calculator_doesNotSupportsPetsWithType_Water() {
      // given:
      Pet waterPet = data.petWithType(data.waterType());
      // expect:
      assertThat(calculator.supports(waterPet))
          .isFalse();
    }
  }

  @Nested
  class CalculateRegularCheckupDate {

    @Mock
    private TimeSource timeSource;

    private final LocalDate LAST_YEAR = now().minusYears(1); (2)
    private final LocalDate LAST_MONTH = now().minusMonths(1);
    private final LocalDate SIX_MONTHS_AGO = now().minusMonths(6);
    private final LocalDate NEXT_MONTH = now().plusMonths(1);

    private List<Visit> visits = new ArrayList<>();

    @BeforeEach
    void configureTimeSourceMockBehavior() {
      Mockito.lenient()
          .when(timeSource.now())
          .thenReturn(ZonedDateTime.now());
    }

    @Test
    @DisplayName(
        "2. the interval between two regular Checkups " +
        "   is one year for electric pets"
    )
    public void intervalIsOneYear_fromTheLatestRegularCheckup() {
      // given: there are two regular checkups in the visit history of this pet
      visits.add(data.regularCheckup(LAST_YEAR));
      visits.add(data.regularCheckup(LAST_MONTH));
      // when:
      LocalDate nextRegularCheckup =
          calculate(electricPet, visits); (3)
      // then:
      assertThat(nextRegularCheckup)
          .isEqualTo(LAST_MONTH.plusYears(1));
    }


    @Test
    @DisplayName(
        "3. Visits that are not regular checkups " +
        "   should not influence the calculation"
    )
    public void onlyRegularCheckupVisitsMatter_whenCalculatingNextRegularCheckup() {
      // given: one regular checkup and one surgery
      visits.add(data.regularCheckup(SIX_MONTHS_AGO));
      visits.add(data.surgery(LAST_MONTH));
      // when:
      LocalDate nextRegularCheckup =
          calculate(electricPet, visits);
      // then: the date of the last checkup is used
      assertThat(nextRegularCheckup)
          .isEqualTo(SIX_MONTHS_AGO.plusYears(1));
    }


    @Test
    @DisplayName(
        "4. in case the pet has not done a regular checkup " +
        "   at the Petclinic before, next month should be proposed"
    )
    public void ifThePetDidNotHavePreviousCheckups_nextMonthIsProposed() {
      // given: there is no regular checkup, just a surgery
      visits.add(data.surgery(LAST_MONTH));
      // when:
      LocalDate nextRegularCheckup =
          calculate(electricPet, visits);
      // then:
      assertThat(nextRegularCheckup)
          .isEqualTo(NEXT_MONTH);
    }


    @Test
    @DisplayName(
        "5. if the last Regular Checkup was performed longer than " +
        "   one year ago, next month should be proposed"
    )
    public void ifARegularCheckup_exceedsTheInterval_nextMonthIsProposed() {
      // given: one regular checkup thirteen month ago
      visits.add(data.regularCheckup(LAST_YEAR.minusMonths(1)));
      // when:
      LocalDate nextRegularCheckup =
          calculate(electricPet, visits);
      // then:
      assertThat(nextRegularCheckup)
          .isEqualTo(NEXT_MONTH);
    }

    private LocalDate calculate(Pet pet, List<Visit> visitHistory) {
      return calculator.calculateRegularCheckupDate(
          pet,
          visitHistory,
          timeSource
      );
    }
  }
}
1 Supports 定义了用于验证 supports() 方法行为的测试用例,并且通过 JUnit 的 @Nested 进行分组
2 静态定义了几个时间点作为测试夹具(test fixtures)和验证值
3 calculate 执行计算器(SUT)的相应方法

测试类使用了 JUnit 的 @Nested 注解。它允许定义具有相同上下文的测试用例组。 在这个示例中,对受测系统(SUT)的方法进行分组,@Nested class Supports {} 用于验证 support() 方法,@Nested class CalculateRegularCheckupDate 用于验证 API 的 calculate() 方法。

另外,外层上下文中定义了用于所有测试用例的测试数据和 @BeforeEach 设置方法。 而 timeSource 模拟(mock) 定义在 CalculateRegularCheckupDate 上下文中,因为它只有在此上下文中使用它。

单元测试的局限性

与集成测试相比,以隔离的方式进行单元测试有一些明显的优势。它启动了一个完全不同的、更小的测试环境,这使得可以更快地运行测试。同样,通过避免依赖应用程序的其他类和其基础框架,测试用例可以具有更好鲁棒性,受环境变化的影响更小。

另一方面,单元测试的隔离性也是一些局限产生的原因。为了达到这种隔离级别,我们需要对集成点上的某些假设进行基本的编码。对于测试环境以及模拟的依赖项都是如此。只要这些假设成立,一切都会工作地很好。但是一旦环境发生改变,编码的假设就有可能不再与实际相符。

这里举一个在测试环境中包含隐性假设的例子: 在 RegularCheckupService 示例中,只有当每个 Calculator 都是 Spring bean 时, 通过 List<RegularCheckupDateCalculator> 定义的依赖才能在生产代码中按预期工作。这意味着需要在 Calculator 类中添加 @Component 注解。 否则,将获取不到这些 Calculator 类,业务逻辑的整体行为将因此受到影响,比如在这里将返回默认值(下个月)。

此外,注入到列表中的实现的顺序决定了其被调用的顺序,因此所有的 Calculator 还需要使用 @Ordered 注解来定义调用顺序。

上述的这两种情况在是无法通过单元测试来进行测试的,因为我们不希望在单元测试中来测试这些类之间的交互,也不想在单元测试中引入相对较慢的测试容器。

单元测试应该是测试业务逻辑的主要机制。但是,要涵盖这些交互场景,实施集成测试也很重要。一个好的经验法则是仅在一个集成测试中测试这些场景。因此,不要再次在集成测试环境创建所有这些用例并执行,而是只将必要的部分放到集成测试。

在上面提到的示例中,测试用例 multiple_supportingCalculators_theFirstOneIsChosen 使用集成测试是一个更好的选择,这个测试用例验证是否正确地注入了的组件,并且使用了正确的组件来执行计算。

总结

在本指南中,我们学习了如何使用单元测试通过 JUnit 以隔离的方式测试功能。 在介绍中,您看到了使用普通单元测试和中间件集成测试之间的区别。特别是,我们没有使用类似于数据库这样的外部依赖项。另外,测试环境不依赖于测试容器,而是手动实例化 SUT 。

为了创建隔离的测试,有时您需要像我们在 RegularCheckupServiceTest 示例中那样利用一个 Mocking 框架。 Mocking 是一种控制 SUT 与其环境和依赖之间交互的方法。通过用特殊的测试实现置换特定的依赖,测试用例能够定义依赖的行为(使其用于验证目的)。在 Java 生态系统中,Mockito 允许配置依赖及期望的行为。

我们还学习到,在测试场景中,将基于字段的注入修改为基于构造函数的注入有助于将依赖项手动注入到 SUT 中。测试驱动开发(TDD) 从快速的反馈周期中获益匪浅。

与集成测试相比,使用简单的单元测试的好处是测试运行速度要快几个数量级。这个好处听起来像是一个不错的功能,但实际上,它更是一种积极有效的自动化测试之路。

除了开发测试用例的工作流程方面的好处外,我们还学习了架构上的好处:测试场景局部化, 控制 SUT 的涉及面,有助于更快地发现导致测试失败的根源。

最终,当涉及面较小时,测试夹具的设置也更容易创建。

除了带来的好处之外,我们还研究了单元测试的局限性。一个例子是,我们无法验证代码在特定环境下的行为是否正确。尽管我们的单元测试完美地表明了可能在 calculators 中正确定义了业务规则,但问题在于它们是否正确地使用了 Spring 注解是不能通过单元测试验证的。

因此,单元测试应与涵盖其余未解决问题的一组集成测试结合使用。通过这种组合,我们可以完全确定我们的应用程序确实能够按预期工作。