Skip to main content

JUnit 单元测试:通过 MockMVC 测试接口

适用场景

对 Spring 应用的控制器方法(如 REST API 接口)进行测试,包括(但不限于)对用户操作审计、人机验证、权限验证、验证码验证的功能的测试,根据 HTTP 响应判断执行结果。

为实现对控制器方法的测试,需要在执行测试用例前构造 Spring 应用上下文,以生成相关 Bean(如配置类、切面(Aspect)、服务、数据仓库(Repository)等)。

可以对数据仓库进行 Mock 处理,以模拟数据库数据读取与写入。

测试微服务时还需要对相关的 FeignClient 进行 Mock 处理,以模拟对其他微服务的访问。

通过 MockMVC 测试微服务场景下的 Spring 应用控制器的步骤:

  • 编写 JUnit 测试场景的应用配置,停用配置中心和服务注册中心,适用测试数据库、Redis、Kafka 等
  • (可选)编写初始化类,针对测试用例修改应用配置
  • 对相关 FeignClient 和数据仓库进行 Mock 处理
  • 编写测试用例,对相关 FeignClient 和数据仓库的方法打桩

测试对象

下面以对 AuthenticationControllerauthenticate(AuthenticateDTO) 方法进行测试为例。

/* AuthenticationController.java */
@RestController
@Api(tags = {"domain=登录认证", "biz=权限鉴定", "responsibility=命令"})
public class AuthenticationController extends BaseController implements AuthenticationApi {

private final AuthenticationService authenticationService;
private final AccessTokenCommandService accessTokenCommandService;
private final UserCommandApi userCommandApi;
private final EventPublisher eventPublisher;

@Autowired
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
public AuthenticationController(AuthenticationService authenticationService,
AccessTokenCommandService accessTokenCommandService,
UserCommandApi userCommandApi,
EventPublisher eventPublisher) {
this.authenticationService = authenticationService;
this.accessTokenCommandService = accessTokenCommandService;
this.userCommandApi = userCommandApi;
this.eventPublisher = eventPublisher;
}

@Override
@EnableUserAudit
@ApiOperation("验证登录凭证,生成访问令牌")
// 若设定了密码则使用登录用户名检查是否必须识别图形验证码,并在必须识别时校验图形验证码
@ValidateCaptcha(
required = false,
passWhenParameterNotPresent = {
@ValidateCaptcha.Parameter(
type = AuthenticateDTO.class,
propertyName = "password"
)
},
credentialParameters = {
@ValidateCaptcha.Parameter(
type = AuthenticateDTO.class,
propertyName = "username"
)
}
)
public void authenticate(@Valid AuthenticateDTO authenticateDTO) {
// 鉴定用户提供的认证信息,当使用验证码登录时,若认证凭证不存在则创建新的用户
UserBaseEntity user = authenticationService.authenticate(authenticateDTO);

// 将登录用户 ID 保存到 HTTP 请求的属性中以供后用
getRequest().setAttribute(HttpRequestAttributes.CURRENT_USER_ID, user.getId());

// 验证登录凭证,生成令牌,并通过响应头返回
response.setHeader(
HttpResponseHeaders.ACCESS_TOKEN,
accessTokenCommandService.create(getContext(), user)
);
response.setHeader(
HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS,
HttpResponseHeaders.ACCESS_TOKEN
);
}

// ...
}
/* AuthenticationServiceImpl.java */
@Component
public class AuthenticationServiceImpl implements AuthenticationService {

private final CredentialPasswordRepository passwordRepository;
private final UserQueryApi userQueryApi;

@Autowired
public AuthenticationServiceImpl(CredentialPasswordRepository passwordRepository,
UserQueryApi userQueryApi) {
this.passwordRepository = passwordRepository;
this.userQueryApi = userQueryApi;
}

/**
* 验证登录凭证,生成访问令牌。
* @param authenticateDTO 登录凭证数据
* @return 用户基本信息
*/
@Override
public UserQueryEntity authenticate(final AuthenticateDTO authenticateDTO) {
final String username = authenticateDTO.getUsername();
final String password = authenticateDTO.getPassword();

// 尝试取得凭证信息
CredentialPasswordEntity credentialEntity =
passwordRepository.findByCredential(username).orElse(null);

// 若凭证无效或密码不正确则返回认证错误
if (credentialEntity == null
|| (!ObjectUtils.isEmpty(password)
&& !PasswordUtils.validatePassword(password, credentialEntity.getPassword()))) {
throw new AuthenticationError();
}

return userQueryApi.get(credentialEntity.getUserId());
}
}

添加依赖

编辑模块的 pom.xml,添加以下依赖:

<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

编写应用配置文件

src/test/resources 下新建 bootstrap.propertiesapplication.yml,分别填写以下内容:

# bootstrap.properties

spring.application.name=auth-command

# 停用配置中心
spring.cloud.config.enabled=false
spring.cloud.config.discovery.enabled=false

# 停用服务注册中心
spring.cloud.consul.enabled=false
spring.cloud.consul.discovery.enabled=false
# application.yml

spring:
cloud.stream.kafka.binder.brokers: 39.97.104.101:9201
datasource:
url: jdbc:mysql://${JUNIT_RDB_HOST}/${JUNIT_RDB_NAME}?useUnicode=true&characterEncoding=utf-8&useSSL=false
username: ${JUNIT_RDB_USERNAME}
password: ${JUNIT_RDB_PASSWORD}
jpa.show-sql: false
redis:
host: ${JUNIT_REDIS_HOST}
port: ${JUNIT_REDIS_PORT}
password: ${JUNIT_REDIS_PASSWORD}
debug: false
aliyun:
oss.endpoint: ${JUNIT_ALIYUN_OSS_ENDPOINT:}
ram.user.oss-administrator:
access-key-id: ${JUNIT_ALIYUN_ACCESS_KEY_ID:}
access-key-secret: ${JUNIT_ALIYUN_ACCESS_KEY_SECRET:}

编写初始化类

/* MockMvcContextInitializer.java */
public class MockMvcContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
Properties properties = new Properties();
properties.put("logging.level.org.apache.kafka.**", "ERROR"); // 不输出 Kafka 相关错误日志
MutablePropertySources propertySources = applicationContext.getEnvironment().getPropertySources();
propertySources.addFirst(new PropertiesPropertySource("junit", properties));
}
}

构造 MockMVC 实例

/* TestAuthenticationController.java */
@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureMockMvc
@ContextConfiguration(
classes = {AuthCommandStarter.class}, // 指定启动类
initializers = MockMvcContextInitializer.class // 指定初始化类
)
class TestAuthenticationController {

// 通过 MockMVC 的实例模拟客户端向 Spring 应用的 REST Controller 发送 HTTP 请求
private final MockMvc mvc;

@Autowired
public TestAuthenticationController(WebApplicationContext context) {
this.mvc = MockMvcBuilders.webAppContextSetup(context).build();
}
}

Mock:模拟依赖的对象

通过 @MockBean 注解可以使用经过 Mock 的 Bean 替换 Spring 上下文中的 Bean,并可以在接下来编写的测试用例中对这些 Bean 的方法打桩。

/* TestAuthenticationController.java */
@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureMockMvc
@ContextConfiguration(
classes = {AuthCommandStarter.class},
initializers = MockMvcContextInitializer.class
)
class TestAuthenticationController {

private final MockMvc mvc;

@MockBean
private CaptchaCommandApi captchaCommandApi;

@MockBean
private CredentialPasswordRepository passwordRepository;

@MockBean
private UserQueryApi userQueryApi;

@Autowired
public TestAuthenticationController(WebApplicationContext context) {
this.mvc = MockMvcBuilders.webAppContextSetup(context).build();
}
}

打桩:模拟依赖的对象的处理逻辑

由于依赖的 CaptchaCommandApiCredentialPasswordRepositoryUserQueryApi 的对象是 Mock 对象,因此在测试时需要对 Mock 对象的方法打桩,即模拟:

  • 给定特定参数时返回什么结果(通过 org.mockito.Mockito.doReturn 模拟)
  • 给定特定参数时抛出什么错误(通过 org.mockito.Mockito.doThrow 模拟)

例如,通过以下代码模拟向验证码服务发送判断当前客户端是否需要识别图形验证码时返回 false(不需要):

doReturn(false).when(captchaCommandApi).required(username);

通过以下代码模拟登录用户名为 user01passwordRepositoryfindByCredential(String) 方法返回指定的密码数据实体:

CredentialPasswordEntity passwordEntity = new CredentialPasswordEntity();
passwordEntity.setUserId(userId);
passwordEntity.setPassword(PasswordUtils.encryptPassword(password, 10));
doReturn(Optional.of(passwordEntity)).when(passwordRepository).findByCredential(username);

通过 MockMVC 实例模拟客户端请求

// 模拟 POST /authorizations 请求
MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post("/authorizations");

// 设置 User-Agent 请求头为 Mozilla/5.0
requestBuilder.header(HttpHeaders.USER_AGENT, "Mozilla/5.0");

// 设置 Content-Type 请求头为 application/json;charset=UTF-8
requestBuilder.header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString());

// 设置请求数据内容(JSON 类型数据)
AuthenticateDTO credentials = new AuthenticateDTO();
credentials.setUsername("user01");
credentials.setPassword("Pa5sW0rD");
requestBuilder.content(StringUtils.toJSON(credentials));

断言:判断执行结果

MockMVC 的实例通过 perform(RequestBuilder) 方法模拟发送 HTTP 请求,通过 andExpect(ResultMatcher) 方法根据响应数据判断执行结果。

其中 ResultMatcher 仅包含一个成员方法 match(MvcResult)(因此可以简写为 Lambda 表达式),可对 MvcResult 类型的执行结果进行判断。

MockMvcResultMatchers 提供了一组静态方法,可以返回预定义的 ResultMatcher,如 StatusResultMatchers(用于判断 HTTP 状态码)、HeaderResultMatchers(用于判断响应头)等。

mvc
// 发送 HTTP 请求
.perform(requestBuilder)
// 判断 HTTP 响应状态码是否为 200(OK)
.andExpect(MockMvcResultMatchers.status().isOk())
// 判断 HTTP 响应是否包含 X-Access-Token 头
.andExpect(MockMvcResultMatchers.header().exists("X-Access-Token"));

测试用例示例

@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureMockMvc
@ContextConfiguration(
classes = {AuthCommandStarter.class},
initializers = MockMvcContextInitializer.class
)
class TestAuthenticationController {

private final MockMvc mvc;

@MockBean
private CaptchaCommandApi captchaCommandApi;

@MockBean
private CredentialPasswordRepository passwordRepository;

@MockBean
private UserQueryApi userQueryApi;

@Autowired
public TestAuthenticationController(WebApplicationContext context) {
this.mvc = MockMvcBuilders.webAppContextSetup(context).build();
}

/**
* 用户名及密码正确时通过 X-Access-Token 响应头返回访问令牌。
*/
@Test
void givenUsernameIsCorrectAndPasswordIsCorrect_whenAuthenticate_thenReturnAccessTokenByHeader() throws Exception {
final String userId = "JUNIT00000000001";
final String username = "user01";
final String password = "Pa5sW0rD";

AuthenticateDTO credentials = new AuthenticateDTO();
credentials.setUsername(username);
credentials.setPassword(password);

doReturn(false).when(captchaCommandApi).required(username);

CredentialPasswordEntity passwordEntity = new CredentialPasswordEntity();
passwordEntity.setUserId(userId);
passwordEntity.setPassword(PasswordUtils.encryptPassword(password, 10));
doReturn(Optional.of(passwordEntity)).when(passwordRepository).findByCredential(username);

UserQueryEntity userEntity = new UserQueryEntity();
userEntity.setId(userId);
userEntity.setType(UserType.USER);
userEntity.setName("Mock User #1");
userEntity.setRevision(System.currentTimeMillis());
doReturn(userEntity).when(userQueryApi).get(userId);

MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post("/authorizations");
requestBuilder.header(HttpHeaders.USER_AGENT, "Mozilla/5.0");
requestBuilder.header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString());
requestBuilder.content(StringUtils.toJSON(credentials));

mvc.perform(requestBuilder)
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.header().exists("X-Access-Token"));
}

/**
* 用户名不正确时返回 error.invalid-credentials 错误,状态码为 401。
*/
@Test
void givenUsernameIsWrong_whenAuthenticate_thenThrowAuthenticationError() throws Exception {
final String username = "user02";
final String password = "Pa5sW0rD";

AuthenticateDTO credentials = new AuthenticateDTO();
credentials.setUsername(username);
credentials.setPassword(password);

doReturn(false).when(captchaCommandApi).required(username);

doReturn(Optional.empty()).when(passwordRepository).findByCredential(username);

MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.post("/authorizations");
requestBuilder.header(HttpHeaders.USER_AGENT, "Mozilla/5.0");
requestBuilder.header(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString());
requestBuilder.content(StringUtils.toJSON(credentials));

mvc.perform(requestBuilder)
.andExpect(MockMvcResultMatchers.status().is(HttpStatus.UNAUTHORIZED.value()))
.andExpect(result -> {
assertNull(result.getResponse().getHeader("X-Access-Token"));
JsonApiDTO content = StringUtils.fromJSON(result.getResponse().getContentAsString(), JsonApiDTO.class);
String errorCode = (content == null || content.getError() == null) ? null : content.getError().getCode();
assertEquals("error.invalid-credentials", errorCode);
});
}
}