SpringCloud升级之路2020.0.x版-40. spock 单元测试封装的 WebClient(上)

2021年11月25日 阅读数:3
这篇文章主要向大家介绍SpringCloud升级之路2020.0.x版-40. spock 单元测试封装的 WebClient(上),主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

本系列代码地址:https://github.com/JoJoTec/spring-cloud-parentjava

咱们来测试下前面封装好的 WebClient,这里开始,咱们使用 spock 编写 groovy 单元测试,这种编写出来的单元测试,代码更加简洁,同时更加灵活,咱们在接下来的单元测试代码中就能看出来。git

编写基于 spock 的 spring-boot context 测试

咱们加入前面设计的配置,编写测试类:github

@SpringBootTest(
		properties = [
				"webclient.configs.testServiceWithCannotConnect.baseUrl=http://testServiceWithCannotConnect",
				"webclient.configs.testServiceWithCannotConnect.serviceName=testServiceWithCannotConnect",
				"webclient.configs.testService.baseUrl=http://testService",
				"webclient.configs.testService.serviceName=testService",
				"webclient.configs.testService.responseTimeout=1s",
				"webclient.configs.testService.retryablePaths[0]=/delay/3",
				"webclient.configs.testService.retryablePaths[1]=/status/4*",
				"spring.cloud.loadbalancer.zone=zone1",
				"resilience4j.retry.configs.default.maxAttempts=3",
				"resilience4j.circuitbreaker.configs.default.failureRateThreshold=50",
				"resilience4j.circuitbreaker.configs.default.slidingWindowType=TIME_BASED",
				"resilience4j.circuitbreaker.configs.default.slidingWindowSize=5",
				//由于重试是 3 次,为了防止断路器打开影响测试,设置为正比如重试多一次的次数,防止触发
				//同时咱们在测试的时候也须要手动清空断路器统计
				"resilience4j.circuitbreaker.configs.default.minimumNumberOfCalls=4",
				"resilience4j.circuitbreaker.configs.default.recordExceptions=java.lang.Exception"
		],
		classes = MockConfig
)
class WebClientUnitTest extends Specification {
    @SpringBootApplication
	static class MockConfig {
	}
}

咱们加入三个服务实例供单元测试调用:web

class WebClientUnitTest extends Specification {
    def zone1Instance1 = new DefaultServiceInstance(instanceId: "instance1", host: "www.httpbin.org", port: 80, metadata: Map.ofEntries(Map.entry("zone", "zone1")))
	def zone1Instance2 = new DefaultServiceInstance(instanceId: "instance2", host: "www.httpbin.org", port: 8081, metadata: Map.ofEntries(Map.entry("zone", "zone1")))
	def zone1Instance3 = new DefaultServiceInstance(instanceId: "instance3", host: "httpbin.org", port: 80, metadata: Map.ofEntries(Map.entry("zone", "zone1")))
}

咱们要动态的指定负载均衡获取服务实例列表的响应,即去 Mock 负载均衡器的 ServiceInstanceListSupplier 并覆盖:spring

class WebClientUnitTest extends Specification {

    @Autowired
	private Tracer tracer
	@Autowired
	private ServiceInstanceMetrics serviceInstanceMetrics
    
    RoundRobinWithRequestSeparatedPositionLoadBalancer loadBalancerClientFactoryInstance = Spy();
	ServiceInstanceListSupplier serviceInstanceListSupplier = Spy();
	
	//全部测试的方法执行前会调用的方法
	def setup() {
		//初始化 loadBalancerClientFactoryInstance 负载均衡器
		loadBalancerClientFactoryInstance.setTracer(tracer)
		loadBalancerClientFactoryInstance.setServiceInstanceMetrics(serviceInstanceMetrics)
		loadBalancerClientFactoryInstance.setServiceInstanceListSupplier(serviceInstanceListSupplier)
	}
}

以后,咱们能够经过下面的 groovy 代码,动态指定微服务返回实例:编程

//指定 testService 微服务的 LoadBalancer 为 loadBalancerClientFactoryInstance
loadBalancerClientFactory.getInstance("testService") >> loadBalancerClientFactoryInstance
//指定 testService 微服务实例列表为 zone1Instance1, zone1Instance3
serviceInstanceListSupplier.get() >> Flux.just(Lists.newArrayList(zone1Instance1, zone1Instance3))

测试断路器异常重试以及断路器级别

咱们须要验证:微信

  • 对于断路器打开的异常,因为没有请求发出去,因此须要直接重试其余的实例。咱们能够设立一个微服务,包含两个实例,将其中一个实例的某个路径断路器打开,以后屡次调用这个微服务的这个路径接口,看是否都调用成功(因为有重试,因此每次调用都会成功)。同时验证,对于负载均衡器获取服务实例的调用,多于调用次数(每次重试都会调用负载均衡器获取一个新的实例用于调用)
  • 某个路径断路器打开的时候,其余路径断路器不会打开。在上面打开一个微服务某个实例的一个路径的断路器以后,咱们调用其余的路径,不管多少次,都成功而且调用负载均衡器获取服务实例的次数等于调用次数,表明没有重试,也就是没有断路器异常

编写代码:负载均衡

@SpringBootTest(
		properties = [
				"webclient.configs.testServiceWithCannotConnect.baseUrl=http://testServiceWithCannotConnect",
				"webclient.configs.testServiceWithCannotConnect.serviceName=testServiceWithCannotConnect",
				"webclient.configs.testService.baseUrl=http://testService",
				"webclient.configs.testService.serviceName=testService",
				"webclient.configs.testService.responseTimeout=1s",
				"webclient.configs.testService.retryablePaths[0]=/delay/3",
				"webclient.configs.testService.retryablePaths[1]=/status/4*",
				"spring.cloud.loadbalancer.zone=zone1",
				"resilience4j.retry.configs.default.maxAttempts=3",
				"resilience4j.circuitbreaker.configs.default.failureRateThreshold=50",
				"resilience4j.circuitbreaker.configs.default.slidingWindowType=TIME_BASED",
				"resilience4j.circuitbreaker.configs.default.slidingWindowSize=5",
				//由于重试是 3 次,为了防止断路器打开影响测试,设置为正比如重试多一次的次数,防止触发
				//同时咱们在测试的时候也须要手动清空断路器统计
				"resilience4j.circuitbreaker.configs.default.minimumNumberOfCalls=4",
				"resilience4j.circuitbreaker.configs.default.recordExceptions=java.lang.Exception"
		],
		classes = MockConfig
)
class WebClientUnitTest extends Specification {
	@SpringBootApplication
	static class MockConfig {
	}
	@SpringBean
	private LoadBalancerClientFactory loadBalancerClientFactory = Mock()

	@Autowired
	private CircuitBreakerRegistry circuitBreakerRegistry
	@Autowired
	private Tracer tracer
	@Autowired
	private ServiceInstanceMetrics serviceInstanceMetrics
	@Autowired
	private WebClientNamedContextFactory webClientNamedContextFactory

	//不一样的测试方法的类对象不是同一个对象,会从新生成,保证互相没有影响
	def zone1Instance1 = new DefaultServiceInstance(instanceId: "instance1", host: "www.httpbin.org", port: 80, metadata: Map.ofEntries(Map.entry("zone", "zone1")))
	def zone1Instance2 = new DefaultServiceInstance(instanceId: "instance2", host: "www.httpbin.org", port: 8081, metadata: Map.ofEntries(Map.entry("zone", "zone1")))
	def zone1Instance3 = new DefaultServiceInstance(instanceId: "instance3", host: "httpbin.org", port: 80, metadata: Map.ofEntries(Map.entry("zone", "zone1")))
	RoundRobinWithRequestSeparatedPositionLoadBalancer loadBalancerClientFactoryInstance = Spy();
	ServiceInstanceListSupplier serviceInstanceListSupplier = Spy();

	//全部测试的方法执行前会调用的方法
	def setup() {
		//初始化 loadBalancerClientFactoryInstance 负载均衡器
		loadBalancerClientFactoryInstance.setTracer(tracer)
		loadBalancerClientFactoryInstance.setServiceInstanceMetrics(serviceInstanceMetrics)
		loadBalancerClientFactoryInstance.setServiceInstanceListSupplier(serviceInstanceListSupplier)
	}

	def "测试断路器异常重试以及断路器级别"() {
		given: "设置 testService 的实例都是正常实例"
			loadBalancerClientFactory.getInstance("testService") >> loadBalancerClientFactoryInstance
			serviceInstanceListSupplier.get() >> Flux.just(Lists.newArrayList(zone1Instance1, zone1Instance3))
		when: "断路器打开"
			//清除断路器影响
			circuitBreakerRegistry.getAllCircuitBreakers().forEach({ c -> c.reset() })
			loadBalancerClientFactoryInstance = (RoundRobinWithRequestSeparatedPositionLoadBalancer) loadBalancerClientFactory.getInstance("testService")
			def breaker
			try {
				breaker = circuitBreakerRegistry.circuitBreaker("httpbin.org:80/anything", "testService")
			} catch (ConfigurationNotFoundException e) {
				breaker = circuitBreakerRegistry.circuitBreaker("httpbin.org:80/anything")
			}
			//打开实例 3 的断路器
			breaker.transitionToOpenState()
			//调用 10 次
			for (i in 0..<10) {
				Mono<String> stringMono = webClientNamedContextFactory.getWebClient("testService")
																	  .get().uri("/anything").retrieve()
																	  .bodyToMono(String.class)
				println(stringMono.block())
			}
		then:"调用至少 10 次负载均衡器且没有异常即成功"
			(10.._) * loadBalancerClientFactoryInstance.getInstanceResponseByRoundRobin(*_)
		when: "调用不一样的路径,验证断路器在这个路径上都是关闭"
			//调用 10 次
			for (i in 0..<10) {
				Mono<String> stringMono = webClientNamedContextFactory.getWebClient("testService")
																	  .get().uri("/status/200").retrieve()
																	  .bodyToMono(String.class)
				println(stringMono.block())
			}
		then: "调用必须为正好 10 次表明没有重试,一次成功,断路器之间相互隔离"
			10 * loadBalancerClientFactoryInstance.getInstanceResponseByRoundRobin(*_)
	}
}

测试针对 connectTimeout 重试

对于链接超时,咱们须要验证:不管是否能够重试的方法或者路径,都必须重试,由于请求并无真的发出去。能够这样验证:设置微服务 testServiceWithCannotConnect 一个实例正常,另外一个实例会链接超时,咱们配置了重试 3 次,因此每次请求应该都能成功,而且随着程序运行,后面的调用不可用的实例还会被断路,照样能够成功调用。spring-boot

@SpringBootTest(
		properties = [
				"webclient.configs.testServiceWithCannotConnect.baseUrl=http://testServiceWithCannotConnect",
				"webclient.configs.testServiceWithCannotConnect.serviceName=testServiceWithCannotConnect",
				"webclient.configs.testService.baseUrl=http://testService",
				"webclient.configs.testService.serviceName=testService",
				"webclient.configs.testService.responseTimeout=1s",
				"webclient.configs.testService.retryablePaths[0]=/delay/3",
				"webclient.configs.testService.retryablePaths[1]=/status/4*",
				"spring.cloud.loadbalancer.zone=zone1",
				"resilience4j.retry.configs.default.maxAttempts=3",
				"resilience4j.circuitbreaker.configs.default.failureRateThreshold=50",
				"resilience4j.circuitbreaker.configs.default.slidingWindowType=TIME_BASED",
				"resilience4j.circuitbreaker.configs.default.slidingWindowSize=5",
				//由于重试是 3 次,为了防止断路器打开影响测试,设置为正比如重试多一次的次数,防止触发
				//同时咱们在测试的时候也须要手动清空断路器统计
				"resilience4j.circuitbreaker.configs.default.minimumNumberOfCalls=4",
				"resilience4j.circuitbreaker.configs.default.recordExceptions=java.lang.Exception"
		],
		classes = MockConfig
)
class WebClientUnitTest extends Specification {
	@SpringBootApplication
	static class MockConfig {
	}
	@SpringBean
	private LoadBalancerClientFactory loadBalancerClientFactory = Mock()

	@Autowired
	private CircuitBreakerRegistry circuitBreakerRegistry
	@Autowired
	private Tracer tracer
	@Autowired
	private ServiceInstanceMetrics serviceInstanceMetrics
	@Autowired
	private WebClientNamedContextFactory webClientNamedContextFactory

	//不一样的测试方法的类对象不是同一个对象,会从新生成,保证互相没有影响
	def zone1Instance1 = new DefaultServiceInstance(instanceId: "instance1", host: "www.httpbin.org", port: 80, metadata: Map.ofEntries(Map.entry("zone", "zone1")))
	def zone1Instance2 = new DefaultServiceInstance(instanceId: "instance2", host: "www.httpbin.org", port: 8081, metadata: Map.ofEntries(Map.entry("zone", "zone1")))
	def zone1Instance3 = new DefaultServiceInstance(instanceId: "instance3", host: "httpbin.org", port: 80, metadata: Map.ofEntries(Map.entry("zone", "zone1")))
	RoundRobinWithRequestSeparatedPositionLoadBalancer loadBalancerClientFactoryInstance = Spy();
	ServiceInstanceListSupplier serviceInstanceListSupplier = Spy();

	//全部测试的方法执行前会调用的方法
	def setup() {
		//初始化 loadBalancerClientFactoryInstance 负载均衡器
		loadBalancerClientFactoryInstance.setTracer(tracer)
		loadBalancerClientFactoryInstance.setServiceInstanceMetrics(serviceInstanceMetrics)
		loadBalancerClientFactoryInstance.setServiceInstanceListSupplier(serviceInstanceListSupplier)
	}

	def "测试针对 connectTimeout 重试"() {
		given: "设置微服务 testServiceWithCannotConnect 一个实例正常,另外一个实例会链接超时"
			loadBalancerClientFactory.getInstance("testServiceWithCannotConnect") >> loadBalancerClientFactoryInstance
			serviceInstanceListSupplier.get() >> Flux.just(Lists.newArrayList(zone1Instance1, zone1Instance2))
		when:
			//因为咱们针对 testService 返回了两个实例,一个能够正常链接,一个不能够,可是咱们配置了重试 3 次,因此每次请求应该都能成功,而且随着程序运行,后面的调用不可用的实例还会被断路
			//这里主要测试针对 connect time out 还有 断路器打开的状况都会重试,而且不管是 GET 方法仍是其余的
			Span span = tracer.nextSpan()
			for (i in 0..<10) {
				Tracer.SpanInScope cleared = tracer.withSpanInScope(span)
				try {
					//测试 get 方法(默认 get 方法会重试)
					Mono<String> stringMono = webClientNamedContextFactory.getWebClient("testServiceWithCannotConnect")
																		  .get().uri("/anything").retrieve()
																		  .bodyToMono(String.class)
					println(stringMono.block())
					//测试 post 方法(默认 post 方法针对请求已经发出的不会重试,这里没有发出请求因此仍是会重试的)
					stringMono = webClientNamedContextFactory.getWebClient("testServiceWithCannotConnect")
															 .post().uri("/anything").retrieve()
															 .bodyToMono(String.class)
					println(stringMono.block())
				}
				finally {
					cleared.close()
				}
			}
		then:"调用至少 20 次负载均衡器且没有异常即成功"
			(20.._) * loadBalancerClientFactoryInstance.getInstanceResponseByRoundRobin(*_)
	}
}

微信搜索“个人编程喵”关注公众号,每日一刷,轻松提高技术,斩获各类offer微服务