應該測試 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 層測試的實現
選型
下面這篇文章總結得很好: 寫有價值的單元測試-阿里雲開發者社群
資料庫測試需要保證測試不會影響到外部環境,且生成的資料在測試完成後需要自動銷燬。一般有幾種方法:
- 連線開發環境的資料庫,並且在測試後回滾。不推薦
- 使用 docker 容器:testContainer。在測試時啟動 mysql 容器,在結束後自動回收。缺點:需要每個測試的機子都安裝 docker 並下載該容器。這就導致:
- 需要推動其他開發者安裝該映象
- 需要推動 devops 線上上 CI/CD 流水線安裝 docker。(放棄吧)
- 使用記憶體資料庫,不會對資料進行持久化。比較常用的有 h2。
如果是個人開發專案,或者不會用到整合部署流水線。可以嘗試使用 testContainer,因為其不僅可以對接 mysql 測試,對一些中介軟體如 redis,mq 等都可以模擬。但是對大型團隊開發的複雜專案還是建議直接用記憶體資料庫吧。
另外,Mybatis 提供了一個測試依賴包,整合了 h2,參考: mybatis-spring-boot-test-autoconfigure – Introduction 。但是缺點是需要依賴不同版本的 springboot,筆者開發的專案使用的 springboot 版本較老,且不宜更新,所以就直接手動配置 h2 了。
程式碼
注:下面的程式碼是從某處文章參考實現的,具體出處已經不甚記得。
我們需要手動建立 4 個 bean 來注入:
- DataSource,用於 jdbc 連線對應的 h2 資料庫。
- Server。h2 的 gui server 服務,可以用連線資料庫檢視資料。不是必需的。
- SqlSessionFactory。為 mybatis 建立一個 sqlSessionFactory,指明 mapper 的 xml 檔案所在位置
- MapperScannerConfigurer。用於將 mybatis 中的 mapper 介面生成代理 bean。
其中幾個需要注意的點: @ComponentScan
需要填上當前專案中的 mapper 介面的位置- 建立 DataSource 時,
addScript()
指定的是自己準備的建表與初始化資料的 sql。路徑在 test/resources/db/schema-h2.sql - 建立 sqlSessionFactory 時,指定 resources 中的 mapper.xml 檔案。
- 建立 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);
}
}