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