Spring实战:为测试方法重置自增列

jopen 9年前

当我们为往数据库中保存信息的方法写集成测试的时候,我们必须验证是否保存了正确的信息。如果程序使用了Spring框架,我们可以使用Spring Test DbUnitDbUnit。然而,验证主键列的值是否正确仍然非常困难。因为主键一般是用自增列自动生成的。这篇博文首先说明关于自动生成列的问题,然后提出解决办法。

我们不能断言未知

让我们先给CrudRepository接口的save()方法写两个集成测试。这些测试如下描述:

  • 第一个测试验证在Todo对象的标题和描述都已设置的情况下,数据库里保存了正确的信息。
  • 第二个测试验证在只有标题已设置的情况下,数据库里保存了正确的信息。
两个测试使用相同的DbUnit数据集(no-todo-entries.xml)初始化数据库,如下所示:
<dataset>      <todos/>  </dataset>

集成测试的源代码如下所示:

import com.github.springtestdbunit.DbUnitTestExecutionListener;  import com.github.springtestdbunit.annotation.DatabaseSetup;  import com.github.springtestdbunit.annotation.DbUnitConfiguration;  import com.github.springtestdbunit.annotation.ExpectedDatabase;  import org.junit.Before;  import org.junit.Test;  import org.junit.runner.RunWith;  import org.springframework.beans.factory.annotation.Autowired;  import org.springframework.context.ApplicationContext;  import org.springframework.test.context.ContextConfiguration;  import org.springframework.test.context.TestExecutionListeners;  import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;  import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;  import org.springframework.test.context.support.DirtiesContextTestExecutionListener;  import org.springframework.test.context.transaction.TransactionalTestExecutionListener;    @RunWith(SpringJUnit4ClassRunner.class)  @ContextConfiguration(classes = {PersistenceContext.class})  @TestExecutionListeners({ DependencyInjectionTestExecutionListener.class,          DirtiesContextTestExecutionListener.class,          TransactionalTestExecutionListener.class,          DbUnitTestExecutionListener.class })  @DbUnitConfiguration(dataSetLoader = ColumnSensingReplacementDataSetLoader.class)  public class ITTodoRepositoryTest {        private static final Long ID = 2L;      private static final String DESCRIPTION = "description";      private static final String TITLE = "title";      private static final long VERSION = 0L;        @Autowired      private TodoRepository repository;        @Test      @DatabaseSetup("no-todo-entries.xml")      @ExpectedDatabase("save-todo-entry-with-title-and-description-expected.xml")      public void save_WithTitleAndDescription_ShouldSaveTodoEntryToDatabase() {          Todo todoEntry = Todo.getBuilder()                  .title(TITLE)                  .description(DESCRIPTION)                  .build();            repository.save(todoEntry);      }        @Test      @DatabaseSetup("no-todo-entries.xml")      @ExpectedDatabase("save-todo-entry-without-description-expected.xml")      public void save_WithoutDescription_ShouldSaveTodoEntryToDatabase() {          Todo todoEntry = Todo.getBuilder()                  .title(TITLE)                  .description(null)                  .build();            repository.save(todoEntry);      }  }

这些集成测试不是很好,因为他们只测试了Spring数据JPA和Hibernate的正确性。不应该把时间浪费到测试框架上去。如果不信任框架,就不应该使用它。

如果你想学习如何为你访问数据的代码写集成测试,你可以读读我的这篇教程:给数据访问的代码写测试.

DbUnit数据集save-todo-entry-with-title-and-description-expected.xml是用来验证是否Todo对象的标题和描述被插入了todos表,如下所示:

<dataset>      <todos id="1" description="description" title="title" version="0"/>  </dataset>

DbUnit数据集(save-todo-entry-with-title-and-description-expected.xml)是用来验证是否只有Todo对象的标题被插入了todos表,如下所示:

<dataset>      <todos id="1" description="[null]" title="title" version="0"/>  </dataset>

当我们写集成测试时,如果有一个测试失败,我们可以看到下面的错误信息:

junit.framework.ComparisonFailure: value (table=todos, row=0, col=id)   Expected :1  Actual   :2

原因是todo表的id列是自增列,而调用它的集成测试首先”取”id 1。在第二次进行集成测试的时候,值2被存入id列,测试失败。

下面我们来看如何解决这个问题。

快速修复的办法?

有两种快速解决办法,如下所述:

第一, 我们可以用@DirtiesContext 来注解测试类,并且把classMode属性设置为DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD 这可以解决我们的问题,因为我们的程序在应用上下文加载时创建了一个新的内存数据库,而@DirtiesContext 确保了每个测试方法使用新的应用上下文。

测试类的配置如下所示:

import com.github.springtestdbunit.DbUnitTestExecutionListener;  import com.github.springtestdbunit.annotation.DatabaseSetup;  import com.github.springtestdbunit.annotation.DbUnitConfiguration;  import com.github.springtestdbunit.annotation.ExpectedDatabase;  import org.junit.Test;  import org.junit.runner.RunWith;  import org.springframework.beans.factory.annotation.Autowired;  import org.springframework.test.annotation.DirtiesContext;  import org.springframework.test.context.ContextConfiguration;  import org.springframework.test.context.TestExecutionListeners;  import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;  import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;  import org.springframework.test.context.support.DirtiesContextTestExecutionListener;  import org.springframework.test.context.transaction.TransactionalTestExecutionListener;    @RunWith(SpringJUnit4ClassRunner.class)  @ContextConfiguration(classes = {PersistenceContext.class})  @TestExecutionListeners({ DependencyInjectionTestExecutionListener.class,          DirtiesContextTestExecutionListener.class,          TransactionalTestExecutionListener.class,          DbUnitTestExecutionListener.class })  @DbUnitConfiguration(dataSetLoader = ColumnSensingReplacementDataSetLoader.class)  @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)  public class ITTodoRepositoryTest {    }

这看起来挺整洁,但不幸的是集成测试的性能会受到影响,因为每个测试方法调用之前,它都创建了新的应用上下文。这就是为什么不应该使用@DirtiesContext注解,除非必须这样做。

尽管这样,如果程序只有少量的集成测试,@DirtiesContext 注解带来的性能损失也是可以承受的。我们不应该仅仅因为会让测试变慢而抛弃这种方案。如果可以接受的话,使用@DirtiesContext 注解是一个很好的方案。

附加阅读

第二, 我们应该忽略数据集里todos元素的id属性,并且把 @ExpectedDatabase 注解的 assertionMode 属性设为 DatabaseAssertionMode.NON_STRICT 这能解决我们的问题,因为 DatabaseAssertionMode.NON_STRICT 的意思是忽略那些没有出现在数据集文件中的列和表。

断言模式是一个很有用的工具,它可以帮助我们忽略那些测试代码没有改变的表。但是,DatabaseAssertionMode.NON_STRICT 不是解决这个问题的正确工具,因为它只能允许我们写一些只能验证很少事情的数据集。

例如,我们不能使用下面的数据集:

<dataset>   <todos id="1" description="description" title="title" version="0"/>   <todos description="description two" title="title two" version="0"/>  </dataset>

如果使用DatabaseAssertionMode.NON_STRICT,那么数据集的每一行都必须指定同一列。换句话说,我们必须修改数据集,让它看起来像这样:

<dataset>      <todos id="1" description="[null]" title="title" version="0"/>  </dataset>

这没什么大不了,因为我们可以确信Hibernate往todos表的id列插入了正确的id。

但是如果每个todo条目都有多个标签,就可能有问题了。假设我们要写一个集成测试往数据库插入两条新的todo条目,然后建立DbUnit数据集来确保:

  • 标题为”title one”的条目有一个叫做“tag one”的标签。
  • 标题为”title two”的条目有一个叫做“tag two”的标签。

看起来像这样:

<dataset> <todos description=”description” title=”title one” version=”0″/> <todos description=”description two” title=”title two” version=”0″/> <tags name=”tag one” version=”0″/> <tags name=”tag two” version=”0″/> </dataset>

我们不能创建有用的DbUnit数据集,因为我们不知道存入数据库的todo条目的id.

必须找一个更好的方案。

寻找更好的方案

我们找到了两种解决问题的方案,但是它们都带来了新的问题。基于下面的想法,我们有第三种解决方案:

如果我们不知道插入自增列的下一个值,我们必须在每个测试方法执行之前重置自增列。

可以用下面的步骤:

  1. 创建一个用来重置指定数据库表的自增列的类。
  2. 修改我们的集成测试。

让我们开始吧。

创建一个可以重置自增列的类

我们可以用下面的步骤来创建一个可以重置指定数据表自增列的类:

  1. 创建一个叫DbTestUtil 的final类,添加私有的构造方法来避免实例化。
  2. 给它添加一个public static void resetAutoIncrementColumns() 方法。这个方法有两个参数:
    1. ApplicationContext 对象。它包含了测试程序的配置信息。
    2. 需要重置自增列的数据表的名字.
    </li>
  3. 用以下步骤实现这个方法:
    1. 获得DataSource对象的引用.
    2. 用’test.reset.sql.template’从配置文件(application.properties) 中读取SQL模板
    3. 打开数据库连接.
    4. 创建SQL语句,并调用它们。
    5. </ol> </li> </ol>

      DbTestUtil 代码如下:

      import org.springframework.context.ApplicationContext;  import org.springframework.core.env.Environment;    import javax.sql.DataSource;  import java.sql.Connection;  import java.sql.SQLException;  import java.sql.Statement;    public final class DbTestUtil {        private DbTestUtil() {}        public static void resetAutoIncrementColumns(ApplicationContext applicationContext,                                                   String... tableNames) throws SQLException {          DataSource dataSource = applicationContext.getBean(DataSource.class);          String resetSqlTemplate = getResetSqlTemplate(applicationContext);          try (Connection dbConnection = dataSource.getConnection()) {              //Create SQL statements that reset the auto increment columns and invoke               //the created SQL statements.              for (String resetSqlArgument: tableNames) {                  try (Statement statement = dbConnection.createStatement()) {                      String resetSql = String.format(resetSqlTemplate, resetSqlArgument);                      statement.execute(resetSql);                  }              }          }      }        private static String getResetSqlTemplate(ApplicationContext applicationContext) {          //Read the SQL template from the properties file          Environment environment = applicationContext.getBean(Environment.class);          return environment.getRequiredProperty("test.reset.sql.template");      }  }

      补充信息:

      让我们继续,看看怎么在集成测试中使用这个类。

      修好我们的集成测试

      我们可以通过下面的步骤来修好集成测试:

      1. 把重置SQL模板添加到示例程序的配置文件里。
      2. 在调用测试方法之前,重置todos表的自增列(id)。

      首先, 必须把重置SQL的模板添加到例子程序的配置文件里。该模板必须使用String类的format()方法支持的格式。因为我们的例程使用H2内存数据库,我们必须把下面的SQL模板添加到配置文件里:

      test.reset.sql.template=ALTER TABLE %s ALTER COLUMN id RESTART WITH 1

      附加信息:

      第二,必须在调用测试方法之前,重置todos表的自增列(id)。我们可以通过对ITTodoRepositoryTest 类做以下修改来完成:

      1. 往测试类注入ApplicationContext 对象,它包含了我们例程的配置信息。
      2. 重置todos表的自增列。

      改好的集成测试源代码如下所示(修改高亮显示):

      import com.github.springtestdbunit.DbUnitTestExecutionListener;  import com.github.springtestdbunit.annotation.DatabaseSetup;  import com.github.springtestdbunit.annotation.DbUnitConfiguration;  import com.github.springtestdbunit.annotation.ExpectedDatabase;  import org.junit.Before;  import org.junit.Test;  import org.junit.runner.RunWith;  import org.springframework.beans.factory.annotation.Autowired;  import org.springframework.context.ApplicationContext;  import org.springframework.test.context.ContextConfiguration;  import org.springframework.test.context.TestExecutionListeners;  import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;  import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;  import org.springframework.test.context.support.DirtiesContextTestExecutionListener;  import org.springframework.test.context.transaction.TransactionalTestExecutionListener;    import java.sql.SQLException;    @RunWith(SpringJUnit4ClassRunner.class)  @ContextConfiguration(classes = {PersistenceContext.class})  @TestExecutionListeners({ DependencyInjectionTestExecutionListener.class,          DirtiesContextTestExecutionListener.class,          TransactionalTestExecutionListener.class,          DbUnitTestExecutionListener.class })  @DbUnitConfiguration(dataSetLoader = ColumnSensingReplacementDataSetLoader.class)  public class ITTodoRepositoryTest {        private static final Long ID = 2L;      private static final String DESCRIPTION = "description";      private static final String TITLE = "title";      private static final long VERSION = 0L;        @Autowired      private ApplicationContext applicationContext;        @Autowired      private TodoRepository repository;        @Before      public void setUp() throws SQLException {          DbTestUtil.resetAutoIncrementColumns(applicationContext, "todos");      }        @Test      @DatabaseSetup("no-todo-entries.xml")      @ExpectedDatabase("save-todo-entry-with-title-and-description-expected.xml")      public void save_WithTitleAndDescription_ShouldSaveTodoEntryToDatabase() {          Todo todoEntry = Todo.getBuilder()                  .title(TITLE)                  .description(DESCRIPTION)                  .build();            repository.save(todoEntry);      }        @Test      @DatabaseSetup("no-todo-entries.xml")      @ExpectedDatabase("save-todo-entry-without-description-expected.xml")      public void save_WithoutDescription_ShouldSaveTodoEntryToDatabase() {          Todo todoEntry = Todo.getBuilder()                  .title(TITLE)                  .description(null)                  .build();            repository.save(todoEntry);      }  }

      附加信息:

      再次运行集成测试,都通过了。让我们总结一下我们从这篇博文里学到了什么。

      总结

      这篇博文教会了我们三件事:

      • 如果不能得到插入列的自动生成的值的话,就无法写有用的集成测试。
      • 如果我们的程序没有太多的集成测试,使用 @DirtiesContext 注解可能是一个好的选择。
      • 如果程序有很多集成测试,我们必须再调用每个测试方法之前重置自增列。

      你可以从 Github下载例程

      补充阅读

      • 测试用的程序在另一篇博文中已经描述过了: 实战Spring:在DbUnit数据集中使用空值。建议你首先阅读,在本文中将不再重复其内容。
      • 如果你不知道怎么给储存库写集成测试,你应该阅读这篇博文:Spring数据持久化导论之集成测试。它解释了应该如何为Spring数据持久化库写集成测试,对于其他基于Spring使用关系型数据库的代码,你也可以用同样的方法。

      关于作者 Petri Kainulainen

      Petri对软件开发和持续改进很有热情。他是Spring框架的软件开发专家,并且是<Spring Data>一书的作者。

      原文链接: javacodegeeks 翻译: ImportNew.com - 冲哥Bob
      译文链接: http://www.importnew.com/14129.html

      </div>