前述文章请见Junit单元测试_Joy T的博客-CSDN博客Mock入门之概念理解_Joy T的博客-CSDN博客,我们直接通过例子学习mock的应用。
Springboot项目Mock测试示例
现在我们搞一个简化的Spring Boot用户管理示例,并为其添加基础的JUnit单元测试。首先,这是一个简单的用户实体类和服务层:
// User.java
package org.example.domain;
public class User {
private Long id;
private String username;
private String password;
// getters, setters, constructors, etc.
}
// UserService.java
package org.example.service;
import org.example.domain.User;
public interface UserService {
User register(User user);
User findByUsername(String username);
}
接下来,假设我们有一个UserServiceImpl
实现了上述接口。它依赖于一个UserRepository
来执行实际的数据库操作:
// UserServiceImpl.java
package org.example.service.impl;
import org.example.domain.User;
import org.example.repository.UserRepository;
import org.example.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
@Autowired
public UserServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public User register(User user) {
return userRepository.save(user);
}
@Override
public User findByUsername(String username) {
return userRepository.findByUsername(username);
}
}
UserRepository:这是一个Spring Data JPA的接口,用于与数据库交互。简而言之,JPA就相当于数据访问层,也就是Dao层。
UserRepository
通常是Spring Data JPA中用于数据库操作的接口,它通常对应于传统的DAO(数据访问对象)层。在Spring框架中,尤其是使用Spring Data JPA时,我们经常用“repository”这个词来描述数据访问层的接口,而这与传统的“DAO”有着类似的功能和责任。简而言之,UserRepository
可以被视为DAO层的一部分或等同于DAO。
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
/*在Java中,关键字extends用于类之间的继承和接口之间的继承。但这两种场景下的含义是不同的:
对于类:extends意味着子类继承了父类,这样子类就可以使用父类的属性和方法。类在Java中只能单继承,即一个类只能继承一个其他类。
对于接口:extends意味着一个接口继承了一个或多个其他接口,从而继承了它们的方法签名。与类不同,接口可以多继承,即一个接口可以继承多个其他接口。*/
读者可以看到,在Service服务接口中定义了register注册方法和findByUsername方法,在UserServiceImpl
中实现两个方法时,用到了userRepository对象的save()和fincByUsername()方法。既然Repository对应Dao层,那么这两个方法应该存在像.xml或者注解等方法实现的与数据库交互的部分。但是,没有!这就对应了JPA的特性(其实和Mybatisplus有点像,只不过对于简单CRUD来说,JPA更为简单,适用范围也挺广泛的),作者会单独出JPA的讲解文章。
简单来说,save()一个方法可以同时在底层中实现insert()和update()两种数据库基本操作,很基础,所以save()方法成功地成为了JpaRepository接口中定义的方法,“子接口”UserRepository可以直接调用。不需要声明。而findByUsername(),它虽然不是JpaRepository直接定义的方法,但是,Spring Data JPA可以通过解析方法名来自动创建查询,这是其非常有力的特点之一。只需按照一定的命名规则定义接口方法,Spring Data JPA就会提供其实现,findByUsername()就是遵循了某个命名规则,所以自动生成实际的数据访问代码,但是注意,它需要在UserRepository接口中声明,因为它不是接口内部的方法,需要声明,JPA才能根据你声明方法的名字来调用相应的方法。
现在,让我们为UserServiceImpl
编写单元测试。为了做到这一点,我们会使用Mockito来模拟UserRepository
的行为。
// UserServiceImplTest.java
package org.example.service.impl;
import org.example.domain.User;
import org.example.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
public class UserServiceImplTest {
//@InjectMocks将这个模拟注入到我们要测试的UserServiceImpl中
//注意这里虽然叫userService,但是它时UserServiceImpl实现类的实例哦!
@InjectMocks
private UserServiceImpl userService;
//@Mock创建一个模拟的UserRepository
@Mock
private UserRepository userRepository;
@BeforeEach
public void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
public void testRegister() {
User user = new User();
user.setUsername("john");
user.setPassword("123456");
when(userRepository.save(any(User.class))).thenReturn(user);
User registeredUser = userService.register(user);
assertEquals("john", registeredUser.getUsername());
assertEquals("123456", registeredUser.getPassword());
}
@Test
public void testFindByUsername() {
User user = new User();
user.setUsername("john");
user.setPassword("123456");
when(userRepository.findByUsername("john")).thenReturn(user);
User foundUser = userService.findByUsername("john");
assertEquals("john", foundUser.getUsername());
}
}
这个测试类的目的是测试UserServiceImpl
的行为,而不是实际的数据库操作。为此,我们使用Mockito模拟UserRepository
的行为。@Mock
创建一个模拟的UserRepository
,@InjectMocks
将这个模拟注入到我们要测试的UserServiceImpl
中。
当运行这些测试时,它们会测试服务层的逻辑,而不会真的与数据库交互。真的与数据库交互,就是集成测试!我们可以为UserServiceImpl
中的每个方法编写多个不同的测试用例,以处理不同的情况和边缘情况。
Mock使用部分讲解(本文核心内容)
//@InjectMocks将这个模拟注入到我们要测试的UserServiceImpl中
@InjectMocks
private UserServiceImpl userService;
//@Mock创建一个模拟的UserRepository
@Mock
private UserRepository userRepository;
@BeforeEach
public void setUp() {
MockitoAnnotations.openMocks(this);
}
@Mock:
- 作用: 使用该注解可以创建一个模拟对象(mocked instance)。这个模拟对象不是真正的实例,但可以按照我们的需求来配置其行为。
- 在代码中:
@Mock private UserRepository userRepository;
创建了一个模拟的UserRepository
。它不是真正的UserRepository
实例,因此不会进行任何实际的数据库操作。- 使用模拟对象允许我们在测试中定义某方法在接收特定输入时的返回值,或者验证某方法是否被正确地调用,而无需关心其内部实现。
@InjectMocks:
- 作用: 使用该注解可以自动将模拟对象注入到另一个对象中。所谓“注入”,指的是将一个对象的引用赋值给另一个对象的某个属性。
- 在代码中:
@InjectMocks private UserServiceImpl userService;
告诉 Mockito 自动将之前模拟的UserRepository
实例注入到UserServiceImpl
中。- 这样,当你在测试中调用
userService
的方法时,它会使用模拟的userRepository
,而不是真正的UserRepository
实例。这个基本上就是Mockito最常用的流程了!启动过程:
MockitoAnnotations.openMocks(this);
这行代码的作用是测试前初始化上面标注的模拟对象和注入它们。
下面,我们以testRegister()测试方法为例,讲一下如何根据Mock创建的模拟对象userService完善单元测试。
TestRegister()
testRegister
方法是一个单元测试,其目标是测试UserServiceImpl
类中的register
方法。
1. 创建用户对象
User user = new User();
user.setUsername("john");
user.setPassword("123456");
这里,我们创建了一个新的用户对象并为其设置了用户名和密码。
2. 模拟UserRepository
的行为
when(userRepository.save(any(User.class))).thenReturn(user);
使用Mockito,我们告诉测试:当userRepository
的save
方法被调用时,返回我们之前创建的用户对象。这意味着我们并不实际执行任何数据库操作,而只是模拟了数据库保存操作的行为。
3. 调用被测试的方法
User registeredUser = userService.register(user);
我们调用了userService
的register
方法并传入了之前创建的用户对象。由于我们已经模拟了userRepository.save
的行为,所以这个调用会直接返回我们之前创建的用户对象。
4. 验证结果
assertEquals("john", registeredUser.getUsername());
assertEquals("123456", registeredUser.getPassword());
最后,我们验证从register
方法返回的用户对象的用户名和密码是否与我们预期的相同。
5. 测试结果
如果一切按照预期运行,这个测试将通过,意味着userService
的register
方法(在给定模拟行为的条件下)正常工作。
如果userService
的register
方法的实现出现问题,或者与模拟的行为不匹配,该测试将失败,并提供相关的错误信息,这有助于我们定位和修复代码中的问题。
怎么验证方法正常工作?
@Test
public void testRegister() {
User user = new User();
user.setUsername("john");
user.setPassword("123456");
when(userRepository.save(any(User.class))).thenReturn(user);
User registeredUser = userService.register(user);
assertEquals("john", registeredUser.getUsername());
assertEquals("123456", registeredUser.getPassword());
}
最后,我们验证从
register
方法返回的用户对象的用户名和密码是否与我们预期的相同。
该测试的目的是检查register
方法是否正确地调用了userRepository
的save
方法,并返回了预期的用户对象。
这里的重点是我们使用了模拟(mocking)技术。当register
方法调用userRepository
的save
方法时,实际上并不会触及实际的数据库。相反,save
方法的行为被我们通过when(userRepository.save(any(User.class))).thenReturn(user);
这一行代码定义了。
当我们说“正常工作”,在这个上下文中,我们是指:
register
方法是否真的调用了userRepository
的save
方法。register
方法是否返回了我们从模拟的save
方法预期的返回值。
这个测试是在假设的场景下运行的,我们控制了所有的变量。所以,如果这个测试成功,那么我们可以说,在模拟的场景下,register
方法的行为与我们的预期相符。如果在真实环境下,userRepository
的save
方法的实际行为与我们在此模拟的行为不符,那么这个测试不能捕捉到那种差异。
就是说,我们给save()方法一个预期的user值,但我们测试的是上一层服务层的register()方法。如果register()能够成功调用save(),那么我们一开始给到save()的预期user值真的会传递到save()方法的返回值,我们再看一下register()方法:
@Override
public User register(User user) {
return userRepository.save(user);
}
save()方法返回值是预期的user,所以register()也返回了预期的user,这样,测试就会通过。我们验证了什么?我们验证了register()方法一定调用了save()方法,并且register()的返回值一定是save()方法的返回值。(虽然上面代码很明显,但是这只是简单的示例,在实际工作中代码逻辑会十分麻烦,一眼是看不出来的,而且也不好检查,所以某方法能成功调用某方法的关系是需要这样测试的)
因为我们给的条件是:一旦出现userRepository.save(任何的user对象)这个方法,不要管这个方法实际上是否和数据库交互、能不能和数据库交互,我们直接给它预期的user值。尽管我们知道
userRepository.save
方法返回了一个user
对象,但我们不确定register
方法是如何处理这个返回值的。也许register
方法有其他的逻辑,可能会修改返回的user
对象,或者在某些条件下返回不同的对象。通过这个测试,我们确保register
方法在这种特定情况下的行为是正确的、保证register的内部逻辑是正确的,这也是我们测试的目的。
User registeredUser = userService.register(user);
assertEquals("john", registeredUser.getUsername());
assertEquals("123456", registeredUser.getPassword());
如果register()能成功调用save(),成功返回save()的返回值,那么registeredUser的值就是预期值,后续的测试就能够通过。
总结
单元测试的目的是要确保
register
方法处理这个返回值的逻辑是正确的。所以,我们想验证当userRepository.save
返回特定值时,register
方法的行为是什么。(当下一层方法返回预期值时,看上层方法的行为是否符合我们的方法预期内容)
通过给方法调用的下一层方法一个预期值,才能测试上层方法
这就是使用模拟技术的主要原因。我们控制下层方法的行为,这样我们就可以专注于测试上层方法的逻辑,而不是同时考虑所有涉及的组件和外部依赖。
这样做的一个主要好处是,如果在将来register
方法的逻辑发生改变,这个测试可能会失败,提示开发者这里可能有问题。同时,由于我们已经模拟了外部依赖,我们可以确定问题出在register
方法上,而不是userRepository
或其他部分。这使得定位问题更加容易。
在这种情况下,测试得到的结果是不是无法验证save方法(下层方法)的行为是否正确?
是的,当我们使用模拟(mocking)为userRepository.save
方法提供预期的返回值或行为时,我们实际上是在绕过了真实的save
方法实现。因此,这个特定的单元测试只是验证了register
方法的行为,而不是save
方法的正确性。
要验证userRepository.save
方法的真实行为是否正确,你需要为它进行另一个单元测试或集成测试,并可能涉及到真实的数据库操作。通常,对于像save
这样的数据库操作,我们会选择集成测试而不是单元测试,因为这涉及到数据库交互,我们希望验证在真实环境中这些操作是否按预期工作。
Mock在此做出的贡献
总结一下,Mocking 的基本思想是这样的:
- 通过模拟,我们可以完全控制外部依赖的行为,使其返回我们想要的结果或模拟错误的情况。
- 我们可以测试代码在各种场景下的行为,而不需要设置和维护一个真实的数据库、网络服务等。
- 我们可以隔离要测试的代码,确保测试失败是因为代码本身的错误,而不是外部依赖导致的。
在实际编写测试时,我们使用 Mockito 的 API 来配置模拟对象的行为,例如 when(...).thenReturn(...)
来指定在调用某个方法时返回什么。然后,调用 UserServiceImpl
的方法,检查它的行为是否符合预期,特别是在与 UserRepository
的交互上。
所以,总结一下:
- 使用模拟的单元测试帮助我们验证某一特定方法或组件的逻辑是否正确。
- 要验证外部依赖(如数据库操作)的行为,我们可能需要进行集成测试。
两者都是软件测试的重要组成部分,但关注点和目的有所不同。
作者自己的问题
为什么要把服务层作为接口呢?为什么要增加一个实现层?我觉得这样好复杂啊,之前用的都是class服务类,类直接开始实现。难道说一个服务层会有多个实现方法?多种实现方法这种开发思想有哪些应用场景呢?
定义服务层为接口,并为其提供具体的实现,是一种常见的设计模式,具有多个优点。使用接口与实现类的结构有以下好处:
-
解耦:通过接口,可以将服务的声明(即所提供的方法)与其实际实现解耦。这样,更改实现时,不会影响到调用该服务的其他代码。这种解耦类似:将特征耦合转换为数据耦合。
-
灵活性:一个接口可以有多个实现。这对于许多应用场景都非常有用,例如:
- 测试:为了进行单元测试,可能提供一个模拟的服务实现,而不是使用真实的实现。
- 策略模式:在某些情况下,可能根据不同的情况使用不同的服务实现。例如,可能有一个用于开发环境的实现和一个用于生产环境的实现。
- 适配器模式:如果您的应用需要与多个外部系统或第三方库交互,您可以为每个系统或库提供一个适配器,而所有这些适配器都实现同一个接口。
-
更好的合作:当团队中的多个开发者同时工作时,他们可以依赖接口进行开发,而不需要等待其他开发者完成实现。这可以加快开发速度。
-
代理和装饰:使用接口,可以轻松地为服务提供代理或装饰器。这在需要添加跨切面关注点(如日志、安全或事务管理)时非常有用。
尽管使用接口和实现类增加了一些复杂性,但这种结构提供了更大的灵活性,并允许更好地组织和扩展代码。不过,对于简单的应用或原型开发,直接使用类可能更为简单和快速。应该根据项目的具体需求和目标来决定是否采用这种结构。