Spring实战:为测试方法重置自增列
当我们为往数据库中保存信息的方法写集成测试的时候,我们必须验证是否保存了正确的信息。如果程序使用了Spring框架,我们可以使用Spring Test DbUnit 和 DbUnit。然而,验证主键列的值是否正确仍然非常困难。因为主键一般是用自增列自动生成的。这篇博文首先说明关于自动生成列的问题,然后提出解决办法。
我们不能断言未知
让我们先给CrudRepository接口的save()方法写两个集成测试。这些测试如下描述:
- 第一个测试验证在Todo对象的标题和描述都已设置的情况下,数据库里保存了正确的信息。
- 第二个测试验证在只有标题已设置的情况下,数据库里保存了正确的信息。
<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.
必须找一个更好的方案。
寻找更好的方案
我们找到了两种解决问题的方案,但是它们都带来了新的问题。基于下面的想法,我们有第三种解决方案:
如果我们不知道插入自增列的下一个值,我们必须在每个测试方法执行之前重置自增列。
可以用下面的步骤:
- 创建一个用来重置指定数据库表的自增列的类。
- 修改我们的集成测试。
让我们开始吧。
创建一个可以重置自增列的类
我们可以用下面的步骤来创建一个可以重置指定数据表自增列的类:
- 创建一个叫DbTestUtil 的final类,添加私有的构造方法来避免实例化。
- 给它添加一个public static void resetAutoIncrementColumns() 方法。这个方法有两个参数:
- ApplicationContext 对象。它包含了测试程序的配置信息。
- 需要重置自增列的数据表的名字.
- 用以下步骤实现这个方法:
- 获得DataSource对象的引用.
- 用’test.reset.sql.template’从配置文件(application.properties) 中读取SQL模板
- 打开数据库连接.
- 创建SQL语句,并调用它们。 </ol> </li> </ol>
- The Javadoc of the ApplicationContext interface
- The Javadoc of the DataSource interface
- The Javadoc of the Environment interface
- The Javadoc of the String.format() method
- 把重置SQL模板添加到示例程序的配置文件里。
- 在调用测试方法之前,重置todos表的自增列(id)。
- 往测试类注入ApplicationContext 对象,它包含了我们例程的配置信息。
- 重置todos表的自增列。
- 如果不能得到插入列的自动生成的值的话,就无法写有用的集成测试。
- 如果我们的程序没有太多的集成测试,使用 @DirtiesContext 注解可能是一个好的选择。
- 如果程序有很多集成测试,我们必须再调用每个测试方法之前重置自增列。
- 测试用的程序在另一篇博文中已经描述过了: 实战Spring:在DbUnit数据集中使用空值。建议你首先阅读,在本文中将不再重复其内容。
- 如果你不知道怎么给储存库写集成测试,你应该阅读这篇博文:Spring数据持久化导论之集成测试。它解释了应该如何为Spring数据持久化库写集成测试,对于其他基于Spring使用关系型数据库的代码,你也可以用同样的方法。
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"); } }
补充信息:
让我们继续,看看怎么在集成测试中使用这个类。
修好我们的集成测试
我们可以通过下面的步骤来修好集成测试:
首先, 必须把重置SQL的模板添加到例子程序的配置文件里。该模板必须使用String类的format()方法支持的格式。因为我们的例程使用H2内存数据库,我们必须把下面的SQL模板添加到配置文件里:
test.reset.sql.template=ALTER TABLE %s ALTER COLUMN id RESTART WITH 1
附加信息:
第二,必须在调用测试方法之前,重置todos表的自增列(id)。我们可以通过对ITTodoRepositoryTest 类做以下修改来完成:
改好的集成测试源代码如下所示(修改高亮显示):
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); } }
附加信息:
再次运行集成测试,都通过了。让我们总结一下我们从这篇博文里学到了什么。
总结
这篇博文教会了我们三件事:
你可以从 Github下载例程。
补充阅读
关于作者 Petri Kainulainen
Petri对软件开发和持续改进很有热情。他是Spring框架的软件开发专家,并且是<Spring Data>一书的作者。
原文链接: javacodegeeks 翻译: ImportNew.com - 冲哥Bob
译文链接: http://www.importnew.com/14129.html