11.3 测试反应式控制器

11.3 测试反应式控制器

在反应式控制器的测试方面,Spring 5并没有置我们于不顾。实际上,Spring 5引入了WebTestClient。这是一个新的测试工具类,让Spring WebFlux编写的反应式控制器的测试变得非常容易。为了了解如何使用WebTestClient编写测试,我们首先使用它测试11.1.2小节中编写的DesignTacoController中的recentTacos()方法。

11.3.1 测试GET请求

对于recentTacos()方法,我们想断言如果针对“/design/recent”路径发送HTTPGET请求,那么将会得到JSON载荷的响应并且taco的数量不会超过12个。程序清单11.1中的测试类将会是一个很好的起点。

程序清单11.1 使用WebTestClient测试DesignTacoController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package tacos;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import tacos.Ingredient.Type;
import tacos.data.TacoRepository;
import tacos.web.api.DesignTacoController;
public class DesignTacoControllerTest {
@Test
public void shouldReturnRecentTacos() {
Taco[] tacos = {
testTaco(1L), testTaco(2L),
testTaco(3L), testTaco(4L), ⇽--- 创建测试数据
testTaco(5L), testTaco(6L),
testTaco(7L), testTaco(8L),
testTaco(9L), testTaco(10L),
testTaco(11L), testTaco(12L),
testTaco(13L), testTaco(14L),
testTaco(15L), testTaco(16L)};
Flux<Taco> tacoFlux = Flux.just(tacos);
TacoRepository tacoRepo = Mockito.mock(TacoRepository.class);
when(tacoRepo.findAll()).thenReturn(tacoFlux); ⇽--- Mock TacoRepository
WebTestClient testClient = WebTestClient.bindToController(
new DesignTacoController(tacoRepo))
.build(); ⇽--- 创建WebTestClient
testClient.get().uri("/design/recent")
.exchange() ⇽--- 请求最近的taco
.expectStatus().isOk() ⇽--- 检验预期的响应
.expectBody()
.jsonPath("$").isArray()
.jsonPath("$").isNotEmpty()
.jsonPath("$[^0].id").isEqualTo(tacos[^0].getId().toString())
.jsonPath("$[^0].name").isEqualTo("Taco 1").jsonPath("$[^1].id")
.isEqualTo(tacos[^1].getId().toString()).jsonPath("$[^1].name")
.isEqualTo("Taco 2").jsonPath("$[^11].id")
.isEqualTo(tacos[^11].getId().toString())
...
.jsonPath("$[^11].name").isEqualTo("Taco 12").jsonPath("$[^12]")
.doesNotExist();
.jsonPath("$[^12]").doesNotExist();
}
...
}

shouldReturnRecentTacos()方法做的第一件事情就是以Flux<Taco>的形式创建了一些测试数据。这个Flux随后作为mock TacoRepository的findAll()方法的返回值。

Flux发布的Taco对象是由一个名为testTaco()的方法创建的。这个方法会根据一个数字生成一个Taco,其ID和名称都是基于该数字生成的。testTaco()方法的实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
private Taco testTaco(Long number) {
Taco taco = new Taco();
taco.setId(UUID.randomUUID());
taco.setName("Taco " + number);
List<IngredientUDT> ingredients = new ArrayList<>();
ingredients.add(
new IngredientUDT("INGA", "Ingredient A", Type.WRAP));
ingredients.add(
new IngredientUDT("INGB", "Ingredient B", Type.PROTEIN));
taco.setIngredients(ingredients);
return taco;
}

简单起见,所有的测试taco都具有两种相同的配料,但是它们的ID和名称是根据传入的数字确定的。

另外,回到shouldReturnRecentTacos()方法,我们实例化了一个DesignTacoController并将mock TacoRepository注入到了构造器中。这个控制器传递给了WebTestClient. bindToController()方法,以便于生成WebTestClient实例。

所有的环境搭建工作完成后,我们可以使用WebTestClient提交GET请求至“/design/recent”并校验响应符合我们的预期。对get().uri(“/design/recent”)的调用描述了我们想要发送的请求。随后,调用exchange()会提交请求,这个请求将会由WebTestClient绑定的控制器(DesignTacoController)来进行处理。

最后,我们可以确认响应符合预期。通过调用expectStatus(),我们可以断言响应具有HTTP 200 (OK)状态码。然后,我们多次调用jsonPath()断言响应体中的JSON包含它应该具有的值。最后一个断言检查第12个元素(在基于零开始计数的数组中)是否真的不存在,以此判断结果不超过12个元素。

如果返回的JSON比较复杂,比如有大量的数据或多层嵌套的数据,那么使用jsonPath()会变得非常烦琐。实际上,为了节省空间,在程序清单11.1中,我省略了很多对jsonPath()的调用。在这种情况下,使用jsonPath()会变得非常枯燥烦琐,WebTestClient提供了json()方法。这个方法可以传入一个String参数(包含响应要对比的JSON)。

举例来说,假设我们在名为recent-tacos.json的文件中创建了完整的响应JSON并将它放到了类路径的“/tacos”路径下,那么我们可以按照如下的方式重写WebTestClient断言:

1
2
3
4
5
6
7
8
9
10
ClassPathResource recentsResource =
new ClassPathResource("/tacos/recent-tacos.json");
String recentsJson = StreamUtils.copyToString(
recentsResource.getInputStream(), Charset.defaultCharset());
testClient.get().uri("/design/recent")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.json(recentsJson);

因为json()接受的是一个String,所以我们必须先将类路径资源加载为String。借助Spring中StreamUtils的copyToString()方法,这一点很容易实现。copyToString()方法返回的String就是我们的请求所预期的响应JSON内容。将其传递给json()方法,我们就能确保控制器会生成正确的输出。

WebTestClient提供的另外一种可选方案就是它允许将响应体与一个值的列表进行对比。expectBodyList()方法会接受一个代表列表中元素类型的Class或ParameterizedTypeReference,并且会返回ListBodySpec对象,随后可以基于该对象进行断言。借助expectBodyList(),我们可以重写测试类,使用创建mockTacoRepository时的测试数据的子集来进行验证:

1
2
3
4
5
6
testClient.get().uri("/design/recent")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBodyList(Taco.class)
.contains(Arrays.copyOf(tacos, 12));

在这里,我们断言响应体包含了在测试方法开头处所创建的原始Taco数组的前12个元素。

11.3.2 测试POST请求

WebTestClient不仅能对控制器发送GET请求,还能用来测试各种HTTP方法,包括GET、POST、PUT、PATCH、DELETE和HEAD方法。表11.1将HTTP方法与WebTestClient的方法进行了映射。

表11.1 WebTestClient能够测试针对Spring WebFlux控制器的各种请求

epub_29101559_89

作为测试Spring WebFlux控制器其他HTTP请求方法的样例,我们看一下针对DesignTacoController的另一个测试。这一次,我们会编写一个对taco创建API的测试,也就是提交POST请求到“/design”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void shouldSaveATaco() {
TacoRepository tacoRepo = Mockito.mock(
TacoRepository.class); ⇽--- 搭建测试数据
Mono<Taco> unsavedTacoMono = Mono.just(testTaco(null));
Taco savedTaco = testTaco(null);
savedTaco.setId(1L);
Mono<Taco> savedTacoMono = Mono.just(savedTaco);
when(tacoRepo.save(any())).thenReturn(savedTacoMono); ⇽--- mock TacoRepository
WebTestClient testClient = WebTestClient.bindToController( ⇽--- 创建WebTestClient
new DesignTacoController(tacoRepo)).build();
testClient.post() ⇽--- POST taco
.uri("/design")
.contentType(MediaType.APPLICATION_JSON)
.body(unsavedTacoMono, Taco.class)
.exchange()
.expectStatus().isCreated() ⇽--- 校验响应
.expectBody(Taco.class)
.isEqualTo(savedTaco);
}

与上面的测试方法类似,shouldSaveATaco()首先会创建一些测试数据和mockTacoRepository,并且创建了一个WebTestClient并绑定到控制器上。随后,它使用WebTestClient提交POST请求到“/design”,并且将请求体声明为application/json类型,请求载荷为Taco的JSON序列化形式,放到未保存的Mono中。在执行exchange()之后,测试断言响应状态为HTTP 201 (CREATED)并且响应体中的载荷与已保存的Taco对象相同。

11.3.3 使用实时服务器进行测试

到目前为止,我们所编写的测试都依赖于Spring WebFlux的mock实现,所以并不需要真正的服务器。但是,我们可能需要在服务器(如Netty或Tomcat)环境中测试WebFlux控制器,也许还会需要repository或其他的依赖。换句话说,我们有可能要编写集成测试。

要编写WebTestClient的集成测试,与其他的Spring Boot集成测试类似,我们首先要为测试类添加@RunWith@SpringBootTest

1
2
3
4
5
6
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment=WebEnvironment.RANDOM_PORT)
public class DesignTacoControllerWebTest {
@Autowired
private WebTestClient testClient;
}

通过将webEnvironment属性设置为WebEnvironment.RANDOM_PORT,我们要求Spring启动一个运行时服务器并监听任意选择的端口
^1

你可能也注意到,我们将WebTestClient自动织入到了测试类中。这不仅意味着我们不用在测试的方法中创建它了,而且在发送请求的时候也不需要指定完整的URL了。这是因为WebTestClient能够知道测试服务器在哪个端口上运行。现在,我们可以使用自动织入的WebTestClient将shouldReturnRecentTacos()重写为集成测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void shouldReturnRecentTacos() throws IOException {
testClient.get().uri("/design/recent")
.accept(MediaType.APPLICATION_JSON).exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$[?(@.id == 'TACO1')].name")
.isEqualTo("Carnivore")
.jsonPath("$[?(@.id == 'TACO2')].name")
.isEqualTo("Bovine Bounty")
.jsonPath("$[?(@.id == 'TACO3')].name")
.isEqualTo("Veg-Out");
}

我们发现,这个新版本的shouldReturnRecentTacos()代码要少得多。我们不再需要创建WebTestClient,因为可以使用自动织入的实例。另外,也不需要mockTacoRepository,因为Spring将会创建DesignTacoController实例并将一个真正的TacoRepository注入进来。在新版本的测试方法中,我们使用JSONPath表达式来校验数据库提供的值。

WebTestClient在测试的时候非常有用,此时我们会消费WebFlux控制器所暴露的API。但是,如果我们的应用本身要消费某个API,又该怎样处理呢?接下来,我们将注意力转向Spring反应式Web的客户端,看一下WebClient如何通过REST客户端来处理反应式类型,如Mono和Flux。