我們應該測試 DAO 層嗎?
應該測試 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);
}
}