测试 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 | 验证服务契约 | 慢 |
测试策略建议:
- 大量使用 Mockito 进行单元测试,快速验证业务逻辑
- 关键路径使用 WireMock 进行集成测试
- 服务间契约使用 Spring Cloud Contract 保证一致性
- 生产环境监控配合测试,形成完整的质量保障体系
下一章将介绍实战案例,演示如何在真实项目中使用 OpenFeign。