文档结构  
翻译进度:已翻译     翻译赏金:0 元 (?)    ¥ 我要打赏

测试 RESTful Web服务可能会比较麻烦,因为我们需要考虑一些底层的东西,这样会让测试显得冗余、阅读困难甚至难以维护。幸运的是,有很多类库和最佳实践可以帮助我们让集成测试变得简洁、清晰、解耦且易于维护。本文就是来阐述这些最佳实践的。

长文慎入

  • 要实现易读和易于维护的测试。如果你让你的测试烂掉了,那么你就是在将你的服务置于死地。
  • 不要在测试中依赖RESTful服务内部的那些东西(比如服务类、数据库结构)。这样会使测试变得脆弱。
  • 使用POJOs和对象映射
    • 创建适合测试的POJO对象。
    • 永远使用POJO对象来创建请求内容或者检查响应内容。这样能使测试易读、简洁且类型安全。不要滥用容易出错JSON字符串。
    • 在POJO类里使用正确的setter方法,而不是臭长且神秘的参数列表。 IntelliJ IDEA可以帮我们生成正确的setter方法。
    • 考虑使用Kotlin。其data类型的类非常适合拿来写POJO对象。
  • 使用类库
    • Rest-Assured,可以方便的创建HTTP请求和对响应进行断言。
    • AssertJ,可以创建流利、类型安全且易读的断言。
    • JsonPath (集成在rest-assured中),可以轻易对JSON进行简单的检查(但是通常我们更倾向于使用POJOs)。
    • Awaitility,可以用来处理异步行为。
    • 使用对象映射工具(比如Jackson)在POJOs和JSON之间互相映射。
    • 使用OkHttp MockWebServer来测试REST的客户端。
  • 使用干净代码的原则来规范测试代码。编写出来的测试应该可以像故事一样阅读。针对这一点,可以使用子方法,然后赋以好听且能描述意义的名字。保持方法尽量短小。
第 1 段(可获 3.18 积分)

Testing-REST-Featured-Image

实现测试

开始编码吧!假定,我们要测试一个提供关于博客信息的RESTful服务。该服务提供以下待测资源:

/blogs
/blogs/<blogId>

使用Rest-Assured

Rest-Assured 提供了一个优秀且流畅的API,可以用来创建针对REST资源的可读性比较强的测试。

import static com.jayway.restassured.RestAssured.given;

@Test
public void collectionResourceOK(){
     given()
             .param("limit", 20)
             .when()
             .get("blogs")
             .then()
             .statusCode(200);
}

使用可重用的RequestSpecifications

第 2 段(可获 0.58 积分)

创建一个RequestSpecification来重用你想用于所有请求的请求配置(包括基础URL,参数,内容类型,调试日志记录等)。

private static RequestSpecification spec;

@BeforeClass
public static void initSpec(){
    spec = new RequestSpecBuilder()
            .setContentType(ContentType.JSON)
            .setBaseUri("http://localhost:8080/")
            .addFilter(new ResponseLoggingFilter())//log request and response for better debugging. You can also only log if a requests fails.
            .addFilter(new RequestLoggingFilter())
            .build();
}
@Test
public void useSpec(){
    given()
            .spec(spec)
            .param("limit", 20)
            .when()
            .get("blogs")
            .then()
            .statusCode(200);
}
第 3 段(可获 0.28 积分)

使用POJO类和对象映射

不要滥用字符串拼接或者麻烦的JsonObject来创建JSON作为请求,或者去检查响应的JSON。这样很繁琐,而且还非类型安全且容易出错的。

相反的,创建一个单独的POJO类,让像Jackson一样的ObjectMapper来为你进行序列化和反序列化。Rest-assured提供了内置的对象映射支持。

BlogDTO retrievedBlog = given()
        .spec(spec)
        .when()
        .get(locationHeader)
        .then()
        .statusCode(200)
        .extract().as(BlogDTO.class);
//check retrievedBlog object...
第 4 段(可获 0.75 积分)

为POJO使用流畅的Setter方法

不要使用普通的setter方法(有些冗余)或者带有大量参数列表的构造器(难于阅读且容易出错)来创建测试请求。相反的,应该使用流畅的setter方法。

public class BlogDTO {

    private String name;
    private String description;
    private String url;

    //let your IDE generate the getters and fluent setters for your:

    public BlogDTO setName(String name) {
        this.name = name;
        return this;
    }

    public BlogDTO setDescription(String description) {
        this.description = description;
        return this;
    }

    public BlogDTO setUrl(String url) {
        this.url = url;
        return this;
    }

    // getter...
}
第 5 段(可获 0.39 积分)

IntelliJ IDEA 可以为你生成这些setter方法。此外,还有一个很方便的快捷键。你只需要写好所需字段(比如 private String name;),然后按Alt+Insert 组合键,以及向下箭头,移至“Getter and Setter”,回车,“Setter template”项对应选择里面的 “Builder”,然后按 Shift+向上箭头(多次),最后再按回车。很酷吧!

然后,你就可以使用流畅的setter方法来编写易读且类型安全的测试数据代码。

BlogDTO newBlog = new BlogDTO()
        .setName("Example")
        .setDescription("Example")
        .setUrl("www.blogdomain.de");
第 6 段(可获 0.7 积分)

由于rest-assured支持对象映射,因此我们就只用将对象传给rest-assured即可。Rest-assured会将该对象序列化并将JSON结果放到请求体内。

String locationHeader = given()
        .spec(spec)
        .body(newBlog)
        .when()
        .post("blogs")
        .then()
        .statusCode(201)
        .extract().header("location");

考虑使用Kotlin替代Java

作为JVM语言的Kotlin可以仅用一行代码定义POJO对象。举个例子,BlogDTO类如下所示:

//definition:
data class BlogDTO (val name: String, val description: String, val url: String)

//usage:
val newBlog = BlogDTO(
        name = "Example",
        description = "Example",
        url = "www.blogdomain.de")
第 7 段(可获 0.68 积分)

有了Kotlin,我们可以很明显的减少代码量。定义好的数据类BlogDTO已经包含了一个构造器,hashCode(), equals(), toString() 和 copy()。我们不需要维护它们。此外,Kotlin还支持命名参数,这样使得构造器的调用可读性很强。这样,我们就完全不需要流畅的setter方法了。

如果你想学习更多关于Kotlin的东西,可以查看我的这篇文章“《Java生态系统值得拥有Kotlin这种语言》”

请注意,你需要添加jackson-module-kotlin到你的类路径里才可以让反序列化起作用。否则,Jackson就会提示你缺少默认构造方法。

第 8 段(可获 1.16 积分)

使用AssertJ来检查返回的POJO对象

AssertJ是一个非常棒的类库,可以用来编写流畅、易读且类型安全的测试断言。相比于Hamcrest来说,我更喜欢它,因为AssertJ会引导你找到适合现有类型的匹配器。

你可以使用AssertJ来检查响应体中返回的(已经反序列化的)POJO。

import static org.assertj.core.api.Assertions.assertThat;

BlogDTO retrievedBlog = given()
        .spec(spec)
        .when()
        .get(locationHeader)
        .then()
        .statusCode(200)
        .extract().as(BlogDTO.class);

assertThat(retrievedBlog.getName()).isEqualTo(newBlog.getName());
assertThat(retrievedBlog.getDescription()).isEqualTo(newBlog.getDescription());
assertThat(retrievedBlog.getUrl()).isEqualTo(newBlog.getUrl());
第 9 段(可获 0.69 积分)

使用AssertJ的isEqualToIgnoringGivenFields()方法

你会经常想要检查检索到的bean对象是否和你要创建的bean是否相等。使用普通的equals()来检查并不好使,因为服务类会给新的实体新生成了一个ID。因此,两个bean会由于ID字段值不同而不相等。幸运的是,你可以让AssertJ在equals检查过程中忽略某些字段。这样你就不必手动检查每一个字段了。

assertThat(retrievedEntity).isEqualToIgnoringGivenFields(expectedEntity, "id");

另外,也可以看看另外一个方法isEqualToIgnoringNullFields()。

第 10 段(可获 0.96 积分)

编写干净的测试代码

牢记干净代码的原则,编写可以作为故事阅读的代码。首先,抽取代码到一个子方法里,取个好名字。其次,保持方法简短。这样可以使得你的测试可读性强且易于维护。

经验法则:每当你在一个方法里使用空行隔开、加以注释来编写一段代码的时候,先等会儿!不要这样做,把那段代码抽取到一个新的方法里,并使用注释作为方法名(在IntelliJ里,可以使用Ctrl+Alt+M快捷键,抽取选择代码到新方法里)。

例如,以下代码可以很轻松读懂:

第 11 段(可获 1.3 积分)

如果你坚持使用Given-When-Then模式来编写测试的话,你也会得到类似的结果。每一部分(given, when, then)应该只包含几行代码(理想的话,是一行)。尝试将每一部分都抽取成为子方法就可以实现此目标。

@Test
public void test(){
    //Given: 为测试行为准备输入,比如测试数据、模拟对象、桩等。
    //When: 执行你要测试的行为。
    //Then: 使用断言检查输出。
}

针对资源的公共操作可创建为可重用的方法

观察上面的 createResource() 和 getResource() 方法。这些方法可以为每一个资源所重用。你可以将它们放到一个抽象的测试方法里,这样可以被具体的测试类所使用到。

第 12 段(可获 1.04 积分)

使用AssertJ的as()方法

使用AssertJ的 as()方法来为你的断言失败消息添加领域信息。

assertThat(retrievedBlog.getName()).as("Blog Name").isEqualTo(newBlog.getName());
assertThat(retrievedBlog.getDescription()).as("Blog Description").isEqualTo(newBlog.getDescription());
assertThat(retrievedBlog.getUrl()).as("Blog URL").isEqualTo(newBlog.getUrl());

创建量身定制的POJO类

POJO里面只添加必要的字段。服务类返回了一个拥有20个属性的JSON,但是你只对其中两个感兴趣,那该怎么办?添加@JsonIgnoreProperties(ignoreUnknown = true) 注解到你的POJO里上,这样Jackson就不会因为响应里的属性比你的POJO类多而报错了。

第 13 段(可获 0.86 积分)
@JsonIgnoreProperties(ignoreUnknown = true)
public class BlogDTO {
    private String name;
    //其他的JSON属性与本测试无关。

    //...
}

另外,你还可以将ObjectMapper的这个配置设为全局的:

objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

正如我们所看到的,使用IDE的代码生成功能和@JsonIgnoreProperties注解可以让编写POJO类变得极其简单快速。因此没有理由不去使用它们。

public字段并非总是不被允许的

有些情况下,你可以将POJO类的代码更简化些:也就是将其字段全部设为public的。 天啊,他真的建议这么做吗?!

第 14 段(可获 0.85 积分)

当响应JSON里包含有嵌套的数据而你仍然想要使用POJO类时,这就变得很有用了。 使用public字段可以帮助你仅使用几行代码就完成一个嵌套的POJO类的编写。但是我建议只在如下这些情形下才使用此方法:a) 当该类在其它项目里不会被用到(对于测试项目来说这是成立的);b) 当该类只是用来映射响应数据时。如果你想映射请求数据,那你应该使用流畅的setter方法,因为创建带有public字段的对象会显得笨笨的。所以使用此技巧一定要小心。

对于简单类使用JsonPath

如果你只对JSON响应的某一个值感兴趣,创建一个POJO类来做映射,实在是有点小题大做了。在这种情况下,就可以使用JsonPath来从JSON文本中提取出特定的值来。JsonPath有点像是JSON版的XPath。

第 15 段(可获 1.8 积分)

比方说我们想要获取一个嵌套很深的数据结构中很深处的值。

JsonPath jsonPath = new JsonPath("{\"blogs\":[\"posts\":[{\"author\":{\"name\":\"Paul\"}}]]}");
String value = jsonPath.getString("blogs[0].posts[0].author.name");

Rest-assured有两种方法可以使用JsonPath。

A:将JSON转换成一个JsonPath对象,然后使用AssertJ来检查它。

JsonPath retrievedBlogs = given()
        .spec(spec)
        .when()
        .get("blogs")
        .then()
        .statusCode(200)
        .extract().jsonPath();
assertThat(retrievedBlogs.getInt("count")).isGreaterThan(7);
assertThat(retrievedBlogs.getList("blogs")).isNotEmpty();
第 16 段(可获 0.48 积分)

B:Rest-assured内置对JsonPath的支持,不过你得使用Hamcrest的匹配器。

import static org.hamcrest.Matchers.*;

given()
        .spec(spec)
        .when()
        .get("blogs")
        .then()
        .statusCode(200)
        .content("count", greaterThan(7))
        .content("blogs", is(not(empty())));

这样相对冗余些,我个人更倾向于使用AssertJ,因为我不用去猜测哪个匹配器(Matcher)适合于哪个类型。

然而,使用JsonPath就是使用字符串来处理JSON的属性,这样比较容易出错。在其他情况下,POJO类和对象映射会是更好的选择。

第 17 段(可获 0.81 积分)

处理列表

有三种方式可以对列表进行断言。

方法1: Object mapping + AssertJ。此方法类型安全、易读且易于调试,但是有点罗嗦。

BlogListDTO retrievedBlogList = given()
        .spec(spec)
        .when()
        .get("blogs")
        .then()
        .statusCode(200)
        .extract().as(BlogListDTO.class);
assertThat(retrievedBlogList.blogs)
        .extracting(blogEntry -> blogEntry.id)
        .contains(23);

 extracting()方法是AssertJ的杀手级功能。它可以将一个东西映射为另外一个(就像 Java 8 Stream API里的 map())。除了 contains(),AssertJ还提供了其他大量有用的列表方法,比如 containsAll(),containsExactly(), containsSequence() 以及 doesNotContain()。

第 18 段(可获 0.84 积分)

方法2:JsonPath + Hamcrest。 该方法比较简洁,但是更容易出错,难以调试,而且你还得花心思为不同的类型选择合适的匹配器。

given()
        .spec(spec)
        .when()
        .get("blogs")
        .then()
        .statusCode(200)
        .content("blogs.id", hasItem(23));

但是我不得不承认使用JsonPath确实能写出非常简洁的代码来。例如“blogs.id”这个表达式可以将blogs列表里每一个元素的id属性抽取成一个新的列表。

方法3:JsonPath + AssertJ。该方法有一半是类型安全的,稍显冗余,但是调试起来很方便。

JsonPath retrievedBlogList = given()
        .spec(spec)
        .when()
        .get("blogs")
        .then()
        .statusCode(200)
        .extract().jsonPath();
assertThat(retrievedBlogList.getList("blogs.id"))
        .contains(23);
第 19 段(可获 0.81 积分)

处理异步行为(比如Events)

有时,你需要在你的测试中等待异步事件,一直轮询等到某个特定条件达成后再做处理。 Awaitility 提供了一个很不错的API,可以在某个断言成立或者超时到达前进行等待和轮询。

import static com.jayway.awaitility.Awaitility.await;

sendAsyncEventThatCreatesABlog(123);
await().atMost(Duration.TWO_SECONDS).until(() -> {
    given()
            .when()
            .get("blogs/123")
            .then()
            .statusCode(200);
});
第 20 段(可获 0.61 积分)

请注意,Awaitility的方法返回的是一个不可变的ConditionFactory。这样你可以一次性配置其轮询和等待的行为,然后可以重用它。

public static final ConditionFactory WAIT = await()
        .atMost(new Duration(15, TimeUnit.SECONDS))
        .pollInterval(Duration.ONE_SECOND)
        .pollDelay(Duration.ONE_SECOND);
@Test
public void waitAndPoll2(){
    WAIT.until(() -> {
        //...
    });
}

使用MockWebServer测试REST客户端

如果你的服务需要借助于其他服务才能完成任务的话,你就需要测试发送REST请求的类。这可以通过使用OkHttp MockWebServer轻易实现,而且还有个好处是:你可以在你的服务项目里作为单元测试来运行。

第 21 段(可获 0.95 积分)

你也可以在测试项目里的集成测试里使用MockWebServer。

如果你的客户端使用了Spring的RestTemplate,那么可以考虑下MockRestServiceServer。首先,它提供了一套更为简洁的API(从响应定义和断言两方面来说都是的)。其次,URL处理更为简洁,你无需将模拟服务器的主机地址和端口号告诉客户端。

解耦和依赖关系

永远不要依赖于REST服务的内部实现

RESTful服务的测试属于黑盒测试。因此,你在测试中应该永远不要依赖于RESTful服务的内部实现。 这样你的测试才会永葆稳健。它们不会因为服务内部的变化而遭到破坏。只要REST API不改变你的测试就能够工作。这意味着:

第 22 段(可获 1.6 积分)
  • 不要与服务项目产生依赖关系。
  • 在你的集成测试项目中要使用服务项目中的任何类。特别是不要使用POJO类(即领域模型类),尽管有时候你会情不自禁的想去使用。如果你需要模型类, 重写它们即可。 如果能使用合适的类库和工具,那这根本不是什么问题。
    • 此外,你还可以对这些类进行量身定制以符合你的测试需求。你还可以在你的测试POJO类中使用其他语言(如Kotlin),序列化框架(Jackson,Gson等),字段类型或者嵌套类。
    • 还有,使用不同的POJO类可以确保你在修改一个应用POJO类时,不会不小心破坏你的API。这种情况下,你的测试(以及符合API的POJO类)会失败,而且会及时给你指出这些严重的问题。
  • 不要访问服务的数据库来生成测试数据。这会导致高度耦合。如果数据库结构发生了变化,那么你的测试就会失败。同样的,当你修改了该服务所使用到的数据库技术(比如从一个关系型数据库迁移到MongoDB)。当然,有时不可避免的要访问数据库。不要过于教条,但是一定要警惕因此带来的高耦合。
第 23 段(可获 2.4 积分)

此外,这种黑盒办法还反映了一个现实,即你的客户端也不知道如何实现。那么你的测试为什么应该需要这个?它能够帮助你站在客户的角度考虑问题,这样也有助于发现问题。

创建测试数据

创建测试数据,有以下几种选择:

  • 使用REST接口插入测试数据。反正你都要测试接口的,那为什么不使用本来就是用来创建数据的接口来做呢?不过有时你的软件并不需要更新或插入数据。
  • 直接读取数据库。这样做很舒服,但是会引起高耦合。集成测试属于黑盒测试,不应该因为内部数据库结构改变而遭到破坏。
  • 提供一个额外的接口来插入数据,只是用于测试场合。你可以使用身份验证或者功能开关来便于上线时及时隐藏掉。这样你的测试会更加健壮,因为你不会依赖于内部实现。不过你需要维护额外的接口。此外,你还得花留心及时关闭这些测试接口,因为它们可能会严重威胁到你的应用的安全性或者会导致滥用。因此要格外小心了。
第 24 段(可获 2.56 积分)

一些有用的工具和技巧

  • Postman是用来专门测试RESTful服务最好的工具。
  • 如果你更偏爱通过命令行界面(CLI)来创建专门的HTTP请求,那么可以尝试下HTTPie。它确实很不错,而且比CURL更棒。
  • 在Chrome浏览器里添加JSONView插件,有时候也会很有用。
  • 在开发中,我更倾向于在IntelliJ IDEA里面执行测试,而不是运行Maven。但是通常这些测试会需要服务的相关信息(比如URL、端口、访问凭证),这些信息一般可以通过Maven提供,通过系统属性在构建过程中传递给测试。不过IntelliJ也可以给这些测试提供一些必要的属性。对于这一点,你只需要修改Default Run Configuration。打开“Edit Configuration…”,然后编辑 Default Run Configuration for JUnit or TestNG,然后添加系统属性(-Dhost=<host>, -Dport=<port>)。这样一来,这些属性就可以提供给你在IDE里执行的每一个测试使用。只用点击测试方法左边的绿色小图标,然后选择Run <你的测试>。
第 25 段(可获 2.04 积分)

Add the necessary system properties to the Default Run Configuration for JUnit or TestNG

为JUnit或TestNG添加必要的系统属性到Default Run Configuration 。

Afterwards you can start your parameterized integration test via the IDE.

之后你就可以在IDE里开始进行参数化的集成测试了。

测试项目结构

我们可以把测试放在什么地方?有两种选择:

  • 将集成测试放在服务项目中(在src/test/java文件夹中),然后在构建的时候同时执行它们。
    • 你需要区分集成测试和单元测试(使用同样的命名约定或者注解)
    • 易于设置。无需单独的测试环境、单独的Jenkins任务以及构建管道。
    • 构建时间更长些,周转时间和反馈时间更长。你会一直需要执行整个构建,包括测试。
    • 如果你的服务需要其他服务来完成任务的话,可能就没法工作了。
  • 针对每一个服务创建一个单独的集成测试项目,并且在单独的构建或任务中执行它们
    • 你可以轻易地只运行集成测试,而不需要构建整个应用。这样就缩短了周转时间。
    • 你需要设置一个测试阶段和一个构建的管道:构建服务,设置测试环境,将服务部署起来,然后针对它运行集成测试。
    • 更好的对服务项目和集成测试进行解耦和。
    • 对使用你的服务的其他团队来说,更容易通过对测试项目的测试来对服务的行为进行预测。
第 26 段(可获 2.79 积分)

重要提示:如果你的服务比较小,没有太多的集成测试,你在测试时也不需要其他服务参与。那么你就可以将集成测试放到你的正常的构建生命周期里。不过,当你的服务系统变得更加复杂,交互众多时,那你无论如何都需要建立一个单独的测试环境了。 这种情况下,最好把服务的测试放到单独的项目里,然后在测试环境里运行对已部署的服务的测试。

源代码和示例

我建立了一个小的Github 项目(testingrestservice),用来展示基于一个小的Spring Boot服务项目的实战项目代码。

第 27 段(可获 1.35 积分)

文章评论