如何对 spring 的网关进行单元测试?

How to unit test spring's gateway?

我的网关会将流量重定向到许多不同的服务(在不同的域名下)。我如何测试网关的配置?只有一项服务,我可以设置模拟服务器(如 httpbin)并测试响应。对于多项服务,我宁愿避免启动整个 docker 网络或更改 locak dns 别名。 spring 是否提供任何测试网关的轻量级方法?

以下是如何使用 API Simulator 实现您想要的:

package my.package;

import static com.apisimulator.embedded.SuchThat.isEqualTo;
import static com.apisimulator.embedded.SuchThat.startsWith;
import static com.apisimulator.embedded.http.HttpApiSimulation.httpApiSimulation;
import static com.apisimulator.embedded.http.HttpApiSimulation.httpRequest;
import static com.apisimulator.embedded.http.HttpApiSimulation.httpResponse;
import static com.apisimulator.embedded.http.HttpApiSimulation.simlet;
import static com.apisimulator.http.Http1Header.CONTENT_TYPE;
import static com.apisimulator.http.HttpMethod.CONNECT;
import static com.apisimulator.http.HttpMethod.GET;
import static com.apisimulator.http.HttpStatus.OK;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

import java.time.Duration;
import java.util.Map;

import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.SocketUtils;

import com.apisimulator.embedded.http.JUnitHttpApiSimulation;

@RunWith(SpringRunner.class)
@SpringBootTest(
   webEnvironment = RANDOM_PORT,
   properties = { 
      "management.server.port=${test.port}", "logging.level.root=info",
      // Configure the Gateway to use HTTP proxy - the API Simulator 
      // instance running at localhost:6090
      "spring.cloud.gateway.httpclient.proxy.host=localhost",
      "spring.cloud.gateway.httpclient.proxy.port=6090"
      //"logging.level.reactor.netty.http.server=debug",
      //"spring.cloud.gateway.httpserver.wiretap=true" 
   }
)
@Import(ServiceGatewayApplication.class)
public class ServiceGatewayApplicationTest
{

   // Configure an API simulation. This starts up an instance 
   // of API Simulator on localhost, default port 6090
   @ClassRule
   public static final JUnitHttpApiSimulation clApiSimulation = JUnitHttpApiSimulation
            .as(httpApiSimulation("svc-gateway-backends"));

   protected static int managementPort;

   @LocalServerPort
   protected int port = 0;

   protected String baseUri;
   protected WebTestClient webClient;

   @BeforeClass
   public static void beforeClass()
   {
      managementPort = SocketUtils.findAvailableTcpPort();
      System.setProperty("test.port", String.valueOf(managementPort));

      // Configure simlets for the API simulation
      // @formatter:off
      clApiSimulation.add(simlet("http-proxy")
         .when(httpRequest(CONNECT))
         .then(httpResponse(OK))
      );

      clApiSimulation.add(simlet("test-domain-1")
         .when(httpRequest()
               .whereMethod(GET)
               .whereUriPath(isEqualTo("/static"))
               // The `host` header is used to determine the actual destination 
               .whereHeader("host", startsWith("domain-1.com"))
          )
         .then(httpResponse()
               .withStatus(OK)
               .withHeader(CONTENT_TYPE, "application/text")
               .withBody("{ \"domain\": \"1\" }")
          )
      );

      clApiSimulation.add(simlet("test-domain-2")
         .when(httpRequest()
               .whereMethod(GET)
               .whereUriPath(isEqualTo("/v1/api/foo"))
               .whereHeader("host", startsWith("domain-2.com"))
          )
         .then(httpResponse()
               .withStatus(OK)
               .withHeader(CONTENT_TYPE, "application/json; charset=UTF-8")
               .withBody(
                  "{\n" +
                  "   \"domain\": \"2\"\n" + 
                  "}"
                )
          )
      );
      // @formatter:on
   }

   @AfterClass
   public static void afterClass()
   {
      System.clearProperty("test.port");
   }

   @Before
   public void setup()
   {
      // @formatter:off
      baseUri = "http://localhost:" + port;
      webClient = WebTestClient.bindToServer()
         .baseUrl(baseUri)
         .responseTimeout(Duration.ofSeconds(2))
         .build();
      // @formatter:on
   }

   @Test
   public void test_domain1()
   {
      // @formatter:off
      webClient.get()
         .uri("/static")
         .exchange()
         .expectStatus().isOk()
         .expectBody(String.class).consumeWith(result -> 
             assertThat(result.getResponseBody()).isEqualTo("{ \"domain\": \"1\" }")
          );
      // @formatter:on
   }

   @Test
   public void test_domain2()
   {
      // @formatter:off
      webClient.get()
         .uri("/v1/api/foo")
         .exchange()
         .expectStatus().isOk()
         .expectHeader()
            .contentType("application/json; charset=UTF-8")
         .expectBody(Map.class).consumeWith(result -> 
             assertThat(result.getResponseBody()).containsEntry("domain", "2")
          );
      // @formatter:on
   }

}

大部分代码基于 GatewaySampleApplicationTests class 来自 Spring Cloud Gateway 项目。

以上假定网关具有与这些类似的路由(仅限片段):

    ...
    uri: "http://domain-1.com"
    predicates:
      - Path=/static
    ...
    uri: "http://domain-2.com"
    predicates:
      - Path=/v1/api/foo
    ...

@apsisim 提供了使用网络代理的好主意。但他建议的工具不在任何 Maven 仓库中,并且具有商业许可。什么对我有用:

运行 网关,因此它将使用代理(你可以更花哨,找到一个空闲端口):

private const val proxyPort = 1080

@SpringBootTest(
    properties = [
        //"logging.level.reactor.netty.http.server=debug",
        //"spring.cloud.gateway.httpserver.wiretap=true",
        //"spring.cloud.gateway.httpclient.wiretap=true",
        "spring.cloud.gateway.httpclient.proxy.host=localhost",
        "spring.cloud.gateway.httpclient.proxy.port=$proxyPort"
    ]
)

然后使用模拟网络服务器作为代理

testImplementation("com.squareup.okhttp3:mockwebserver:4.2.1")
testImplementation("com.squareup.okhttp3:okhttp:4.2.1")

然后您的所有请求都将转到您的代理。请记住,http 协议指定对新服务器的第一个请求需要通过代理进行隧道传输,因此当您对网关进行第一个请求时,网关将向代理发送 2 个请求:

testClient.get()
            .uri(path)
            .header("host", gatewayDns)
            .exchange()

nextRequestFromGateway {
    method `should equal` "CONNECT"
    headers[HOST] `should equal` "$realUrlBehindGateway:80"
}

nextRequestFromGateway {
    path `should equal` "/api/v1/whatever"
    headers[HOST] `should equal` realUrlBehindGateway
}

...
fun nextRequestFromGateway(block : RecordedRequest.() -> Unit) {
    mockWebServer.takeRequest().apply (block)
}