單元測試 - 探索java web 單元測試的正確姿勢
一丶起因
筆者一直聽聞TDD,自動化測試等高大上的技術名詞, 向往其中的便利之處, 但一直求而不得, 只因項目中有各種依賴的存在,其中最大的依賴便是數據庫. java web 項目大部分都是寫sql語句, 不依賴數據庫, 便測試不了sql語句的正確性, 但依賴數據庫又有種種不變之處. 除此之外, 還有種種類與類之間的依賴關系,很不方便. 遺憾的是, 網上各種文章參差不齊, 筆者所參與的項目很少甚至沒有單元測試, 修改代碼, 如履薄冰. 在苦思不得其解之際, 向優秀開源項目mybatis求取經驗, 終獲得一些答案.
二丶實踐思路
mybatis使用單元測試的方式是使用內存數據庫做單元測試,單元測試前,先根據配置以及數據庫腳本,初始化內存數據庫,然后再使用內存數據庫測試.所以,筆者也是采用這種思路.
除此之外, 還有各種類與類之間依賴關系, 筆者依據mybatis以及spring選擇使用mockito框架mock解決
所以選用的工具有 hsql內存數據庫, mockito mock工具, junit單元測試工具, spring-boot-test子項目
三丶實施測試
1. 在pom.xml添加hsql 以及mockito
<dependency> <groupId>org.hsqldb</groupId> <artifactId>hsqldb</artifactId> <version>2.5.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>3.1.0</version> <scope>test</scope> </dependency>
2. 在test/resources/databases/jpetstore 下添加hsql 數據源的配置, 其中顯示設置sql.syntax_mys=true 是對mysql的支持
driver=org.hsqldb.jdbcDriver ## 配置hsql最大程度兼容mysql url=jdbc:hsqldb:.;sql.syntax_mys=true username=sa password=
以及在該文件夾下配置初始化化mysql測試數據的腳本
3. 配置初始化內存測試庫
BaseDataTest.java 來源於mybatis,用於運行sql腳本,初始化測試庫
public abstract class BaseDataTest { public static final String BLOG_PROPERTIES = "org/apache/ibatis/databases/blog/blog-derby.properties"; public static final String BLOG_DDL = "org/apache/ibatis/databases/blog/blog-derby-schema.sql"; public static final String BLOG_DATA = "org/apache/ibatis/databases/blog/blog-derby-dataload.sql"; public static final String JPETSTORE_PROPERTIES = "org/apache/ibatis/databases/jpetstore/jpetstore-hsqldb.properties"; public static final String JPETSTORE_DDL = "org/apache/ibatis/databases/jpetstore/jpetstore-hsqldb-schema.sql"; public static final String JPETSTORE_DATA = "org/apache/ibatis/databases/jpetstore/jpetstore-hsqldb-dataload.sql"; public static UnpooledDataSource createUnpooledDataSource(String resource) throws IOException { Properties props = Resources.getResourceAsProperties(resource); UnpooledDataSource ds = new UnpooledDataSource(); ds.setDriver(props.getProperty("driver")); ds.setUrl(props.getProperty("url")); ds.setUsername(props.getProperty("username")); ds.setPassword(props.getProperty("password")); return ds; } public static PooledDataSource createPooledDataSource(String resource) throws IOException { Properties props = Resources.getResourceAsProperties(resource); PooledDataSource ds = new PooledDataSource(); ds.setDriver(props.getProperty("driver")); ds.setUrl(props.getProperty("url")); ds.setUsername(props.getProperty("username")); ds.setPassword(props.getProperty("password")); return ds; } public static void runScript(DataSource ds, String resource) throws IOException, SQLException { try (Connection connection = ds.getConnection()) { ScriptRunner runner = new ScriptRunner(connection); runner.setAutoCommit(true); runner.setStopOnError(false); runner.setLogWriter(null); runner.setErrorLogWriter(null); runScript(runner, resource); } } public static void runScript(ScriptRunner runner, String resource) throws IOException, SQLException { try (Reader reader = Resources.getResourceAsReader(resource)) { runner.runScript(reader); } } public static DataSource createBlogDataSource() throws IOException, SQLException { DataSource ds = createUnpooledDataSource(BLOG_PROPERTIES); runScript(ds, BLOG_DDL); runScript(ds, BLOG_DATA); return ds; } public static DataSource createJPetstoreDataSource() throws IOException, SQLException { DataSource ds = createUnpooledDataSource(JPETSTORE_PROPERTIES); runScript(ds, JPETSTORE_DDL); runScript(ds, JPETSTORE_DATA); return ds; } }
配置數據源, 初始化數據庫, 以及配置生成mapper
/** * 准備內存數據庫中的數據 * @author TimFruit * @date 19-11-17 上午10:50 */ @Configuration public class BaseDataConfig { public static final Logger logger=LoggerFactory.getLogger(BaseDataConfig.class); String dataPrefix= "databases/jpetstore/"; //數據源 @Bean("myDataSource") public DataSource createDataSource() { //創建數據源 logger.info("創建數據源..."); InputStream inputStream=FileUtil.getInputStream(dataPrefix+"jpetstore-hsql2mysql.properties"); Properties properties=new Properties(); try { properties.load(inputStream); } catch (IOException e) { throw new RuntimeException(e); } HikariDataSource dataSource=new HikariDataSource(); dataSource.setDriverClassName(properties.getProperty("driver")); dataSource.setJdbcUrl(properties.getProperty("url")); dataSource.setUsername(properties.getProperty("username")); dataSource.setPassword(properties.getProperty("password")); //准備數據 logger.info("准備數據..."); try { BaseDataTest.runScript(dataSource, dataPrefix+"jpetstore-mysql-schema.sql"); BaseDataTest.runScript(dataSource, dataPrefix+"jpetstore-mysql-dataload.sql"); } catch (Exception e) { throw new RuntimeException(e); } logger.info("准備數據完成..."); return dataSource; } // mapper @Bean public SqlSessionFactory createSqlSessionFactoryBean ( @Qualifier("myDataSource") DataSource dataSource, @Autowired MybatisProperties mybatisProperties) throws Exception { SqlSessionFactoryBean factory = new SqlSessionFactoryBean(); factory.setDataSource(dataSource); if (!ObjectUtils.isEmpty(mybatisProperties.resolveMapperLocations())) { factory.setMapperLocations(mybatisProperties.resolveMapperLocations()); } return factory.getObject(); } }
4. spring-boot-test對mockito的支持
使用@SpyBean和@MockBean修飾屬性, spring會對該類型的bean進行spy或者mock操作, 之后裝配該類型屬性的時候, 都會使用spy或者mock之后的bean進行裝配. 關於Mockito的使用可以看我前一篇文章
// @MockBean @SpyBean AccountMapper accountMapper; @SpyBean AccountService accountService; @Test public void shouldSelectAccount(){ Account mockAccount=createTimFruitAccount(); //打樁 doReturn(mockAccount) .when(accountMapper) .selectAccount(timfruitUserId); //測試service方法 Account result=accountService.selectAccount(timfruitUserId); //驗證 Assert.assertEquals(mockAccount, result); }
需要注意的時, 使用@SpyBean修飾Mapper類的時候, 需要設置mockito對final類型的支持, 否則會報"Mockito cannot mock/spy because : - final class"的異常
設置方式如下:
先在resources文件夾下,新建mockito-extensions文件夾,在該文件夾下新建名為org.mockito.plugins.MockMaker的文本文件,添加以下內容:
mock-maker-inline
5. 對Mapper sql 進行單元測試
避免各單元測試方法的相互影響, 主要利用數據庫的事務, 測試完之后, 回滾事務, 不應影響其他測試
@RunWith(SpringRunner.class) @SpringBootTest //加事務, 在單元測試中默認回滾測試數據, 各個測試方法互不影響 // https://blog.csdn.net/qq_36781505/article/details/85339640 @Transactional public class AccountMapperTests { @SpyBean AccountMapper accountMapper; @Test public void shouldSelectAccount(){ //given 測試數據庫中的數據 //when Account result=accountMapper.selectAccount("ttx"); //then Assert.assertEquals("ttx@yourdomain.com", result.getEmail()); } @Test public void shouldInsertAccount(){ //given Account timAccount=createTimFruitAccount(); //when accountMapper.insertAccount(timAccount); //then Account result=accountMapper.selectAccount(timfruitUserId); Assert.assertEquals(timfruitUserId, result.getUserid()); Assert.assertEquals(timAccount.getCity(), result.getCity()); Assert.assertEquals(timAccount.getAddr1(), result.getAddr1()); Assert.assertEquals(timAccount.getAddr2(), result.getAddr2()); } @Test public void shouldUpdateCountry(){ //given String userId="ttx"; String country="萬獸之國"; //when accountMapper.updateCountryByUserId(userId, country); //then Account result=accountMapper.selectAccount(userId); Assert.assertEquals(country, result.getCountry()); } @Test public void shouldDeleteAccount(){ //given String userId="ttx"; Account account=accountMapper.selectAccount(userId); Assert.assertTrue(account!=null); //when accountMapper.deleteAccountry(userId); //then account=accountMapper.selectAccount(userId); Assert.assertTrue(account==null); } @Test public void shouldSaveAccountBatch(){ //given //update String userId1="ttx"; String country1="天堂"; Account account1=accountMapper.selectAccount(userId1); account1.setCountry(country1); //insert String userId2=timfruitUserId; String country2="中國"; Account account2=createTimFruitAccount(); account2.setCountry(country2); List<Account> accountList=Arrays.asList(account1,account2); //when accountMapper.saveAccountBatch(accountList); //then account1=accountMapper.selectAccount(userId1); Assert.assertEquals(country1, account1.getCountry()); account2=accountMapper.selectAccount(userId2); Assert.assertEquals(country2, account2.getCountry()); } }
四丶后記
1. 這里僅僅只是對java web 單元測試實踐提供一種思路, 筆者尚未在真實項目中應用, 有可能存在一些坑, 如hsql對mysql的兼容性支持等.任重而道遠
2. 單元測試要想實施方便, 主要思路是除去各種依賴
3. 多向優秀開源項目學習, 我所走之路, 前人早已開辟, 這其實也是看源碼的一個好處
補充(2020-04-25):
使用內存數據庫兼容性不好,流程不變,將內存數據庫改為docker運行數據庫更為可行