当前版本仍在开发中,尚不被视为稳定版本。最新稳定版请使用 Spring Batch 文档 6.0.2!

单元测试

和其他应用风格一样,对批处理作业中编写的任何代码进行单元测试都非常重要。Spring 核心文档已经对如何使用 Spring 进行单元测试和集成测试做了非常详细的说明,这里不再重复。不过,如何对一个批处理作业进行“端到端”测试同样值得重点考虑,这也正是本章要说明的内容。spring-batch-test 项目提供了一些类,用于简化这种端到端测试方式。

创建单元测试类

为了让单元测试能够运行批处理作业,框架必须加载该作业对应的 ApplicationContext。通常使用以下两个注解来触发这一行为:

  • @SpringJUnitConfig 表示该类应使用 Spring 提供的 JUnit 测试支持

  • @SpringBatchTest 会在测试上下文中注入 Spring Batch 的测试工具类,例如 JobOperatorTestUtilsJobRepositoryTestUtils

如果测试上下文中只包含一个 Job bean 定义,那么这个 bean 会自动注入到 JobOperatorTestUtils 中。否则,就需要在 JobOperatorTestUtils 上手动设置要测试的 job。
从 Spring Batch 6.0 开始,JUnit 4 已不再受支持,建议迁移到 JUnit Jupiter。
  • Java

  • XML

下面的 Java 示例展示了这些注解的用法:

使用 Java 配置
@SpringBatchTest
@SpringJUnitConfig(SkipSampleConfiguration.class)
public class SkipSampleFunctionalTests { ... }

下面的 XML 示例展示了这些注解的用法:

使用 XML 配置
@SpringBatchTest
@SpringJUnitConfig(locations = { "/skip-sample-configuration.xml" })
public class SkipSampleFunctionalTests { ... }

批处理作业的端到端测试

“端到端”测试可以理解为:从头到尾验证一个批处理作业的完整运行过程。借助这种方式,测试可以先构造测试条件,再执行 job,最后校验最终结果。

假设有一个批处理作业从数据库读取数据并写入平面文件。测试方法首先会向数据库准备测试数据:先清空 CUSTOMER 表,再插入 10 条新记录。接着,测试通过 startJob() 方法启动该 JobstartJob() 方法由 JobOperatorTestUtils 提供;它同时还提供 startJob(JobParameters) 方法,用于在测试中传入特定参数。startJob() 会返回一个 JobExecution 对象,可用于断言此次 Job 运行的相关信息。在下面的示例中,测试会验证该 Job 是否以 COMPLETED 状态结束。

  • Java

  • XML

下面的代码清单展示了一个基于 Java 配置风格、使用 JUnit 5 的示例:

基于 Java 的配置
@SpringBatchTest
@SpringJUnitConfig(SkipSampleConfiguration.class)
public class SkipSampleFunctionalTests {

    @Autowired
    private JobOperatorTestUtils jobOperatorTestUtils;

    private JdbcTemplate jdbcTemplate;

    @Autowired
    public void setDataSource(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Test
    public void testJob(@Autowired Job job) throws Exception {
        this.jobOperatorTestUtils.setJob(job);
        this.jdbcTemplate.update("delete from CUSTOMER");
        for (int i = 1; i <= 10; i++) {
            this.jdbcTemplate.update("insert into CUSTOMER values (?, 0, ?, 100000)",
                                      i, "customer" + i);
        }

        JobExecution jobExecution = jobOperatorTestUtils.startJob();


        Assert.assertEquals("COMPLETED", jobExecution.getExitStatus().getExitCode());
    }
}

下面的代码清单展示了一个基于 XML 配置风格、使用 JUnit 5 的示例:

基于 XML 的配置
@SpringBatchTest
@SpringJUnitConfig(locations = { "/skip-sample-configuration.xml" })
public class SkipSampleFunctionalTests {

    @Autowired
    private JobOperatorTestUtils jobOperatorTestUtils;

    private JdbcTemplate jdbcTemplate;

    @Autowired
    public void setDataSource(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Test
    public void testJob(@Autowired Job job) throws Exception {
        this.jobOperatorTestUtils.setJob(job);
        this.jdbcTemplate.update("delete from CUSTOMER");
        for (int i = 1; i <= 10; i++) {
            this.jdbcTemplate.update("insert into CUSTOMER values (?, 0, ?, 100000)",
                                      i, "customer" + i);
        }

        JobExecution jobExecution = jobOperatorTestUtils.startJob();


        Assert.assertEquals("COMPLETED", jobExecution.getExitStatus().getExitCode());
    }
}

测试单个 Step

对于复杂的批处理作业,端到端测试方式下的测试用例可能会变得难以维护。在这种情况下,为每个单独的 step 编写专门测试通常会更有价值。JobOperatorTestUtils 类中包含一个名为 launchStep 的方法,它接收 step 名称,并只运行指定的那个 Step。这种方式可以让测试更具针对性,只为该 step 准备数据,并直接验证它的执行结果。下面的示例展示了如何通过名称使用 startStep 方法启动一个 Step

JobExecution jobExecution = jobOperatorTestUtils.startStep("loadFileStep");

测试 Step 作用域组件

很多时候,step 在运行时所使用的组件会依赖 step 作用域和延迟绑定,以便从 step 或 job 执行上下文中注入运行时信息。若把这些组件单独拿出来测试,通常会比较困难,除非你能人为构造出它们仿佛正处于 step 执行中的上下文环境。Spring Batch 中有两个组件正是为了解决这个问题:StepScopeTestExecutionListenerStepScopeTestUtils

这个 listener 声明在类级别,它的职责是为每个测试方法创建一个 step 执行上下文,如下例所示:

@SpringJUnitConfig
@TestExecutionListeners( { DependencyInjectionTestExecutionListener.class,
    StepScopeTestExecutionListener.class })
public class StepScopeTestExecutionListenerIntegrationTests {

    // This component is defined step-scoped, so it cannot be injected unless
    // a step is active...
    @Autowired
    private ItemReader<String> reader;

    public StepExecution getStepExecution() {
        StepExecution execution = MetaDataInstanceFactory.createStepExecution();
        execution.getExecutionContext().putString("input.data", "foo,bar,spam");
        return execution;
    }

    @Test
    public void testReader() {
        // The reader is initialized and bound to the input data
        assertNotNull(reader.read());
    }

}

这里有两个 TestExecutionListeners。其中一个是常规的 Spring Test 框架监听器,它会根据配置好的应用上下文完成依赖注入,从而把 reader 注入进来。另一个是 Spring Batch 提供的 StepScopeTestExecutionListener。它的工作方式是:在测试类中查找一个返回 StepExecution 的工厂方法,并将其返回结果作为测试方法的上下文,就好像该执行对象在运行时正处于某个 Step 中一样。这个工厂方法通过方法签名识别,也就是它必须返回一个 StepExecution。如果没有提供工厂方法,则会自动创建一个默认的 StepExecution

从 v4.1 开始,如果测试类上标注了 @SpringBatchTest,那么 StepScopeTestExecutionListenerJobScopeTestExecutionListener 会自动作为测试执行监听器导入。前面的测试示例可以简化为如下配置:

@SpringBatchTest
@SpringJUnitConfig
public class StepScopeTestExecutionListenerIntegrationTests {

    // This component is defined step-scoped, so it cannot be injected unless
    // a step is active...
    @Autowired
    private ItemReader<String> reader;

    public StepExecution getStepExecution() {
        StepExecution execution = MetaDataInstanceFactory.createStepExecution();
        execution.getExecutionContext().putString("input.data", "foo,bar,spam");
        return execution;
    }

    @Test
    public void testReader() {
        // The reader is initialized and bound to the input data
        assertNotNull(reader.read());
    }

}

如果你希望 step 作用域的持续时间恰好等于测试方法的执行周期,那么 listener 方式会比较方便。若想采用更灵活、但也更具侵入性的方式,可以使用 StepScopeTestUtils。下面的示例统计了前一个示例中 reader 可读取的 item 数量:

int count = StepScopeTestUtils.doInStepScope(stepExecution,
    new Callable<Integer>() {
      public Integer call() throws Exception {

        int count = 0;

        while (reader.read() != null) {
           count++;
        }
        return count;
    }
});

模拟领域对象

在为 Spring Batch 组件编写单元测试和集成测试时,另一个常见问题是如何模拟领域对象。一个典型例子是 StepExecutionListener,如下代码所示:

public class NoWorkFoundStepExecutionListener implements StepExecutionListener {

    public ExitStatus afterStep(StepExecution stepExecution) {
        if (stepExecution.getReadCount() == 0) {
            return ExitStatus.FAILED;
        }
        return null;
    }
}

前面的 listener 示例会检查某个 StepExecution 的读取计数是否为空,以此判断是否没有执行任何工作。虽然这个例子本身很简单,但它足以说明:当你尝试为那些实现了依赖 Spring Batch 领域对象接口的类编写单元测试时,通常会遇到什么样的问题。下面来看一个针对前述 listener 的单元测试:

private NoWorkFoundStepExecutionListener tested = new NoWorkFoundStepExecutionListener();

@Test
public void noWork() {
    StepExecution stepExecution = new StepExecution("NoProcessingStep",
                new JobExecution(new JobInstance(1L, new JobParameters(),
                                 "NoProcessingJob")));

    stepExecution.setExitStatus(ExitStatus.COMPLETED);
    stepExecution.setReadCount(0);

    ExitStatus exitStatus = tested.afterStep(stepExecution);
    assertEquals(ExitStatus.FAILED.getExitCode(), exitStatus.getExitCode());
}

由于 Spring Batch 的领域模型遵循良好的面向对象设计原则,要创建一个有效的 StepExecution,就必须先有 JobExecution,而 JobExecution 又依赖 JobInstanceJobParameters。这种设计对于稳固的领域模型来说是合理的,但也确实会让单元测试中的桩对象构造显得冗长。为了解决这个问题,Spring Batch 的测试模块提供了一个用于创建领域对象的工厂:MetaDataInstanceFactory。有了这个工厂之后,单元测试就可以写得更加简洁,如下所示:

private NoWorkFoundStepExecutionListener tested = new NoWorkFoundStepExecutionListener();

@Test
public void testAfterStep() {
    StepExecution stepExecution = MetaDataInstanceFactory.createStepExecution();

    stepExecution.setExitStatus(ExitStatus.COMPLETED);
    stepExecution.setReadCount(0);

    ExitStatus exitStatus = tested.afterStep(stepExecution);
    assertEquals(ExitStatus.FAILED.getExitCode(), exitStatus.getExitCode());
}

前面用于创建简单 StepExecution 的方法,只是该工厂提供的一个便捷方法而已。完整的方法列表可以在它的Javadoc中查看。