最近团队内在推广单元测试,我主要做一些 Java 框架和 CI 环境的支持。我们内部的 RPC 框架主要有 HTTP(Spring Cloud Feign)和 gRPC 两种,而在单元测试中一般需要 mock 跨服务之间的请求,相比之下 gRPC 的 mock 较为复杂,在此详细介绍一下。
Mock Client? 对于大多数 RPC 框架来说,都会有一个封装抽象的比较上层的接口,即不需要考虑序列化以及通信相关的实现。所以只需要直接 mock 这类的接口,作为本地方法调用并返回对应的结果即可,不必进行真实的 RPC 请求。
以 Spring Cloud Feign 为例,Feign 的定义本身就是完全抽象的 Java 接口,同时每一个 Feign Client 又会注册成一个 Spring Bean,所以就可以通过 Spring 原生提供的 @MockBean
进行 mock,例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @SpringBootTest @RunWith (SpringRunner.class)public class UserServiceTest { @MockBean private UserInfoFeignClient client; @Autowired private UserInfoService service; @Test public void updateUserBasicInfos () { doReturn(null ).when(client).getByMobile(any()); UserInfoDetail user = service.register("18812345678" ); assertTrue("user should register success when mobile not exists" , user != null ); } }
在最开始,我以为 gRPC 也会有类似的支持,可以通过现有的框架 mock 一个 Stub,使其返回指定的 protobuf 对象。
不幸的是,由于 gRPC 的所有源码都是由 protobuf 文件生成而来,而最重要的是:其生成的 Java Class 都是 final 的,这导致我们没有办法使用基于动态代理实现的 mock 框架去直接代理一个 Stub。
在 gRPC Java 的 Github Issues 中也有着一些类似的讨论,一部分开发者认为 Stub 不应该被定义为 final 类型,这样就可以进行 mock 了。而核心开发者认为 mock Stub 的做法本身就是错误的,真正的作法应该是 mock 一个 Server 实现,并通过 in-process 的传输方式和 Client 进行通信。
Mock Server! 在明确了 gRPC 的 mock 只能在 Server 端进行之后,官方为此也提供了一些对应的支持,其中最核心的实现是一个 Junit4 的 Rule GrpcServerRule
。
在这个 Rule 中,每次进行测试之前都会启动一个 in-process Server 以及一个 MutableHandlerRegistry
作为注册中心。之后使用者可以 mock 对应的 Server 实现并将其添加到其中,之后再使用 in-process Server 返回的 Channel 构造 Stub,最终调用该 Stub 的对应方法就可以进入到对应的 Server 逻辑中了。
下面是一个最简单的代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 @RunWith (MockitoJUnitRunner.class)public class SimpleGrpcClientTests { @Rule public final GrpcServerRule grpcServerRule = new GrpcServerRule().directExecutor(); @Spy private SimpleGrpc.SimpleImplBase server; @Captor private ArgumentCaptor<HelloRequest> requestArgumentCaptor; private SimpleGrpc.SimpleBlockingStub stub; private GrpcClientService grpcClientService; @Before public void setup () { grpcServerRule.getServiceRegistry().addService(server); stub = SimpleGrpc.newBlockingStub(grpcServerRule.getChannel()); grpcClientService = new GrpcClientService(stub); } @Test public void testSendMessage () { doAnswer((invocationOnMock) -> { StreamObserver<HelloReply> argument = invocationOnMock.getArgumentAt(1 , StreamObserver.class); HelloReply reply = HelloReply.newBuilder().setMessage("World" ).build(); argument.onNext(reply); argument.onCompleted(); return null ; }).when(server).sayHello(requestArgumentCaptor.capture(), any()); String message = "Hello my world" ; assertThat(grpcClientService.sendMessage(message)).isEqualTo("World" ); verify(server).sayHello(requestArgumentCaptor.capture(), any()); assertThat(requestArgumentCaptor.getValue().getName()).isEqualTo(message); } }
使用这种方式需要注意的是,Server 的实现必须严格的使用 StreamObserver.class
进行结果返回,否则会一直卡在请求中,无法正确的得到结果。
Spring Style 当了解了最核心的 mock 实现后,让我们回到真实世界。
在大多数情况下的实际场景并没有这么简单,例如我们使用了 yidongnan/grpc-spring-boot-starter 将 gRPC 和 Spring 所结合,其实现了一个 PostBeanProcessor 用于将 Channel 或是 Stub 注入到 Bean 的字段中,例如:
1 2 3 4 5 6 7 8 9 @Service public class GrpcClientService { @GrpcClient ("local-grpc-server" ) private Channel serverChannel; @GrpcStub ("local-grpc-server" ) private SimpleBlockingStub stub; }
在这种场景下 Mockito 的 @InjectMocks
和 Spring Boot 的 @MockBean
都是非常优秀的实现,但是由于篇幅有限,这里只展示一个参考 MockitoAnnotations#initMocks
的类似实现。
这个方法只需要做三件事:
找到测试类中所有包含 @Mock
或是 @Spy
的字段,如果其是一个 gRPC Server 实现(继承了 BindableService
),则将其添加到 grpcServiceRule
中。
找到测试类中所有包含 @Autowired
的字段,递归遍历所有包含 @GrpcClient
和 @GrpcStub
的字段,将 grpcServiceRule
中的 Channel 注入到其中。
在实际情况中可能会出现只有部分 Stub、Channel 需要注入的情况,所以在第一步的时候需要收集所有 mock 对象所对应的名称,而在第二步时只注入含有对应名称的字段
下面是代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 @Slf 4jpublic class GrpcAnnotations { public static void initMocks (Object testClass, GrpcServerRule grpcServerRule) { if (testClass == null ) { throw new IllegalArgumentException("testClass cannot be null" ); } Set<String> bindingGrpcServiceName = new HashSet<>(); for (Field field : testClass.getClass().getDeclaredFields()) { if (BindableService.class.isAssignableFrom(field.getType()) && field.isAnnotationPresent(MockGrpc.class)) { MockGrpc mockGrpc = field.getAnnotation(MockGrpc.class); if (!bindingGrpcServiceName.add(mockGrpc.value())) { throw new IllegalStateException("Multiple gRPC services have the same name." ); } try { Object instance = Mockito.spy(field.getType()); ReflectionUtils.makeAccessible(field); field.set(testClass, instance); grpcServerRule.getServiceRegistry().addService((BindableService) instance); } catch (Exception e) { throw new IllegalStateException("Unable to inject because of reflection failure." , e); } } } for (Field field : testClass.getClass().getDeclaredFields()) { if (field.isAnnotationPresent(InjectGrpc.class)) { ReflectionUtils.makeAccessible(field); try { injectGrpcFields(field.get(testClass), grpcServerRule, bindingGrpcServiceName); } catch (Exception e) { throw new IllegalStateException("Unable to inject because of reflection failure." , e); } } } } private static void injectGrpcFields (Object instance, GrpcServerRule grpcServerRule, Set<String> bindingGrpcServiceName) { if (instance == null ) { return ; } for (Field field: instance.getClass().getDeclaredFields()) { if (Channel.class.isAssignableFrom(field.getType()) && field.isAnnotationPresent(GrpcClient.class)) { GrpcClient grpcClient = field.getAnnotation(GrpcClient.class); if (bindingGrpcServiceName.contains(grpcClient.value())) { ReflectionUtils.makeAccessible(field); ReflectionUtils.setField(field, instance, grpcServerRule.getChannel()); } } else if (AbstractStub.class.isAssignableFrom(field.getType()) && field.isAnnotationPresent( GrpcStub.class)) { GrpcStub grpcClient = field.getAnnotation(GrpcStub.class); if (bindingGrpcServiceName.contains(grpcClient.value())) { ReflectionUtils.makeAccessible(field); ReflectionUtils.setField(field, instance, GrpcClientUtils.createGrpcStub(field, grpcServerRule.getChannel())); } } else { ReflectionUtils.makeAccessible(field); try { injectGrpcFields(field.get(instance), grpcServerRule, bindingGrpcServiceName); } catch (IllegalAccessException e) { log.warn("Unable to inject because of reflection failure." ); } } } } }
如此一来,使用者只需要在每个测试运行前调用下 GrpcAnnotations#initMocks
即可完成所有 Server 的 mock 声明和对应 Client 的注入了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @SpringBootTest @RunWith (SpringRunner.class)public class SpringGrpcClientIntegrationTests { @Rule public final GrpcServerRule grpcServerRule = new GrpcServerRule().directExecutor(); @MockGrpc ("local-grpc-server" ) private SimpleGrpc.SimpleImplBase server; @Autowired @InjectGrpc private GrpcClientService grpcClientService; @Before public void setUp () { GrpcAnnotations.initMocks(this , grpcServerRule); } @Test public void testSendMessage () { } }