SpringCloud升級之路2020.0.x版-40. spock 單元測試封裝的 WebClient(上)

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

我們來測試下前面封裝好的 WebClient,這裏開始,我們使用 spock 編寫 groovy 單元測試,這種編寫出來的單元測試,代碼更加簡潔,同時更加靈活,我們在接下來的單元測試代碼中就能看出來。

編寫基於 spock 的 spring-boot context 測試

我們加入前面設計的配置,編寫測試類:

@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 {
	}
}

我們加入三個服務實例供單元測試調用:

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 並覆蓋:

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 次,所以每次請求應該都能成功,並且隨着程序運行,後面的調用不可用的實例還會被斷路,照樣可以成功調用。

@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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章