在没有代码生成工具或尝试一门新的 ORM
框架时,当我们希望不去另外写 Service 和 Controller 来验证 DAO 层的代码不希望只通过接口请求的方式来验证时,这时候单元测试的方式就可以帮助我们满足这一需求。
在我们开发Web应用时,经常会直接去观察结果进行测试。虽然也是一种方式,但是并不严谨。作为开发者编写测试代码来测试自己所写的业务逻辑是,以提高代码的质量、降低错误方法的概率以及进行性能测试等。经常作为开发这写的最多就是单元测试。引入
spring-boot-starter-test
SpringBoot的测试依赖。该依赖会引入JUnit的测试包,也是我们用的做多的单元测试包。而Spring Boot在此基础上做了很多增强,支持很多方面的测试,例如JPA,MongoDB,Spring MVC(REST)和Redis等。接下来主要是测试业务逻辑层的代码,REST和Mock测试。
springboot-2.7.1
默认绑定junit5
而不再是junit4
,后期若想使用 junit4
也可以 Add JUnit4 to classpath
fail to load ApplicationContext
)@RunWith(SpringRunner.class)
或@SpringBootTest
注解说明
专门的注解 | 提供方 | 说明 | |
---|---|---|---|
JPA | @DataJpaTest | org.springframework.boot.test | 会默认扫描@Entity和@Repository注解 |
MyBatis | @MybatisTest | com.baomidou.mybatis-spring-boot-starter-test | 官方提供支持 |
MyBatis-Plus | @MybatisPlusTest | com.baomidou.mybatis-plus-boot-starter-test | 官方提供支持 |
其它注解说明:
@AutoConfigureTestDatabase | 会自动配置一个基于内存的数据库,只要依赖中含有一个可用的基于内存的嵌入式数据库。 结果会自动回滚,不会发生数据库变化。若使用实际数据库可设置 replace值为Replace.NONE |
|
@Rollback | 是否回滚,通过设置 value 为 true or false 决定测试数据是否写入数据库 |
package com.imooc;
import org.junit.jupiter.api.Test;
/**
*
*/
public class LoggerTest {
@Test
public void test1() {
System.out.println("=========");
}
}
2
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class Logger2Test {
// 选择org.slf4j下的接口
private final Logger logger = LoggerFactory.getLogger(Logger2Test.class);
@Test
public void test1() {
logger.info("info...");
}
}
版本: junit5
springboot-2.7.2` 不启动应用 数据库已配
目录:
@Repository
注解@DataJpaTest
注解@AutoConfigureTestDatabase
注解依赖
spring-boot-starter-web (2.7.2)
spring-boot-starter-test (with junit5)
mysql-connector-java (8.0)
spring-boot-starter-data-jpa
h2 # 构建基于内存的数据库环境
步骤:
@DataJpaTest
注解@AutoConfigureTestDatabase
注解:最终元素(已验证)
/
@Data
@Entity // JPA
public class ProductCategory implements Serializable {
// ...
}
/
@Repository
public interface ProductCategoryRepository extends JpaRepository {
}
/
@DataJpaTest // 只启动JPA组件不启动全环境
@AutoConfigureTestDatabase(replace= AutoConfigureTestDatabase.Replace.NONE) // 创建一个基于内存的数据库环境
class ProductCategoryRepositoryTest {
@Autowired
ProductCategoryRepository repository;
@Test
public void testFindById() {
Optional byId = repository.findById(1);
System.out.println("查询结果:");
System.out.println(byId);
}
}
控制台
查询结果:
Optional[ProductCategory(categoryId=1, categoryType=1, categoryName=热销榜, createTime=2022-08-03 06:37:48.0, updateTime=2022-08-03 06:37:48.0)]
参考资料:
(方法讲解)https://howtodoinjava.com/spring-boot2/testing/datajpatest-annotation/#1-repository-annotation
(实际代码)https://github.com/lokeshgupta1981/jakarta-projects/tree/master/spring-boot-projects/hibernate-examples
Auto-configured Data JPA Tests
版本: junit5
不启动应用 数据库已配 单测 在主模块里
步骤:
@MybatisTest
注解@AutoConfigureTestDatabase
注解依赖(+)
mybatis-spring-boot-starter-test(@MybatisTest)
h2
案例(已验证)
///子模块
@Mapper
public interface LoginMapper {
@Select("SELECT * FROM t_user")
List selectAllUser();
}
///main模块
@MybatisTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class LoginMapperTest {
@Autowired
LoginMapper loginMapper;
@Test
void contextLoads() {
List users = loginMapper.selectAllUser();
System.out.println("查询所有用户:");
System.out.println(users);
}
}
日志结果
查询所有用户:
[User(uid=1, uname=123, upwd=password, pwdErr=null), User(uid=2, uname=test, upwd=test, pwdErr=null), User(uid=3, uname=234, upwd=password, pwdErr=null), User(uid=4, uname=234, upwd=password, pwdErr=null), User(uid=5, uname=2345, upwd=password, pwdErr=null), User(uid=6, uname=452345, upwd=password, pwdErr=null), User(uid=7, uname=523, upwd=password, pwdErr=null), User(uid=8, uname=453, upwd=password, pwdErr=null), User(uid=9, uname=234523, upwd=password, pwdErr=null), User(uid=10, uname=45, upwd=password, pwdErr=null), User(uid=11, uname=452345, upwd=password, pwdErr=null), User(uid=12, uname=2345, upwd=password, pwdErr=null)]
参考资料:
mybatis.org@MyBatisTest的使用
过程和 mb 的差不多,它也有另外支持的依赖starter
和注解。
引入依赖
com.baomidou.mybatis-plus-boot-starter-test
测试
@MybatisPlusTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Slf4j
class UserMapperTest {
@Autowired
UserMapper userMapper;
@Test
public void testInsert() {
User user = User.builder().id(51L).mobile("8808820").password("e10adc3949ba59abbe56e057f20f883e").build();
int insert = userMapper.insert(user);
log.info("是否插入成功={}", insert == 1);
}
}
结果
### Error updating database. Cause: java.sql.SQLIntegrityConstraintViolationException: Column 'create_time' cannot be null
### The error may exist in com/imooc/sso/mapper/UserMapper.java (best guess)
### The error may involve com.imooc.sso.mapper.UserMapper.insert-Inline
### The error occurred while setting parameters
### SQL: INSERT INTO user ( id, mobile, password, create_time, update_time ) VALUES ( ?, ?, ?, ?, ? )
启动成功,说明单测步骤是对头的。
参考资料:Spring boot Mybatis-Plus数据库单测实战(三种方式)_CuteXiaoKe的博客-CSDN博客_mybatisplus单元测试
单测默认会自动回滚,如果执行有插入或更新的数据,数据库的数据不会变化。且假若新增/更新数据没设置【非null】字段,单测也一样会通过(可以理解为执行错了随后回滚了?!)
若想写入数据库,禁止回滚,重写@Rollback
注解,将value
值置为false
。
@DataJpaTest // 只启动JPA组件不启动全环境
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 创建一个基于内存的数据库环境
@Rollback(value = false) // 不自动回滚,真实写入数据库
class ProductCategoryRepositoryTest {
@Autowired
ProductCategoryRepository repository;
@Test
public void testSave() {
ProductCategory category = new ProductCategory();
System.out.println("插入ing");
category.setCategoryId(2);
category.setCategoryType(2);
category.setCategoryName("女生最爱");
category.setCreateTime(new Date());
category.setUpdateTime(new Date());
repository.save(category);
System.out.println("插入一条数据");
Optional byId = repository.findById(2);
System.out.println("插入后查询:" + byId);
}
}
控制台
此时真正读写数据库,当插入not null字段时,则会触发ConstraintViolationException
(约束违背异常) 否则执行后,此时,数据库数据写入成功。
多模块工程:
如果只有一个模板,以上方式都可以;
如果是多模块工程,单测建议放 main
模块,否则会抛异常
`Test ignored.`
java.lang.IllegalStateException: Unable to find a @SpringBootConfiguration, you need to use @ContextConfiguration or @SpringBootTest(classes=...) with your test`·
service 层比起 DAO 层要常规得多
测试ServiceImpl
@SpringBootTest
class ProductInfoServiceImplTest {
@Autowired
ProductInfoService productInfoService;
@Test
void listUpProductInfos() {
List upProductInfos = productInfoService.listUpProductInfos();
System.out.println("验证已上架商品" + upProductInfos);
}
}
Assertions
的使用Assertions
是 junit5 代替 junit4 中的 Assert
的一个静态类,内置一系列的静态方法,提供用户判空,判等等方法验证的 api。跳过打印验证执行结构
assertNotNull(obj)
import org.junit.jupiter.api.Assertions;
// 判断返回结果是否为空
@Test
void findOne() {
ProductCategory productCategory = productCategoryRepository.findById(3).get();
Assertions.assertNotNull(productCategory);
}
assertEquals(Expected, Actual)
import org.junit.jupiter.api.Assertions;
@Test
void count() {
long count = productCategoryRepository.count();
Assertions.assertEquals(count, 10);
}
(JUnit5+Mockito)
如果项目组有测试团队,最常接触的概念有:功能测试、回归测试、冒烟测试等,但这些都是由测试人员发起的。
开发人员写的常常是“单元测试”,但其实可以细分成 单元测试
和 集成测试
两个。
划分的原因拿常见的 Spring IoC 举例。Spring 不同Bean之间相互依赖,例如某API业务逻辑中会依赖不同模块的 Service,Service 方法中又可能依赖不同的 Dao 层方法,甚至还会通过 RPC、HTTP 调用外部服务方法。这给我们写测试用例带来了难度,本来只想测试某个方法的功能,却要考虑一连串的依赖关系。
单元测试:是指对软件中的最小可测试单元进行检查和验证。
通常任何软件都会划分为不同的模块和组件。单独测试一个组件时,我们叫做单元测试。单元测试用于验证相关的一小段代码是否正常工作。单元测试不是用于发现应用程序范围内的 bug,或者回归测试的 bug,而是分别检测每个代码片段。
单元测试不验证应用程序代码是否和外部依赖正常工作。它聚焦与单个组件并且 Mock 所有和它交互的依赖。例如,方法中调用发短信的服务,以及和数据库的交互,我们只需要 Mock 假执行即可,毕竟测试的焦点在当前方法上。
单元测试的特点:
集成测试:在单元测试的基础上,将所有模块按照设计要求组装成为子系统或系统,进行集成测试。
集成测试主要用于发现用户端到端请求时不同模块交互产生的问题。集成测试范围可以是整个应用程序,也可以是一个单独的模块,取决于要测试什么。
在集成测试中,我们应该聚焦于从控制器层到持久层的完整请求。应用程序应该运行嵌入服务(例如:Tomcat)以创建应用程序上下文和所有 bean。这些 bean 有的可能会被 Mock 覆盖。
集成测试的特点:
SpringBoot中有关测试的框架,主要来源于 spring-boot-starter-test。一旦依赖了spring-boot-starter-test,下面这些类库将被一同依赖进去:
测试环境自定义Bean
@TestComponent
:该注解是另一种@Component,在语义上用来指定某个Bean是专门用于测试的。该注解适用于测试代码和正式混合在一起时,不加载被该注解描述的Bean,使用不多。@TestConfiguration
:该注解是另一种@TestComponent,它用于补充额外的Bean或覆盖已存在的Bean。在不修改正式代码的前提下,使配置更加灵活。前者说了,JUnit 是一个Java语言的单元测试框架,但同样可用于集成测试。当前最新版本是 JUnit5。
常见区别有:
JUnit5 和 JUnit4 在注解上的区别在于:
功能 | JUnit4 | JUnit5 |
---|---|---|
声明一种测试方法 | @Test | @Test |
在当前类中的所有测试方法之前执行 | @BeforeClass | @BeforeAll |
在当前类中的所有测试方法之后执行 | @AfterClass | @AfterAll |
在每个测试方法之前执行 | @Before | @BeforeEach |
在每个测试方法之后执行 | @After | @AfterEach |
禁用测试方法/类 | @Ignore | @Disabled |
测试工厂进行动态测试 | NA | @TestFactory |
嵌套测试 | NA | @Nested |
标记和过滤 | @Category | @Tag |
注册自定义扩展 | NA | @ExtendWith |
RunWith 和 ExtendWith
在 JUnit4 版本,在测试类加 @SpringBootTest
注解时,同样要加上 @RunWith(SpringRunner.class)
才生效,即:
@SpringBootTest @RunWith(SpringRunner.class) class HrServiceTest { ... }
但在 JUnit5 中,官网告知 @RunWith
的功能都被 @ExtendWith
替代,即原 @RunWith(SpringRunner.class)
被同功能的 @ExtendWith(SpringExtension.class)
替代。但 JUnit5 中 @SpringBootTest 注解中已经默认包含了 @ExtendWith(SpringExtension.class)。
因此,在 JUnit5 中只需要单独使用 @SpringBootTest
注解即可。其他需要自定义拓展的再用 @ExtendWith
,不要再用 @RunWith
了。
测试驱动的开发(TDD)要求我们先写单元测试,再写实现代码。在写单元测试的过程中,我们往往会遇到要测试的类有很多依赖,这些依赖的类/对象/资源又有别的依赖,从而形成一个大的依赖树。而 Mock 技术的目的和作用是模拟一些在应用中不容易构造或者比较复杂的对象,从而把测试与测试边界以外的对象隔离开。
Mock 框架有很多,除了传统的 EasyMock、Mockito以外,还有PowerMock、JMock、JMockit等。这里选用 Mockito ,是因为 Mockito 在社区流行度较高,而且是 SpringBoot 默认集成的框架。
Mockito 框架中最核心的两个概念就是 Mock 和 Stub。测试时不是真正的操作外部资源,而是通过自定义的代码进行模拟操作。我们可以对任何的依赖进行模拟,从而使测试的行为不需要任何准备工作或者不具备任何副作用。
当我们在测试时,如果只关心某个操作是否执行过,而不关心这个操作的具体行为,这种技术称为 mock。比如我们测试的代码会执行发送邮件的操作,我们对这个操作进行 mock;测试的时候我们只关心是否调用了发送邮件的操作,而不关心邮件是否确实发送出去了。
另一种情况,当我们关心操作的具体行为,或者操作的返回结果的时候,我们通过执行预设的操作来代替目标操作,或者返回预设的结果作为目标操作的返回结果。这种对操作的模拟行为称为 stub(打桩)。比如我们测试代码的异常处理机制是否正常,我们可以对某处代码进行 stub,让它抛出异常。再比如我们测试的代码需要向数据库插入一条数据,我们可以对插入数据的代码进行stub,让它始终返回1,表示数据插入成功。
推荐一个 Mockito 中文文档。
mock 和 spy 的区别
mock方法和spy方法都可以对对象进行mock。但是前者是接管了对象的全部方法,而后者只是将有桩实现(stubbing)的调用进行mock,其余方法仍然是实际调用。
如下例,因为只mock了List.size()方法。如果mockList是通过mock生成的,List的add、get等其他方法都失效,返回的数据都为null。但如果是通过spy生成的,则验证正常。
平时开发过程中,我们通常只需要mock类的某些方法,用spy即可。
@Test void mockAndSpy() { ListmockList = Mockito.mock(List.class); // List mockList = Mockito.spy(new ArrayList<>()); Mockito.when(mockList.size()) .thenReturn(100); mockList.add("A"); mockList.add("B"); Assertions.assertEquals("A", mockList.get(0)); Assertions.assertEquals(100, mockList.size()); }
强烈推荐这个自动生成单元测试代码的插件,像本文说的框架搭配,默认的模板就是 JUnit5Mockito.java.ft
。
在选中类右键Generate -> Generate Test
后,不光能生成测试类和方法,甚至连Mockito 数据、方法和 Assertions 等都写好了,只需要自己改一改即可。
因为 JUnit5、Mockito 都是 spring-boot-starter-test 默认依赖的,所以 pom 中无需引入其他特殊依赖。先写个简单的 Service 层方法,通过两张表查询数据。
HrService.java
@AllArgsConstructor @Service public class HrService { private final OrmDepartmentDao ormDepartmentDao; private final OrmUserDao ormUserDao; ListfindUserByDeptName(String deptName) { return ormDepartmentDao.findOneByDepartmentName(deptName) .map(OrmDepartmentPO::getId) .map(ormUserDao::findByDepartmentId) .orElse(Collections.emptyList()); } }
IDEA 创建测试类
接下来针对该 Service 类创建测试类,我们使用的开发工具是 IDEA。点进当前类,右键->Go To->Test->Create New Test
,在 Testing library 中选择 Junit5,则在对应目录生成测试类和方法。
HrServiceTest.java
@ExtendWith(MockitoExtension.class) class HrServiceTest { @Mock private OrmDepartmentDao ormDepartmentDao; @Mock private OrmUserDao ormUserDao; @InjectMocks private HrService hrService; @DisplayName("根据部门名称,查询用户") @Test void findUserByDeptName() { Long deptId = 100L; String deptName = "行政部"; OrmDepartmentPO ormDepartmentPO = new OrmDepartmentPO(); ormDepartmentPO.setId(deptId); ormDepartmentPO.setDepartmentName(deptName); OrmUserPO user1 = new OrmUserPO(); user1.setId(1L); user1.setUsername("001"); user1.setDepartmentId(deptId); OrmUserPO user2 = new OrmUserPO(); user2.setId(2L); user2.setUsername("002"); user2.setDepartmentId(deptId); ListuserList = new ArrayList<>(); userList.add(user1); userList.add(user2); Mockito.when(ormDepartmentDao.findOneByDepartmentName(deptName)) .thenReturn( Optional.ofNullable(ormDepartmentPO) .filter(dept -> deptName.equals(dept.getDepartmentName())) ); Mockito.doReturn( userList.stream() .filter(user -> deptId.equals(user.getDepartmentId())) .collect(Collectors.toList()) ).when(ormUserDao).findByDepartmentId(deptId); List result1 = hrService.findUserByDeptName(deptName); List result2 = hrService.findUserByDeptName(deptName + "error"); Assertions.assertEquals(userList, result1); Assertions.assertEquals(Collections.emptyList(), result2); }
因为单元测试不用启动 Spring 容器,则无需加 @SpringBootTest,因为要用到 Mockito,只需要自定义拓展 MockitoExtension.class 即可,依赖简单,运行速度更快。
可以明显看到,单元测试写的代码,怎么是被测试代码长度的好几倍?其实单元测试的代码长度比较固定,都是造数据和打桩,但如果针对越复杂逻辑的代码写单元测试,还是越划算的。
还是那个方法,如果使用Spring上下文,真实的调用方法依赖,可直接用下列方式:
@SpringBootTest class HrServiceTest { @Autowired private HrService hrService; @DisplayName("根据部门名称,查询用户") @Test void findUserByDeptName() { ListuserList = hrService.findUserByDeptName("行政部"); Assertions.assertTrue(userList.size() > 0); } }
还可以使用@MockBean
、@SpyBean
替换Spring上下文中的对应的Bean:
@SpringBootTest class HrServiceTest { @Autowired private HrService hrService; @SpyBean private OrmDepartmentDao ormDepartmentDao; @DisplayName("根据部门名称,查询用户") @Test void findUserByDeptName() { String deptName="行政部"; OrmDepartmentPO ormDepartmentPO = new OrmDepartmentPO(); ormDepartmentPO.setDepartmentName(deptName); Mockito.when(ormDepartmentDao.findOneByDepartmentName(ArgumentMatchers.anyString())) .thenReturn(Optional.of(ormDepartmentPO)); ListuserList = hrService.findUserByDeptName(deptName); Assertions.assertTrue(userList.size() > 0); } }
小提示:@SpyBean 和 spring boot data 的问题
当用 @SpyBean 添加到 spring data jpa 的dao层上时(继承 JpaRepository 的接口),会无法启动容器,报错 org.springframework.beans.factory.BeanCreationException: Error creating bean with name
。包括 mongo 等 spring data 都会有此问题,是 spring boot 官方不支持,可查看 Issues-7033,已在 spring boot 2.5.3
版本修复。
Repository接口的@Repository
注解可写可不写
Test类的必要注解:2个
@RunWith(SpringRunner.class) // 启动容器环境
@SpringBootTest
class ProductCategoryRepositoryTest {
ProductCategoryRepository repository;
@Test
public void testFindById() {
Optional byId = repository.findById(1);
System.out.println("查询结果:");
System.out.println(byId);
}
}
AutoConfigureTestDatabase
If you prefer your test to run against a real database, you can use the@AutoConfigureTestDatabase
annotation in the same way as forDataJpaTest
. (See " Auto-configured Data JPA Tests".)
-- Spring Boot Reference Documentation