跳到主要内容

测试 Feign 客户端

测试是确保 Feign 客户端正确工作的关键环节。本章将介绍多种测试策略,从简单的单元测试到完整的集成测试,帮助你构建可靠的服务调用层。

为什么需要测试 Feign 客户端?

Feign 客户端虽然只是接口定义,但仍然需要测试,原因如下:

验证接口契约:确保 Feign 接口的定义与服务端的 API 契约一致。如果服务端修改了接口,测试能及时发现不兼容问题。

验证序列化和反序列化:确保请求参数正确序列化,响应数据正确反序列化为 Java 对象。

验证错误处理逻辑:确保各种错误情况(超时、服务不可用、业务异常)得到正确处理。

验证降级和熔断逻辑:确保服务不可用时降级逻辑正确执行。

提升代码质量:良好的测试覆盖率能减少生产环境的问题。

单元测试

使用 Mockito 模拟 Feign 客户端

最简单的测试方式是使用 Mockito 模拟 Feign 客户端,专注于测试业务逻辑:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
@ExtendWith(MockitoExtension.class)
class UserServiceTest {

@Mock
private UserClient userClient;

@InjectMocks
private UserService userService;

@Test
void getUserById_shouldReturnUser() {
// 准备测试数据
User mockUser = new User(1L, "张三", "[email protected]");

// 设置模拟行为
when(userClient.getUserById(1L)).thenReturn(mockUser);

// 执行测试
User result = userService.getUserById(1L);

// 验证结果
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getName()).isEqualTo("张三");

// 验证 Feign 客户端被调用
verify(userClient, times(1)).getUserById(1L);
}

@Test
void getUserById_whenUserNotFound_shouldThrowException() {
// 设置模拟行为:用户不存在时抛出异常
when(userClient.getUserById(999L))
.thenThrow(new FeignException.NotFound("User not found", null, null));

// 验证抛出预期异常
assertThatThrownBy(() -> userService.getUserById(999L))
.isInstanceOf(UserNotFoundException.class)
.hasMessage("用户不存在");
}
}

适用场景

  • 测试使用 Feign 客户端的业务逻辑
  • 不需要验证 Feign 本身的行为
  • 快速的单元测试

局限性

  • 无法验证 Feign 注解是否正确
  • 无法验证序列化和反序列化
  • 无法验证 HTTP 请求的实际行为

验证 Feign 注解

如果需要验证 Feign 接口定义是否正确,可以使用反射检查注解:

class UserClientAnnotationTest {

@Test
void userClient_shouldHaveCorrectAnnotations() {
// 验证类级别的注解
FeignClient feignClient = UserClient.class.getAnnotation(FeignClient.class);
assertThat(feignClient).isNotNull();
assertThat(feignClient.name()).isEqualTo("user-service");

// 验证方法级别的注解
try {
Method getUserById = UserClient.class.getMethod("getUserById", Long.class);

GetMapping getMapping = getUserById.getAnnotation(GetMapping.class);
assertThat(getMapping).isNotNull();
assertThat(getMapping.value()).containsExactly("/users/{id}");

// 验证参数注解
Parameter[] parameters = getUserById.getParameters();
PathVariable pathVariable = parameters[0].getAnnotation(PathVariable.class);
assertThat(pathVariable).isNotNull();
assertThat(pathVariable.value()).isEqualTo("id");

} catch (NoSuchMethodException e) {
fail("Method not found", e);
}
}
}

集成测试

使用 WireMock 模拟 HTTP 服务

WireMock 是一个流行的 HTTP 模拟服务器,非常适合测试 Feign 客户端:

<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.3.1</version>
<scope>test</scope>
</dependency>

使用 Spring Boot 测试配置

@SpringBootTest
@TestPropertySource(properties = {
"user-service.url=http://localhost:9561"
})
class UserClientIntegrationTest {

@Autowired
private UserClient userClient;

private static WireMockServer wireMockServer;

@BeforeAll
static void setUp() {
wireMockServer = new WireMockServer(9561);
wireMockServer.start();
}

@AfterAll
static void tearDown() {
wireMockServer.stop();
}

@BeforeEach
void reset() {
wireMockServer.resetAll();
}

@Test
void getUserById_shouldReturnUser() {
// 配置 WireMock 响应
wireMockServer.stubFor(get(urlEqualTo("/users/1"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("""
{
"id": 1,
"name": "张三",
"email": "[email protected]"
}
""")));

// 执行测试
User result = userClient.getUserById(1L);

// 验证结果
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getName()).isEqualTo("张三");
assertThat(result.getEmail()).isEqualTo("[email protected]");

// 验证请求
wireMockServer.verify(getRequestedFor(urlEqualTo("/users/1"))
.withHeader("Accept", containing("application/json")));
}

@Test
void createUser_shouldSendCorrectRequest() {
// 配置 WireMock 响应
wireMockServer.stubFor(post(urlEqualTo("/users"))
.willReturn(aResponse()
.withStatus(201)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"id": 100,
"name": "新用户",
"email": "[email protected]"
}
""")));

// 执行测试
User newUser = new User(null, "新用户", "[email protected]");
User result = userClient.createUser(newUser);

// 验证结果
assertThat(result.getId()).isEqualTo(100L);

// 验证请求体
wireMockServer.verify(postRequestedFor(urlEqualTo("/users"))
.withHeader("Content-Type", containing("application/json"))
.withRequestBody(containing("新用户"))
.withRequestBody(containing("[email protected]")));
}

@Test
void getUserById_whenNotFound_shouldThrowException() {
// 配置 WireMock 返回 404
wireMockServer.stubFor(get(urlEqualTo("/users/999"))
.willReturn(aResponse()
.withStatus(404)));

// 验证抛出异常
assertThatThrownBy(() -> userClient.getUserById(999L))
.isInstanceOf(FeignException.NotFound.class);
}

@Test
void getUserById_whenServerError_shouldRetry() {
// 配置 WireMock 先返回 500,然后返回成功
wireMockServer.stubFor(get(urlEqualTo("/users/1"))
.inScenario("Retry Scenario")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(500))
.willSetStateTo("Failed Once"));

wireMockServer.stubFor(get(urlEqualTo("/users/1"))
.inScenario("Retry Scenario")
.whenScenarioStateIs("Failed Once")
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":1,\"name\":\"张三\"}")));

// 执行测试(假设配置了重试)
User result = userClient.getUserById(1L);

assertThat(result.getName()).isEqualTo("张三");

// 验证被调用了两次
wireMockServer.verify(2, getRequestedFor(urlEqualTo("/users/1")));
}
}

使用 @SpringBootTest 的完整配置示例

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@EnableFeignClients(basePackages = "com.example.client")
@ActiveProfiles("test")
class FeignIntegrationTest {

@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().port(9561))
.build();

@TestConfiguration
static class TestConfig {
@Bean
@Primary
public UserClient userClient() {
return Feign.builder()
.client(new Client.Default(null, null))
.encoder(new SpringEncoder(new SpringFormEncoder()))
.decoder(new SpringDecoder(() -> new DefaultMessageConverters()))
.contract(new SpringMvcContract())
.target(UserClient.class, "http://localhost:9561");
}
}

@Test
void testWithDynamicPort() {
wireMock.stubFor(get("/users/1")
.willReturn(ok()
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":1,\"name\":\"测试用户\"}")));

// 测试逻辑
}
}

测试超时和重试

@Test
void getUserById_whenTimeout_shouldHandleGracefully() {
// 配置 WireMock 延迟响应
wireMockServer.stubFor(get(urlEqualTo("/users/1"))
.willReturn(aResponse()
.withStatus(200)
.withFixedDelay(5000))); // 延迟 5 秒

// 假设 Feign 配置了 2 秒超时
assertThatThrownBy(() -> userClient.getUserById(1L))
.isInstanceOf(FeignException.RetryableException.class)
.hasMessageContaining("timed out");
}

@Test
void getUserById_withRetry_shouldEventuallySucceed() {
// 模拟前两次失败,第三次成功
wireMockServer.stubFor(get(urlEqualTo("/users/1"))
.inScenario("Retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("First Retry"));

wireMockServer.stubFor(get(urlEqualTo("/users/1"))
.inScenario("Retry")
.whenScenarioStateIs("First Retry")
.willReturn(aResponse().withStatus(503))
.willSetStateTo("Second Retry"));

wireMockServer.stubFor(get(urlEqualTo("/users/1"))
.inScenario("Retry")
.whenScenarioStateIs("Second Retry")
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":1,\"name\":\"张三\"}")));

User result = userClient.getUserById(1L);
assertThat(result.getName()).isEqualTo("张三");
}

测试降级逻辑

@SpringBootTest
@TestPropertySource(properties = {
"spring.cloud.openfeign.circuitbreaker.enabled=true",
"user-service.url=http://localhost:9561"
})
class FallbackTest {

@Autowired
private UserClient userClient;

private static WireMockServer wireMockServer;

@BeforeAll
static void startWireMock() {
wireMockServer = new WireMockServer(9561);
wireMockServer.start();
}

@AfterAll
static void stopWireMock() {
wireMockServer.stop();
}

@Test
void getUserById_whenServiceUnavailable_shouldFallback() {
// 配置 WireMock 返回 503
wireMockServer.stubFor(get(urlEqualTo("/users/1"))
.willReturn(aResponse().withStatus(503)));

// 执行测试,应该触发降级
User result = userClient.getUserById(1L);

// 验证降级结果
assertThat(result).isNotNull();
assertThat(result.getName()).isEqualTo("默认用户");
}

@Test
void getUserById_whenTimeout_shouldFallback() {
// 配置 WireMock 延迟响应
wireMockServer.stubFor(get(urlEqualTo("/users/1"))
.willReturn(aResponse()
.withStatus(200)
.withFixedDelay(10000))); // 延迟 10 秒

// 执行测试,应该触发降级
User result = userClient.getUserById(1L);

// 验证降级结果
assertThat(result.getName()).isEqualTo("默认用户");
}
}

契约测试

使用 Spring Cloud Contract

Spring Cloud Contract 提供 Consumer Driven Contracts(消费者驱动契约)测试,确保服务端和客户端的契约一致。

添加依赖

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
<scope>test</scope>
</dependency>

配置 Stub Runner

# application-test.yml
stubrunner:
idsToServiceIds:
user-service: user-service
repositoryRoot: classpath:/contracts/
stubsMode: LOCAL

编写契约定义

// contracts/user-service/shouldReturnUser.groovy
Contract.make {
request {
method GET()
url '/users/1'
}
response {
status 200
headers {
contentType(applicationJson())
}
body([
id: 1,
name: "张三",
email: "[email protected]"
])
}
}

编写契约测试

@SpringBootTest
@AutoConfigureStubRunner(
ids = ["com.example:user-service:+:stubs:9561"],
stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
class ContractTest {

@Autowired
private UserClient userClient;

@Test
void shouldReturnUser() {
User user = userClient.getUserById(1L);

assertThat(user.getId()).isEqualTo(1L);
assertThat(user.getName()).isEqualTo("张三");
assertThat(user.getEmail()).isEqualTo("[email protected]");
}
}

测试配置

测试专用配置类

@TestConfiguration
public class FeignTestConfig {

@Bean
public Logger.Level feignLoggerLevel() {
return Logger.Level.FULL; // 测试时使用完整日志
}

@Bean
public Request.Options requestOptions() {
// 测试时使用较短的超时时间
return new Request.Options(1000, 2000);
}

@Bean
public Retryer retryer() {
// 测试时不重试,便于验证异常
return new Retryer.NEVER_RETRY;
}
}

测试配置文件

# application-test.yml
spring:
cloud:
openfeign:
client:
config:
default:
connectTimeout: 2000
readTimeout: 3000
loggerLevel: full
circuitbreaker:
enabled: true

# 覆盖服务地址为 WireMock 地址
user-service:
url: http://localhost:9561

logging:
level:
com.example.client: DEBUG

测试数据构建

使用 Builder 模式

public class UserTestBuilder {
private Long id = 1L;
private String name = "测试用户";
private String email = "[email protected]";

public static UserTestBuilder aUser() {
return new UserTestBuilder();
}

public UserTestBuilder withId(Long id) {
this.id = id;
return this;
}

public UserTestBuilder withName(String name) {
this.name = name;
return this;
}

public UserTestBuilder withEmail(String email) {
this.email = email;
return this;
}

public User build() {
User user = new User();
user.setId(id);
user.setName(name);
user.setEmail(email);
return user;
}
}

// 使用示例
User user = UserTestBuilder.aUser()
.withId(100L)
.withName("张三")
.build();

使用测试数据工厂

public class TestDataFactory {

public static User createDefaultUser() {
User user = new User();
user.setId(1L);
user.setName("默认用户");
user.setEmail("[email protected]");
return user;
}

public static List<User> createUsers(int count) {
return IntStream.rangeClosed(1, count)
.mapToObj(i -> {
User user = new User();
user.setId((long) i);
user.setName("用户" + i);
user.setEmail("user" + i + "@example.com");
return user;
})
.collect(Collectors.toList());
}

public static String toJson(Object obj) {
try {
return new ObjectMapper().writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}

测试最佳实践

1. 分层测试策略

┌─────────────────────────────────────────────────────────────┐
│ 测试金字塔 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ╱╲ │
│ ╱ ╲ │
│ ╱ 契约 ╲ 少量,验证服务契约 │
│ ╱ 测试 ╲ │
│ ╱──────────╲ │
│ ╱ ╲ │
│ ╱ 集成测试 ╲ 中量,验证完整流程 │
│ ╱ (WireMock) ╲ │
│ ╱──────────────────╲ │
│ ╱ ╲ │
│ ╱ 单元测试 ╲ 大量,快速验证逻辑 │
│ ╱ (Mockito) ╲ │
│ ╱──────────────────────────╲ │
│ │
└─────────────────────────────────────────────────────────────┘

2. 测试命名规范

// 好的命名:描述测试场景和预期结果
@Test
void getUserById_whenUserExists_shouldReturnUser() { }

@Test
void getUserById_whenUserNotFound_shouldThrowNotFoundException() { }

@Test
void createUser_withValidData_shouldReturnCreatedUser() { }

// 不好的命名:不够清晰
@Test
void testGetUser() { }

@Test
void test1() { }

3. 测试隔离

@SpringBootTest
@TestMethodOrder(OrderAnnotation.class)
class OrderedTest {

@MockBean
private UserClient userClient;

@BeforeEach
void setUp() {
// 每个测试前重置 Mock
Mockito.reset(userClient);
}

@Test
@Order(1)
void firstTest() {
// 独立的测试
}

@Test
@Order(2)
void secondTest() {
// 独立的测试
}
}

4. 使用测试切片

// 只测试 Feign 相关的 Bean,不加载整个 Spring 上下文
@FeignClientTest
class FeignSliceTest {

@Autowired
private UserClient userClient;

@MockBean
private LoadBalancerClient loadBalancerClient;

@Test
void testUserClient() {
// 测试逻辑
}
}

5. 参数化测试

@ParameterizedTest
@ValueSource(longs = {1L, 2L, 3L})
void getUserById_withDifferentIds_shouldReturnUsers(Long userId) {
wireMockServer.stubFor(get(urlEqualTo("/users/" + userId))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":" + userId + ",\"name\":\"用户" + userId + "\"}")));

User result = userClient.getUserById(userId);

assertThat(result.getId()).isEqualTo(userId);
}

@ParameterizedTest
@CsvSource({
"1, 张三, [email protected]",
"2, 李四, [email protected]",
"3, 王五, [email protected]"
})
void createUser_withDifferentUsers_shouldSucceed(Long id, String name, String email) {
User user = new User(id, name, email);

wireMockServer.stubFor(post(urlEqualTo("/users"))
.willReturn(aResponse()
.withStatus(201)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":" + id + ",\"name\":\"" + name + "\"}")));

User result = userClient.createUser(user);

assertThat(result.getId()).isEqualTo(id);
}

6. 测试异常情况

class ExceptionHandlingTest {

@ParameterizedTest
@CsvSource({
"400, BadRequest",
"401, Unauthorized",
"403, Forbidden",
"404, NotFound",
"500, InternalServerError",
"503, ServiceUnavailable"
})
void handleDifferentErrorStatuses(int status, String exceptionName) {
wireMockServer.stubFor(get(urlEqualTo("/users/1"))
.willReturn(aResponse().withStatus(status)));

assertThatThrownBy(() -> userClient.getUserById(1L))
.isInstanceOf(FeignException.class)
.hasMessageContaining(String.valueOf(status));
}
}

7. 清理资源

class ResourceCleanupTest {

private WireMockServer wireMockServer;
private ExecutorService executorService;

@BeforeEach
void setUp() {
wireMockServer = new WireMockServer(9561);
wireMockServer.start();
executorService = Executors.newFixedThreadPool(10);
}

@AfterEach
void tearDown() {
if (wireMockServer != null) {
wireMockServer.stop();
}
if (executorService != null) {
executorService.shutdownNow();
}
}

@Test
void testWithProperCleanup() {
// 测试逻辑
}
}

常见测试问题

问题一:Feign 客户端注入失败

现象:测试时报错 No qualifying bean of type 'UserClient'

解决方案

// 方案一:使用 @SpringBootTest 并启用 Feign 客户端
@SpringBootTest
@EnableFeignClients(basePackages = "com.example.client")
class MyTest { }

// 方案二:使用 @Import 导入配置
@SpringBootTest
@Import(FeignTestConfig.class)
class MyTest { }

// 方案三:使用 @MockBean 模拟
@SpringBootTest
class MyTest {
@MockBean
private UserClient userClient;
}

问题二:WireMock 端口冲突

现象java.net.BindException: Address already in use

解决方案

// 使用动态端口
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();

// 在测试中获取实际端口
int port = wireMock.getRuntimeInfo().getHttpPort();

问题三:序列化测试失败

现象:JSON 反序列化失败

解决方案

// 确保 Jackson 配置正确
@TestConfiguration
public class JacksonTestConfig {
@Bean
@Primary
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.registerModule(new JavaTimeModule());
return mapper;
}
}

小结

本章介绍了 Feign 客户端的多种测试策略:

测试类型工具适用场景执行速度
单元测试Mockito测试业务逻辑
集成测试WireMock测试 HTTP 交互中等
契约测试Spring Cloud Contract验证服务契约

测试策略建议

  1. 大量使用 Mockito 进行单元测试,快速验证业务逻辑
  2. 关键路径使用 WireMock 进行集成测试
  3. 服务间契约使用 Spring Cloud Contract 保证一致性
  4. 生产环境监控配合测试,形成完整的质量保障体系

下一章将介绍实战案例,演示如何在真实项目中使用 OpenFeign。