Spring實戰:為測試方法重置自增列
當我們為往數據庫中保存信息的方法寫集成測試的時候,我們必須驗證是否保存了正確的信息。如果程序使用了Spring框架,我們可以使用Spring Test DbUnit 和 DbUnit。然而,驗證主鍵列的值是否正確仍然非常困難。因為主鍵一般是用自增列自動生成的。這篇博文首先說明關于自動生成列的問題,然后提出解決辦法。
我們不能斷言未知
讓我們先給CrudRepository接口的save()方法寫兩個集成測試。這些測試如下描述:
- 第一個測試驗證在Todo對象的標題和描述都已設置的情況下,數據庫里保存了正確的信息。
- 第二個測試驗證在只有標題已設置的情況下,數據庫里保存了正確的信息。 </ul>
<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語句,并調用它們。
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"); } }
補充信息:
- 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)。
首先, 必須把重置SQL的模板添加到例子程序的配置文件里。該模板必須使用String類的format()方法支持的格式。因為我們的例程使用H2內存數據庫,我們必須把下面的SQL模板添加到配置文件里:
test.reset.sql.template=ALTER TABLE %s ALTER COLUMN id RESTART WITH 1
附加信息:
第二,必須在調用測試方法之前,重置todos表的自增列(id)。我們可以通過對ITTodoRepositoryTest 類做以下修改來完成:
- 往測試類注入ApplicationContext 對象,它包含了我們例程的配置信息。
- 重置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