RESTFul 服务测试自动化的艺术 - TODO 服务篇

802 阅读10分钟
原文链接: my.oschina.net

老码农在上一篇博客 给出了如何从头开始创建一个 自带自动化测试工具的 RESTful 服务项目的例子. 今天我们在这个简单例子上做延伸, 把这个例子改写为一个简单的 TODO Task 应用. 该应用会提供以下服务端口:

  • GET /todos - 返回所有的 TODO 项
  • GET /todos/?q=? - 查询 TODO 项, 所有描述符合 q 参数的 TODO 项都会被返回
  • GET /todos/{id} - 返回指定 ID 的 TODO 项
  • POST /todos - 添加一条 TODO 项
  • DELETE /todos/{id} - 删除指定 ID 的 TODO 项

1. 创建项目

下面开始创建初始项目:

mvn archetype:generate -B \
    -DgroupId=demo.todo \
    -DartifactId=todo-service \
    -DarchetypeGroupId=org.actframework \
    -DarchetypeArtifactId=archetype-simple-restful-service \
    -DarchetypeVersion=1.8.8.5

下面我们将项目用 Intellij IDEA 打开. (推荐使用 IDEA 开发 Act 应用, 社区版足够使用了)

image

2. 加入数据库访问插件依赖

可以删除掉项目创建的 Service.java 文件. 然后在 pom.xml 中加入一下依赖:

<dependency>
  <groupId>org.actframework</groupId>
  <artifactId>act-eclipselink</artifactId>
</dependency>

act-eclipselink 使用 EclipseLink 提供数据库访问服务. 也可以换为 act-hibernate, 对我们这个 todo-service 的开发没有任何变化.

3. 加入 Todo 服务相关类

现在可以在项目中创建我们的 Model 类 - Todo:

package demo.todo;

import act.util.SimpleBean;

import javax.persistence.Entity;
import javax.persistence.Id;

@Entity(name = "todo")
public class Todo implements SimpleBean {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    public Integer id;
    public String desc;
}

注意, 这里实现了 SimpleBean 框架会自动为 Todo 创建 Getter 和 Setter 方法. 在 Todo 之外的地方调用 todo.desc = "abc" 会被自动更换为 todo.setDesc("abc"), 而 String s = todo.desc 则会被自动更换为 String s = todo.getDesc().

下面创建 Todo 的服务类 TodoService:

package demo.todo;

import act.controller.annotation.UrlContext;
import act.db.jpa.JPADao;
import act.util.JsonView;
import org.osgl.mvc.annotation.DeleteAction;
import org.osgl.mvc.annotation.GetAction;
import org.osgl.mvc.annotation.PostAction;
import org.osgl.util.S;

import java.util.List;
import javax.inject.Inject;

@JsonView
@UrlContext("/todos")
public class TodoService {

    @Inject
    private JPADao<Integer, Todo> dao;

    @GetAction
    public List<Todo> list(String q) {
        return S.blank(q) ?
                dao.findAllAsList() :
                dao.q("desc like", q).fetch();
    }

    @GetAction("{id}")
    public Todo findById(int id) {
        return dao.findById(id);
    }

    @PostAction
    public int create(Todo todo) {
        dao.save(todo);
        return todo.id;
    }

    @DeleteAction("{id}")
    public void remove(int id) {
        dao.deleteById(id);
    }

}

4. 启动并试用服务

好了, 开发工作搞定. 现在运行起来试试. 有两种方法运行程序.

  1. 在 IDE 里面运行:

image

  1. 通过 mvn 命令在控制台运行:

image

选择任何一种方式, 把应用跑起来之后, 我们用 httpie 来试试我们的 Todo 服务:

4.1 创建一个 todo 项

luog@luog-X510UQR:~$ http POST localhost:5460/todos desc='Task A'
HTTP/1.1 201 Created
Content-Length: 12
Content-Type: application/json
Date: Wed, 30 May 2018 07:40:58 GMT
Server: act/1.8.8-RC8

{
    "result": 1
}

4.2 获取 todo 列表:

luog@luog-X510UQR:~$ http localhost:5460/todos
HTTP/1.1 200 OK
Content-Length: 26
Content-Type: application/json
Date: Wed, 30 May 2018 07:41:53 GMT
Server: act/1.8.8-RC8

[
    {
        "desc": "Task A", 
        "id": 1
    }
]

4.3 获取 id 为 1 的 todo 项

luog@luog-X510UQR:~$ http localhost:5460/todos/1
HTTP/1.1 200 OK
Content-Length: 24
Content-Type: application/json
Date: Wed, 30 May 2018 07:42:28 GMT
Server: act/1.8.8-RC8

{
    "desc": "Task A", 
    "id": 1
}

4.4 删除 id 为 1 的 todo 项, 并验证

luog@luog-X510UQR:~$ http DELETE localhost:5460/todos/1
HTTP/1.1 204 No Content
Content-Type: application/json
Date: Wed, 30 May 2018 07:44:53 GMT
Server: act/1.8.8-RC8



luog@luog-X510UQR:~$ http localhost:5460/todos
HTTP/1.1 200 OK
Content-Length: 2
Content-Type: application/json
Date: Wed, 30 May 2018 07:44:59 GMT
Server: act/1.8.8-RC8

[]

到此基本上我们的实现部分就完成了, 同时也手工做了测试. 看看代码统计:

luog@luog-X510UQR:/tmp/2/todo-service$ loc src
--------------------------------------------------------------------------------
 Language             Files        Lines        Blank      Comment         Code
--------------------------------------------------------------------------------
 XML                      1          115           20            7           88
 Java                     3           84           19            7           58
 YAML                     1           53            1            4           48
--------------------------------------------------------------------------------
 Total                    5          252           40           18          194
--------------------------------------------------------------------------------

所有 Java 代码加起来也就 58 行, 虽说是一个非常粗糙的 Todo 服务, 也算是麻雀虽小五脏俱全了.

5. 自动测试的实现

下面开始进入肉戏了. 我们刚刚使用了 httpie 对服务进行了测试, 貌似也很简单, 可这毕竟需要人工介入啊. 没听葛先生说过二十一世纪人工最贵吗? 怎么能把这么贵的人工浪费在重复性的工作上面. 更重要的问题是人工在这种重复性劳作上远远不如机器可靠, 如果没有自动化测试的保障, 即便是大牛也不敢随便对代码动刀子搞搞重构之类的高级手术.

那自动化测试怎么搞, 这是一个问题. 老码农也思考了很多年. 最初的想法是复制 Spring 的招数, 搞一个 ActRunner 之类的东西让开发人员能在 JUnit 框架下跑对 Act 应用的测试. 可当老码农看到 3.4.5. Meta-Annotation Support for Testing 一节的时候已经被吓傻了, 仅仅是为了支持测试可能会用到的注解就已经是这番模样了:

image

这完全和 Act 的核心理念背道而驰啊. 欲破珍珑棋局, 必须跳出棋盘, 自动测试也不是一定要在 JUnit 框架下进行. 多年寻寻觅觅找不到满意的方案, 这思路一开, act-e2e 插件便横空出世.

5.1 act-e2e 简介

act-e2e 是老码农为 Act 应用开发提供的自动化测试插件, 其设计目的主要有一下几点:

  1. 提供端到端测试支持, 换句话说, 测试通过 HTTP 协议向应用发送请求, 并接受应用传回的 HTTP 响应, 并验证响应的内容
  2. 测试脚本应该易于编写. 用户只需要定义每次交互的请求数据以及响应数据的验证方式, 完全无需考虑应用内部实现
  3. 测试环境治理. 这包括:
  • 运行测试场景之前对数据库清除
  • 运行测试场景之前加载指定数据文件到数据库中
  • 数据文件 (Fixture) 通过 YAML 格式定义, 包括数据项之间的关联关系
  • 测试场景的依赖管理, 某些测试场景可能被其他场景依赖, 比如用户登录这个场景就可以被很多其他测试场景依赖. 而用户登录自身又可能依赖与用户注册场景.
  1. 其他工具, 比如共享的请求模板, 如果大部分请求都需要添加某些 HTTP 头, 可以定义请求模板, 并被其他请求引用.

5.2 Scenarios 文件

定义 Scenarios.yml 文件内容是使用 act-e2e 进行自动化测试的核心活动. 这个文件的结构如下:

image

在本文中我们不会详细罗列整个 Scenarios 文件的语法结构, 而是通过对 Todo 服务进行自动化测试来介绍 Scenarios 文件的用法.

5.3 为 Todo 服务实现自动测试

打开 src/main/resources/e2e/scenarios.yml 文件, 清除其中的内容, 添加针对 TODO 服务的测试脚本. Todo 服务足够简单, 一个场景足够涵盖基本测试, 我们首先加入场景声明:

Scenario(Main):
  description: Test TODO service

5.3.1 第一个交互: 添加 Todo 项目

接下来加入第一个交互:

Scenario(Main):
  description: Test TODO service
  interactions:
    - description: Add one todo item
      request:
        method: POST
        url: /todos
        params:
          desc: Task A
      response:
        json:
          result:
            - exists: true

分解一下该交互的定义. 首先是请求:

      request:
        method: POST
        url: /todos
        params:
          desc: Task A

请求的元素主要为:

  • method: HTTP 方法, 可以为 GET/POST/DELETE/PUT/PATCH/HEAD
  • url: 测试服务的 URL
  • params: 请求发送的参数
  • headers: 请求头. 这个部分在本文中不会使用

本例中响应的定义为:

      response:
        json:
          result:
            - exists: true

这个定义的意思是, 这个响应应该是一个 {"result": ...} JSON 结构. 该定义来自 Todo 服务的下面的服务端口:

@PostAction
@Transactional
public int create(Todo todo) {
    dao.save(todo);
    return todo.id;
}

从代码看来, 返回的应该是一个整数类型的值. 但因为我们前面定义了整个控制器都是 @JsonView, 也就是任何返回都应该是合法的 JSON 结构. 对于无结构的值, 我们使用 result 来包裹返回值. 因此, 我们的响应定义中有 result: -exists: true 这样的验证.

加入第一个交互之后我们就可以试试 e2e 了, 打开浏览器, 导航到 localhost:5460/~/e2e, 会看到定义的测试以及运行情况:

image

5.3.2 查询新创建的记录

    - description: Fetch todo item added in last interaction
      request:
        method: GET
        url: /todos/${last:result}
      response:
        json:
          desc: Task A

这个交互定义有个地方值得注意: url: /todos/${last:result}, 这个的意思是取上一个交互对象的 result 引用的值填充到 /todos/ 后面, 也就是说 url 最后会是 /todos/{id}, 而 {id} 是上次添加 Todo 项生成的 id.

对于 response 的定义则是检测是否 JSON 数据中的 descTask A.

回到浏览器, 按 F5 刷新:

image

5.3.3 获得 Todo 列表

    - description: Fetch todo item list
      request:
        method: GET
        url: /todos
      response:
        json:
          size: 1
          0:
            desc: Task A

这个交互定义中比较有趣的是响应的验证定义. 其含义是 json 数据是一个数组, 其中有一个元素, 第一个元素的 descTask A

刷新浏览器得到:

image

5.3.4 查询 Todo 列表

    - description: Search todo item list
      request:
        method: GET
        url: /todos
        params:
          q: A
      response:
        json:
          size: 1
          0:
            desc: Task A

这个和前面的交互 [5.3.3 获得 Todo 列表] 非常接近, 唯一不同的地方是 GET 请求多了请求参数: q=A. 表示查询含有 A 字母的 Todo 项.

再次刷新浏览器:

image

Oops, 怎么最后的交互没有通过. 回到后台, 发现这样的错误:

[FAIL] Search todo item list
error running scenario: Main
org.osgl.exception.UnexpectedException: Cannot verify value[0] with test [1]
	at org.osgl.util.E.unexpected(E.java:179)
	at act.e2e.Scenario.verifyValue(Scenario.java:569)
	at act.e2e.Scenario.verifyList(Scenario.java:509)
	at act.e2e.Scenario.verifyBody(Scenario.java:440)
	at act.e2e.Scenario.verify(Scenario.java:406)

这应该是希望返回数组有一个元素, 但实际返回数组没有元素. 仔细检查一下, 应该是在 A 前面加上 % 才行, 更改我们的 scenarios.yml 文件, 将 q: A 改成 q: %A, 之后再刷新浏览器:

image

貌似错误更加严重了, 原来 % 是 yaml 的保留字符, 需要用引号括起来, 即 q: %A 变为 q: '%A'. 最后终于全部搞定了:

image

5.4 在 CI 中集成 e2e 测试

我们上面的过程都使用了浏览器访问 /~/e2e 来完成测试. 这个对于开发调试 sceanrios.yml 测试脚步非常方便. 但是对于 CI 集成自动测试过程就不友好了. 不用担心, Act 的 maven 插件可以帮忙解决这个问题:

mvn -q clean compile act:e2e

获得的结果是:

luog@luog-X510UQR:/tmp/2/todo-service$ mvn -q clean compile act:e2e
Listening for transport dt_socket at address: 5005
 ___   _    _    _        __   _   _         ___   _   _ 
  |   / \  | \  / \  __  (_   |_  |_)  \  /   |   /   |_ 
  |   \_/  |_/  \_/      __)  |_  | \   \/   _|_  \_  |_ 
                                                         
                  powered by ActFramework r1.8.8-RC8-7ed4

 version: v1.0-SNAPSHOT-180530_2132
scan pkg: 
base dir: /tmp/2/todo-service
     pid: 13566
 profile: e2e
    mode: DEV

     zen: Special cases aren't special enough to break the rules. 
          Although practicality beats purity.

2018-05-30 21:32:21,043 INFO  a.Act@[main] - loading application(s) ...
2018-05-30 21:32:21,057 INFO  a.a.App@[main] - App starting ....
2018-05-30 21:32:21,236 WARN  a.h.b.ResourceGetter@[main] - URL base not exists: META-INF/resources/webjars
2018-05-30 21:32:21,255 WARN  a.a.DbServiceManager@[main] - DB configuration not found. Will try to init default service with the sole db plugin: act.db.eclipselink.EclipseLinkPlugin@31e90355
2018-05-30 21:32:23,089 WARN  a.m.MailerConfig@[main] - smtp host configuration not found, will use mock smtp to send email
2018-05-30 21:32:23,529 WARN  a.Act@[jobs-thread-2] - No data source user configuration specified. Will use the default 'sa' user
2018-05-30 21:32:23,530 WARN  a.Act@[jobs-thread-2] - No database URL configuration specified. Will use the default h2 inmemory test database
2018-05-30 21:32:23,530 WARN  a.Act@[jobs-thread-2] - JDBC driver not configured, system automatically set to: org.h2.Driver
2018-05-30 21:32:23,998 INFO  o.xnio@[main] - XNIO version 3.3.8.Final
2018-05-30 21:32:24,041 INFO  o.x.nio@[main] - XNIO NIO Implementation Version 3.3.8.Final
2018-05-30 21:32:24,089 INFO  a.a.App@[jobs-thread-2] - App[todo-service] loaded in 3032ms
2018-05-30 21:32:24,106 INFO  a.a.ApiManager@[jobs-thread-6] - start compiling API book
2018-05-30 21:32:24,272 INFO  a.a.ApiManager@[jobs-thread-6] - API book compiled
2018-05-30 21:32:24,311 INFO  a.Act@[main] - network client hooked on port: 5460
2018-05-30 21:32:24,313 INFO  a.Act@[main] - CLI server started on port: 5461
2018-05-30 21:32:24,317 INFO  a.Act@[main] - app is ready at: http://192.168.1.2:5460
2018-05-30 21:32:24,318 INFO  a.Act@[main] - it takes 9396ms to start the app

Start running E2E test scenarios

================================================================================
MAIN

Test TODO service
--------------------------------------------------------------------------------
[EL Info]: 2018-05-30 21:32:25.399--ServerSession(1256982899)--EclipseLink, version: Eclipse Persistence Services - 2.7.1.v20171221-bd47e8f
[EL Info]: connection: 2018-05-30 21:32:25.516--ServerSession(1256982899)--/file:/tmp/2/todo-service/./_default login successful
[PASS] Add one todo item
[PASS] Fetch todo item added in last interaction
[PASS] Fetch todo item list
[PASS] Search todo item list
--------------------------------------------------------------------------------
It takes 0s to run this scenario.

这里还需要一些改进, 方便 CI 工具更容易判断测试是否通过. 老码农会在以后的版本中持续改进对端到端自动测试的支持.

另外在操作过程中有可能出现一些异常现象, 需要重启动应用. 这个问题老码农已经提交 issue 报告了. 将会在以后的版本中修复.

本文讲述的项目代码在 gitee 上有完整版本, 有兴趣可以参考