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

乾貨滿滿張雜湊發表於2021-11-23

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

我們繼續上一節,繼續使用 spock 測試我們自己封裝的 WebClient

測試針對 readTimeout 重試

針對響應超時,我們需要驗證重試僅針對可以重試的方法(包括 GET 方法以及配置的可重試方法),針對不可重試的方法沒有重試。我們可以通過 spock 單元測試中,檢查對於負載均衡器獲取例項方法的呼叫次數看出來是否有重試

我們通過 httpbin.org 的 '/delay/秒' 實現 readTimeout,分別驗證:

  • 測試 GET 延遲 2 秒返回,超過讀取超時,這時候會重試
  • 測試 POST 延遲 3 秒返回,超過讀取超時,同時路徑在重試路徑中,這樣也是會重試的
  • 測試 POST 延遲 2 秒返回,超過讀取超時,同時路徑在重試路徑中,這樣不會重試

程式碼如下:

@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 "測試針對 readTimeout 重試"() {
		given: "設定 testService 的例項都是正常例項"
			loadBalancerClientFactory.getInstance("testService") >> loadBalancerClientFactoryInstance
			serviceInstanceListSupplier.get() >> Flux.just(Lists.newArrayList(zone1Instance1, zone1Instance3))
		when: "測試 GET 延遲 2 秒返回,超過讀取超時"
			//清除斷路器影響
			circuitBreakerRegistry.getAllCircuitBreakers().forEach({ c -> c.reset() })
			try {
				webClientNamedContextFactory.getWebClient("testService")
											.get().uri("/delay/2").retrieve()
											.bodyToMono(String.class).block();
			} catch (WebClientRequestException e) {
				if (e.getCause() in  ReadTimeoutException) {
					//讀取超時忽略
				} else {
					throw e;
				}
			}
		then: "每次都會超時所以會重試,根據配置一共有 3 次"
			3 * loadBalancerClientFactoryInstance.getInstanceResponseByRoundRobin(*_)
		when: "測試 POST 延遲 3 秒返回,超過讀取超時,同時路徑在重試路徑中"
			//清除斷路器影響
			circuitBreakerRegistry.getAllCircuitBreakers().forEach({ c -> c.reset() })
			try {
				webClientNamedContextFactory.getWebClient("testService")
											.post().uri("/delay/3").retrieve()
											.bodyToMono(String.class).block();
			} catch (WebClientRequestException e) {
				if (e.getCause() in  ReadTimeoutException) {
					//讀取超時忽略
				} else {
					throw e;
				}
			}
		then: "每次都會超時所以會重試,根據配置一共有 3 次"
			3 * loadBalancerClientFactoryInstance.getInstanceResponseByRoundRobin(*_)
		when: "測試 POST 延遲 2 秒返回,超過讀取超時,這個不能重試"
			//清除斷路器影響
			circuitBreakerRegistry.getAllCircuitBreakers().forEach({ c -> c.reset() })
			try {
				webClientNamedContextFactory.getWebClient("testService")
											.post().uri("/delay/2").retrieve()
											.bodyToMono(String.class).block();
			} catch (WebClientRequestException e) {
				if (e.getCause() in  ReadTimeoutException) {
					//讀取超時忽略
				} else {
					throw e;
				}
			}
		then: "沒有重試,只有一次呼叫"
			1 * loadBalancerClientFactoryInstance.getInstanceResponseByRoundRobin(*_)
	}
}

測試非 2xx 響應碼返回的重試

對於非 2xx 的響應碼,代表請求失敗,我們需要測試:

  • 測試 GET 返回 500,會有重試
  • 測試 POST 返回 500,沒有重試
  • 測試 POST 返回 400,這個請求路徑在重試路徑中,會有重試
@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 "測試非 200 響應碼返回" () {
		given: "設定 testService 的例項都是正常例項"
			loadBalancerClientFactory.getInstance("testService") >> loadBalancerClientFactoryInstance
			serviceInstanceListSupplier.get() >> Flux.just(Lists.newArrayList(zone1Instance1, zone1Instance3))
		when: "測試 GET 返回 500"
			//清除斷路器影響
			circuitBreakerRegistry.getAllCircuitBreakers().forEach({ c -> c.reset() })
			try {
				webClientNamedContextFactory.getWebClient("testService")
											.get().uri("/status/500").retrieve()
											.bodyToMono(String.class).block();
			} catch (WebClientResponseException e) {
				if (e.getStatusCode().is5xxServerError()) {
					//5xx忽略
				} else {
					throw e;
				}
			}
		then: "每次都沒有返回 2xx 所以會重試,根據配置一共有 3 次"
			3 * loadBalancerClientFactoryInstance.getInstanceResponseByRoundRobin(*_)
		when: "測試 POST 返回 500"
			//清除斷路器影響
			circuitBreakerRegistry.getAllCircuitBreakers().forEach({ c -> c.reset() })
			try {
				webClientNamedContextFactory.getWebClient("testService")
											.post().uri("/status/500").retrieve()
											.bodyToMono(String.class).block();
			} catch (WebClientResponseException e) {
				if (e.getStatusCode().is5xxServerError()) {
					//5xx忽略
				} else {
					throw e;
				}
			}
		then: "POST 預設不重試,所以只會呼叫一次"
			1 * loadBalancerClientFactoryInstance.getInstanceResponseByRoundRobin(*_)
		when: "測試 POST 返回 400,這個請求路徑在重試路徑中"
			//清除斷路器影響
			circuitBreakerRegistry.getAllCircuitBreakers().forEach({ c -> c.reset() })
			try {
				webClientNamedContextFactory.getWebClient("testService")
											.post().uri("/status/400").retrieve()
											.bodyToMono(String.class).block();
			} catch (WebClientResponseException e) {
				if (e.getStatusCode().is4xxClientError()) {
					//4xx忽略
				} else {
					throw e;
				}
			}
		then: "路徑在重試路徑中,所以會重試"
			3 * loadBalancerClientFactoryInstance.getInstanceResponseByRoundRobin(*_)
	}
}

微信搜尋“我的程式設計喵”關注公眾號,每日一刷,輕鬆提升技術,斬獲各種offer

相關文章