应该测试 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); } }