我們應該測試 DAO 層嗎?

聞爾於達發表於2022-07-18

應該測試 DAO 層嗎?

網上有很多人討論單元測試是否應該包含 DAO 層的測試。筆者覺得,對於一些主要是crud的業務來說,service層和controller層都會非常薄,而主要的邏輯都落在mapper上。這時候對service層和controller層寫單測沒有太多意義。可以只寫mapper層的單測。

另一方面,mapper層的測試可以有效地避免一些低階的sql錯誤。

定義單測

單元測試是隻針對一個單元的測試,比如說,一個 Service 類的一個每個公共函式。而這個函式所有呼叫了外部依賴的地方都需要被隔離,比如說外部類的依賴,或者是請求了某個伺服器。
也就是說單元測試僅僅是測試當前類的某個函式本身的邏輯,而不涉及到外部的邏輯。因此執行單測應該是很快速的。

在 Java 中單測常用的依賴主要分為測試框架與 Mock 框架。測試框架就是執行和管理測試方法的框架,一般用 JUnit。而 Mock 框架就是用於模擬外部依賴,將被測試的函式的所有外部依賴全部隔離。

一些誤區

在網上見到太多的單測教程,寫得一塌糊塗。甚至連單測的概念都搞不清楚就發表文章,真的是誤人子弟。
關於常見的誤區,這篇部落格列舉得很到位: 如何寫好單元測試:Mock 脫離資料庫+不使用@SpringBootTest

最關鍵的一點是不要使用 @SpringBootTest(classes=XXXApplication.class) 註解測試類。這樣會直接啟動一個 springboot 程式,對稍微複雜一點的專案就至少要花 1 分鐘以上來執行了。如果專案使用了遠端配置中心,SOA 等中介軟體,那建議出去泡杯茶?。
所以為啥大家不想寫單測?等這麼久,人走茶涼了都。但是實際上這都是錯誤的實現手法。下面這篇文章講解了在 SpringBoot 專案中不同整合層次的測試類的例子: Testing in Spring Boot | Baeldung
總地來說,分清楚整合測試與單元測試的區別。不要把單測寫成整合測試。

DAO 層測試的實現

選型

下面這篇文章總結得很好: 寫有價值的單元測試-阿里雲開發者社群
資料庫測試需要保證測試不會影響到外部環境,且生成的資料在測試完成後需要自動銷燬。一般有幾種方法:

  1. 連線開發環境的資料庫,並且在測試後回滾。不推薦
  2. 使用 docker 容器:testContainer。在測試時啟動 mysql 容器,在結束後自動回收。缺點:需要每個測試的機子都安裝 docker 並下載該容器。這就導致:
    1. 需要推動其他開發者安裝該映象
    2. 需要推動 devops 線上上 CI/CD 流水線安裝 docker。(放棄吧)
  3. 使用記憶體資料庫,不會對資料進行持久化。比較常用的有 h2。

如果是個人開發專案,或者不會用到整合部署流水線。可以嘗試使用 testContainer,因為其不僅可以對接 mysql 測試,對一些中介軟體如 redis,mq 等都可以模擬。但是對大型團隊開發的複雜專案還是建議直接用記憶體資料庫吧。
另外,Mybatis 提供了一個測試依賴包,整合了 h2,參考: mybatis-spring-boot-test-autoconfigure – Introduction 。但是缺點是需要依賴不同版本的 springboot,筆者開發的專案使用的 springboot 版本較老,且不宜更新,所以就直接手動配置 h2 了。

程式碼

注:下面的程式碼是從某處文章參考實現的,具體出處已經不甚記得。

我們需要手動建立 4 個 bean 來注入:

  1. DataSource,用於 jdbc 連線對應的 h2 資料庫。
  2. Server。h2 的 gui server 服務,可以用連線資料庫檢視資料。不是必需的。
  3. SqlSessionFactory。為 mybatis 建立一個 sqlSessionFactory,指明 mapper 的 xml 檔案所在位置
  4. MapperScannerConfigurer。用於將 mybatis 中的 mapper 介面生成代理 bean。
    其中幾個需要注意的點:
  5. @ComponentScan 需要填上當前專案中的 mapper 介面的位置
  6. 建立 DataSource 時,addScript() 指定的是自己準備的建表與初始化資料的 sql。路徑在 test/resources/db/schema-h2.sql
  7. 建立 sqlSessionFactory 時,指定 resources 中的 mapper.xml 檔案。
  8. 建立 mapperScannerConfigurer 時,指定 mapper 介面的 package 以及上一步建立的 factory 的 bean 的名字,這裡使用的都是預設的名字,即方法的名稱。
@Configuration
@ComponentScan({ "com.my.app.mapper" })
public class BaseTestConfig {
    @Bean()
    public DataSource dataSource() {
        EmbeddedDatabaseBuilder databaseBuilder = new EmbeddedDatabaseBuilder();

        return databaseBuilder
                .setType(EmbeddedDatabaseType.H2)
                //啟動時初始化建表語句
                .addScript("classpath:db/schema-h2.sql")
                .build();
    }

    @Bean(name = "h2WebServer", initMethod = "start", destroyMethod = "stop")
    //啟動一個H2的web server, 除錯時可以通過localhost:8082訪問到H2的內容
    //JDBC URL: jdbc:h2:mem:testdb
    //User Name: sa
    //Password: 無
    //注意如果使用斷點,斷點型別(Suspend Type)一定要設定成Thread而不能是All,否則web server無法正常訪問!
    public Server server() throws Exception {
        //在8082埠上啟動一個web server
        return Server.createWebServer("-web", "-webAllowOthers", "-webDaemon", "-webPort", "8082");
    }

    @Bean()
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
        sessionFactory.setDataSource(dataSource);
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        //載入所有的sqlmapper檔案
        Resource[] mapperLocations = resolver.getResources("classpath*:mapper/*.xml");
        sessionFactory.setMapperLocations(mapperLocations);
        return sessionFactory.getObject();
    }

    @Bean()
    public MapperScannerConfigurer mapperScannerConfigurer() {
        //只需要寫DAO介面,不用寫實現類,執行時動態生成代理
        MapperScannerConfigurer configurer = new MapperScannerConfigurer();
        configurer.setBasePackage("com.my.app.mapper");
        configurer.setSqlSessionFactoryBeanName("sqlSessionFactory");
        return configurer;
    }

}

建立一個這樣的 Configuration 類後,後面的 MapperTest 類只需要用 @Import 引入這個配置類即可,或者將註解全部放在一個基類上,讓後面的 mapper 測試類都繼承這個基類,就不需要在每個測試類上都加註解了:

@RunWith(SpringJUnit4ClassRunner.class)
@Import(BaseTestConfig.class)
public class BaseMapperTest {
    @Autowired
    private MyMapper myMapper;
    @Test
    public void test(){
        Object o = myMapper.selectOne();
        assertNotNull(o);
    }
}

相關文章