单元测试 - 探索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运行数据库更为可行