Spring cloud upgrade 2020.0.x version - 40. spock unit test encapsulated WebClient

Keywords: Spring Cloud

Code address of this series: https://github.com/JoJoTec/spring-cloud-parent

Let's test the previously encapsulated WebClient. From here on, we use spock to write groovy unit tests. The written unit tests are simpler and more flexible. We can see it in the next unit test code.

Write spring boot context test based on spock

We add the configuration designed earlier and write the test class:

@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",
				//Because the retry is 3 times, in order to prevent the opening of the circuit breaker from affecting the test, it is set to exactly one more time to prevent triggering
				//At the same time, we also need to manually clear the circuit breaker during the test
				"resilience4j.circuitbreaker.configs.default.minimumNumberOfCalls=4",
				"resilience4j.circuitbreaker.configs.default.recordExceptions=java.lang.Exception"
		],
		classes = MockConfig
)
class WebClientUnitTest extends Specification {
    @SpringBootApplication
	static class MockConfig {
	}
}

We add three service instances for unit test calls:

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")))
}

We need to dynamically specify load balancing to obtain the response of the service instance list, that is, go to the ServiceInstanceListSupplier of Mock load balancer and overwrite:

class WebClientUnitTest extends Specification {

    @Autowired
	private Tracer tracer
	@Autowired
	private ServiceInstanceMetrics serviceInstanceMetrics
    
    RoundRobinWithRequestSeparatedPositionLoadBalancer loadBalancerClientFactoryInstance = Spy();
	ServiceInstanceListSupplier serviceInstanceListSupplier = Spy();
	
	//The method that will be called before the execution of all tested methods
	def setup() {
		//Initialize loadBalancerClientFactoryInstance load balancer
		loadBalancerClientFactoryInstance.setTracer(tracer)
		loadBalancerClientFactoryInstance.setServiceInstanceMetrics(serviceInstanceMetrics)
		loadBalancerClientFactoryInstance.setServiceInstanceListSupplier(serviceInstanceListSupplier)
	}
}

After that, we can dynamically specify the microservice return instance through the following groovy Code:

//Specify the LoadBalancer of testService microservice as loadBalancerClientFactoryInstance
loadBalancerClientFactory.getInstance("testService") >> loadBalancerClientFactoryInstance
//Specify the testService micro service instance list as zone1instance1 and zone1instance3
serviceInstanceListSupplier.get() >> Flux.just(Lists.newArrayList(zone1Instance1, zone1Instance3))

Test circuit breaker abnormal retry and circuit breaker level

We need to verify:

  • For the exception of circuit breaker opening, since no request is sent, it is necessary to retry other instances directly. We can set up a microservice, including two instances, open a path breaker of one instance, and then call the path interface of the microservice many times to see whether the calls are successful (because there are retries, each call will be successful). At the same time, it is verified that the number of calls for the load balancer to obtain the service instance is more than the number of calls (each retry will call the load balancer to obtain a new instance for calling)
  • When one path breaker is open, other path breakers will not open. After opening the circuit breaker of a path of an instance of a microservice, we call other paths successfully no matter how many times, and the number of times we call the load balancer to obtain the service instance is equal to the number of calls, which means that there is no retry, that is, there is no circuit breaker exception.

Write code:

@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",
				//Because the retry is 3 times, in order to prevent the opening of the circuit breaker from affecting the test, it is set to exactly one more time to prevent triggering
				//At the same time, we also need to manually clear the circuit breaker during the test
				"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

	//The class objects of different test methods are not the same object and will be regenerated to ensure that they do not affect each other
	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();

	//The method that will be called before the execution of all tested methods
	def setup() {
		//Initialize loadBalancerClientFactoryInstance load balancer
		loadBalancerClientFactoryInstance.setTracer(tracer)
		loadBalancerClientFactoryInstance.setServiceInstanceMetrics(serviceInstanceMetrics)
		loadBalancerClientFactoryInstance.setServiceInstanceListSupplier(serviceInstanceListSupplier)
	}

	def "Test circuit breaker abnormal retry and circuit breaker level"() {
		given: "set up testService All instances are normal"
			loadBalancerClientFactory.getInstance("testService") >> loadBalancerClientFactoryInstance
			serviceInstanceListSupplier.get() >> Flux.just(Lists.newArrayList(zone1Instance1, zone1Instance3))
		when: "Circuit breaker open"
			//Clear circuit breaker effects
			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")
			}
			//Open the circuit breaker of example 3
			breaker.transitionToOpenState()
			//Call 10 times
			for (i in 0..<10) {
				Mono<String> stringMono = webClientNamedContextFactory.getWebClient("testService")
																	  .get().uri("/anything").retrieve()
																	  .bodyToMono(String.class)
				println(stringMono.block())
			}
		then:"Call the load balancer at least 10 times without exception"
			(10.._) * loadBalancerClientFactoryInstance.getInstanceResponseByRoundRobin(*_)
		when: "Call different paths and verify that the circuit breaker is closed on this path"
			//Call 10 times
			for (i in 0..<10) {
				Mono<String> stringMono = webClientNamedContextFactory.getWebClient("testService")
																	  .get().uri("/status/200").retrieve()
																	  .bodyToMono(String.class)
				println(stringMono.block())
			}
		then: "The call must be exactly 10 times, which means no retry, one success, and the circuit breakers are isolated from each other"
			10 * loadBalancerClientFactoryInstance.getInstanceResponseByRoundRobin(*_)
	}
}

Test retry for connectTimeout

For connection timeout, we need to verify that no matter whether the method or path can be retried, it must be retried because the request is not really sent. It can be verified as follows: one instance of the microservice testServiceWithCannotConnect is set to be normal, and the other instance will timeout. We have configured retry for 3 times, so each request should succeed. With the program running, the instances that are unavailable for subsequent calls will be disconnected, and they can still be called successfully.

@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",
				//Because the retry is 3 times, in order to prevent the opening of the circuit breaker from affecting the test, it is set to exactly one more time to prevent triggering
				//At the same time, we also need to manually clear the circuit breaker during the test
				"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

	//The class objects of different test methods are not the same object and will be regenerated to ensure that they do not affect each other
	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();

	//The method that will be called before the execution of all tested methods
	def setup() {
		//Initialize loadBalancerClientFactoryInstance load balancer
		loadBalancerClientFactoryInstance.setTracer(tracer)
		loadBalancerClientFactoryInstance.setServiceInstanceMetrics(serviceInstanceMetrics)
		loadBalancerClientFactoryInstance.setServiceInstanceListSupplier(serviceInstanceListSupplier)
	}

	def "Test for connectTimeout retry "() {
		given: "Set up microservices testServiceWithCannotConnect One instance is normal, and the other instance will timeout"
			loadBalancerClientFactory.getInstance("testServiceWithCannotConnect") >> loadBalancerClientFactoryInstance
			serviceInstanceListSupplier.get() >> Flux.just(Lists.newArrayList(zone1Instance1, zone1Instance2))
		when:
			//Because we returned two instances for testService, one can connect normally and the other can't, but we configured to retry three times, so each request should succeed, and the instances that are unavailable for subsequent calls will be disconnected as the program runs
			//The main test here is to retry when the connect time out and the circuit breaker are open, and whether it is the GET method or other methods
			Span span = tracer.nextSpan()
			for (i in 0..<10) {
				Tracer.SpanInScope cleared = tracer.withSpanInScope(span)
				try {
					//Test the get method (the default get method will retry)
					Mono<String> stringMono = webClientNamedContextFactory.getWebClient("testServiceWithCannotConnect")
																		  .get().uri("/anything").retrieve()
																		  .bodyToMono(String.class)
					println(stringMono.block())
					//Test the post method (the default post method will not retry if the request has been issued. There is no request issued here, so it will still retry)
					stringMono = webClientNamedContextFactory.getWebClient("testServiceWithCannotConnect")
															 .post().uri("/anything").retrieve()
															 .bodyToMono(String.class)
					println(stringMono.block())
				}
				finally {
					cleared.close()
				}
			}
		then:"Call the load balancer at least 20 times without exception"
			(20.._) * loadBalancerClientFactoryInstance.getInstanceResponseByRoundRobin(*_)
	}
}

WeChat search "my programming meow" attention to the official account, daily brush, easy to upgrade technology, and capture all kinds of offer:

Posted by cowboy_x on Mon, 22 Nov 2021 06:00:25 -0800