單元測試 - 探索java web 單元測試的正確姿勢


單元測試 - 探索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運行數據庫更為可行

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM