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

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

本系列程式碼地址: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

相關文章