V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
sigoden
V2EX  ›  分享创造

Apitest 接口自动化测试工具

  •  
  •   sigoden · 2021-06-18 12:16:50 +08:00 · 1671 次点击
    这是一个创建于 1036 天前的主题,其中的信息可能已经有所发展或是发生改变。

    推荐一款用类似 JSON 语法写接口测试的工具

    特性

    • 跨平台
    • DSL
      • 类 JSON,没有学习难度
      • 编写简单,阅读容易
      • 不要求编写者会编程
    • 数据即断言
    • 数据可访问
    • 支持 Mock
    • 支持 Mixin
    • 支持 CI
    • 支持 TDD
    • 支持用户定义函数
    • 跳过,延时,重试和循环
    • 支持 Form,文件上传,GraphQL

    安装

    Apitest 工具是单可执行文件,不需要安装,放到PATH路径下面就可以直接运行

    # linux
    curl -L -o apitest https://github.com/sigoden/apitest/releases/latest/download/apitest-linux 
    chmod +x apitest
    sudo mv apitest /usr/local/bin/
    
    # macos
    curl -L -o apitest https://github.com/sigoden/apitest/releases/latest/download/apitest-macos
    chmod +x apitest
    sudo mv apitest /usr/local/bin/
    
    # npm
    npm install -g @sigodenjs/apitest
    

    开始使用

    编写测试文件 httpbin.jsona

    {
      test1: {
        req: {
          url: "https://httpbin.org/anything",
          query: {
            k1: "v1",
          },
        },
        res: {
          body: { @partial
            args: {
              "k1": "v2", // 注意,这儿应该是"v1", 我们故意写"v2"以测试 Apitest 的反应
            },
            url: "https://httpbin.org/anything?k1=v1",
          }
        }
      }
    }
    

    执行如下命令测试接口

    apitest httpbin.jsona
    

    其结果如下

    main
      test1 (2.554) ✘
      main.test1.res.body.args.k1: v2 ≠ v1
    
      {
        "req": {
          "url": "https://httpbin.org/anything",
          "query": {
            "k1": "v1"
          }
        },
        "res": {
          "headers": {
            "date": "Thu, 17 Jun 2021 15:01:51 GMT",
            "content-type": "application/json",
            "content-length": "400",
            "connection": "close",
            "server": "gunicorn/19.9.0",
            "access-control-allow-origin": "*",
            "access-control-allow-credentials": "true"
          },
          "status": 200,
          "body": {
            "args": {
              "k1": "v1"
            },
            "data": "",
            "files": {},
            "form": {},
            "headers": {
              "Accept": "application/json, text/plain, */*",
              "Host": "httpbin.org",
              "User-Agent": "axios/0.21.1",
              "X-Amzn-Trace-Id": "Root=1-60cb63df-1b8592de3767882a6e865295"
            },
            "json": null,
            "method": "GET",
            "origin": "119.123.242.225",
            "url": "https://httpbin.org/anything?k1=v1"
          }
        }
      }
    

    Apitest 发现了 k1 的值异常 main.test1.res.body.args.k1: v2 ≠ v1 并打印错误,同时还打印了接口请求响应详情。

    如果我们修改 main.test1.res.body.args.k1v2 => v1 后再执行测试。

    apitest httpbin.jsona
    

    其结果如下

    main
      test1 (1.889) ✔
    

    Apitest 报告测试通过了。

    原理

    Apitest 执行测试文件时会加载全部测试用例,逐一执行,其执行过程可以描述为:根据 req 部分构造请求发送给服务器,收到响应后依据 res 校验响应数据,然后打印结果。

    Apitest 中的用例文件格式是 JSONA。JSONA 是 JSON 的超集,减轻了一些 JSON 语法限制(不强制要求双引号,支持注释等),再添加了一个特性:注解。上面例子中的@partial就是注解。

    为什么使用 JSONA ?

    接口测试的本质的就是构造并发送req数据,接收并校验res数据。数据即是主体又是核心,而 JSON 是最可读最通用的数据描述格式。 接口测试还需要某些特定逻辑。比如请求中构造随机数,在响应中只校验给出的部分数据。

    JSONA = JSON + Annotation(注解)。JSON 负责数据部分,注解负责逻辑部分。完美的贴合接口测试需求。

    示例

    下面的示例会用到一些注解,不明白的地方请查看README

    全等校验

    默认请求下,Apitest 进行全等校验。

    • 简单类型数据(null,boolean,string,number)完全相等
    • object 数据属性和属性值完全相等,字段顺序可以不一致
    • array 数据元素长度和各元素完全相等,元素顺序也要一致
    {
      test1: { @client("echo")
        req: {
          any: null,
          bool: true,
          str: "string",
          int: 3,
          float: 0.3,
          obj: {a:3, b:4},
          arr: [3,4],
        },
        res: {
          any: null,
          bool: true,
          str: "string",
          int: 3,
          float: 0.3,
          obj: {a:3, b:4},
          // obj: {b:4, b:3}, object 类数据字段顺序可以不一致
          arr: [3,4],
        }
      }
    }
    

    Apitest 保证:只有当实际接收到的 res 数据与我们用例中描述的 res 数据全等,测试才会通过。

    数组校验技巧

    Apitest 默认全等校验,而接口返回的 array 数据可能几十上百条,怎么办?

    通常接口数据是结构化的,我们可以只校验数组第一个元素。

    {
      test1: { @client("echo")
        req: {
          arr: [
            {name: "v1"},
            {name: "v2"},
            {name: "v3"},
          ]
        },
        res: {
          arr: [ @partial
            {
              name: "", @type
            }
          ],
        }
      }
    }
    

    如果 array 数据的长度也很关键呢?

    {
      test1: { @client("echo")
        req: {
          arr: [
            {name: "v1"},
            {name: "v2"},
            {name: "v3"},
          ]
        },
        res: {
          arr: [ @every
            [ @partial
                {
                  name: "", @type
                }
            ],
            `$.length === 3`, @eval
          ],
        }
      }
    }
    

    对象校验技巧

    Apitest 默认全等校验,而接口返回的 object 数据的属性很多,我们只关注其中部分属性?

    {
      test1: { @client("echo")
        req: {
          obj: {
            a: 3,
            b: 4,
            c: 5,
          }
        },
        res: {
          obj: { @partial
            b: 4,
          }
        }
      }
    }
    

    接口可能返回一些可选字段,我们使用@optional标记这种字段

    {
      test1: { @client("echo")
        req: {
          v1: 3,
          // v2: 4, 可选字段
        },
        res: {
          v1: 3,
          v2: 4, @optional
        }
      }
    }
    

    查询字符串

    通过 req.query 传入 QueryString

    {
      test1: {
        req: {
          url: "https://httpbin.org/get",
          query: {
            k1: "v1",
            k2: "v2",
          }
        },
        res: {
          body: { @partial
            url: "https://httpbin.org/get?k1=v1&k2=v2",
          }
        }
      }
    }
    

    当然你可以把 QueryString 直接写在req.url

    {
      test1: {
        req: {
          url: "https://httpbin.org/get?k1=v1&k2=v2",
        },
        res: {
          body: { @partial
            url: "https://httpbin.org/get?k1=v1&k2=v2",
          }
        }
      }
    }
    

    路径变量

    通过 req.params 传入路径变量

    {
      test1: {
        req: {
          url: "https://httpbin.org/anything/{id}",
          params: {
            id: 3,
          }
        },
        res: {
          body: { @partial
            url: "https://httpbin.org/anything/3"
          }
        }
      }
    }
    

    请求头 /响应头

    通过 req.headers 传入请求头,通过 res.headers 校验响应头

    {
      setCookies: { @describe("response with set-cookies header")
        req: {
          url: "https://httpbin.org/cookies/set",
          query: {
            k1: "v1",
            k2: "v2",
          },
        },
        res: {
          status: 302,
          headers: { @partial
            'set-cookie': [
              "k1=v1; Path=/",
              "k2=v2; Path=/",
            ], 
          },
          body: "", @type
        }
      },
      useCookies: { @describe("request with cookie header")
        req: {
          url: "https://httpbin.org/cookies",
          headers: {
            Cookie: `setCookies.res.headers["set-cookie"]`, @eval
          }
        },
        res: {
          body: { @partial
            cookies: {
              k1: "v1",
              k2: "v2",
            }
          }
        },
      },
    }
    

    校验 http 状态码

    {
      test1: {
        req: {
          url: "https://httpbin.org/status/401",
        },
        res: {
          status: 401,
        }
      }
    }
    

    用例数据变量导出与引用

    凡是执行过的用例其数据均可以当做已自动导出变量,它们均可以被后续用例引用。

    Apitest 中可以使用 @eval 注解引用用例数据。

    比如上面例子中setCookies.res.headers["set-cookie"],就是引用前面setCookies用例的set-cookie响应头数据。

    表单: x-www-form-urlencoded

    {
      test1: { @describe('test form')
        req: {
          url: "https://httpbin.org/post",
          method: "post",
          headers: {
            'content-type':"application/x-www-form-urlencoded"
          },
          body: {
            v1: "bar1",
            v2: "Bar2",
          }
        },
        res: {
          status: 200,
          body: { @partial
            form: {
              v1: "bar1",
              v2: "Bar2",
            }
          }
        }
      },
    }
    

    表单: multipart/form-data

    结合 @file 注解实现文件上传

    {
      test1: { @describe('test multi-part')
        req: {
          url: "https://httpbin.org/post",
          method: "post",
          headers: {
            'content-type': "multipart/form-data",
          },
          body: {
            v1: "bar1",
            v2: "httpbin.jsona", @file
          }
        },
        res: {
          status: 200,
          body: { @partial
            form: {
              v1: "bar1",
              v2: "", @type
            }
          }
        }
      }
    }
    

    GraphQL

    {
      test1: { @describe("test graphql")
        req: {
          url: "https://api.spacex.land/graphql/",
          body: {
            query: `\`query {
      launchesPast(limit: ${othertest.req.body.count}) {
        mission_name
        launch_date_local
        launch_site {
          site_name_long
        }
      }
    }\`` @eval
          }
        },
        res: {
          body: {
            data: {
              launchesPast: [ @partial
                {
                  "mission_name": "", @type
                  "launch_date_local": "", @type
                  "launch_site": {
                    "site_name_long": "", @type
                  }
                }
              ]
            }
          }
        }
      }
    }
    

    http(s)代理

    {
      @client({
        name: "default",
        type: "http",
        options: {
          proxy: "http://localhost:8080",
        }
      })
      test1: {
        req: {
          url: "https://httpbin.org/ip",
        },
        res: {
          body: {
            origin: "", @type
          }
        }
      }
    }
    

    Apitest 支持通过 HTTP_PROXY HTTPS_PROXY 环境变量开全局代理

    多个接口服务地址

    {
      @client({
        name: "api1",
        type: "http",
        options: {
          baseURL: "http://localhost:3000/api/v1",
        }
      })
      @client({
        name: "api2",
        type: "http",
        options: {
          baseURL: "http://localhost:3000/api/v2",
        }
      })
      test1: { @client("api1")
        req: {
          url: "/signup", // => http://localhost:3000/api/v1/signup
        }
      },
      test2: { @client("api2")
        req: {
          url: "/signup", // => http://localhost:3000/api/v2/signup
        }
      }
    }
    
    

    自定义超时

    你可以设置客户端超时,影响所有使用该客户端的接口

    {
      @client({
        name: "default",
        type: "http",
        options: {
          timeout: 30000, 
        }
      })
    }
    

    你也可以为某个用例设置超时

    {
      test1: { @client({options:{timeout: 30000}})
    
      }
    }
    

    环境变量传递数据

    {
      test1: {
        req: {
          headers: {
            "x-key": "env.API_KEY", @eval
          }
        }
      }
    }
    

    mock 数据

    {
      login1: {
        req: {
          url: "/signup",
          body: {
            username: 'username(3)', @mock
            password: 'string(12)', @mock
            email: `req.username + "@gmail.com"`, @eval
          }
        }
      }
    }
    

    Apitest 支持近 40 个 mock 函数。下面列些常用的

    {
      test1: {
        req: {
          email: 'email', @mock
          username: 'username', @mock
          integer: 'integer(-5, 5)', @mock
          image: 'image("200x100")', @mock
          string: 'string("alpha", 5)', @mock
          date: 'date', @mock  // iso8601 格式的当前时间 // 2021-06-03T07:35:55Z
          date2: 'date("","2 weeks ago")', @mock // 2 周前
          sentence: 'sentence', @mock
          cnsentence: 'cnsentence', @mock // 中文段落    
        }
      }
    }
    

    用例组

    {
      @describe("这是一个模块")
      @client({name:"default",kind:"echo"})
      group1: { @group @describe("这是一个组")
        test1: { @describe("最内用例")
          req: {
          }
        },
        group2: { @group @describe("这是一个嵌套组")
          test1: { @describe("嵌套组内的用例")
            req: {
            }
          }
        }
      }
    }
    

    上面的测试文件打印如下

    这是一个模块
      这是一个组
        最内用例 ✔
        这是一个嵌套组
          嵌套组内的用例 ✔
    

    跳过用例(组)

    {
      test1: { @client("echo")
        req: {
        },
        run: {
          skip: `othertest.res.status === 200`, @eval
        }
      }
    }
    

    延时执行用例(组)

    {
      test1: { @client("echo")
        req: {
        },
        run: {
          delay: 1000, // 延时毫秒
        }
      }
    }
    

    重试用例(组)

    {
      test1: { @client("echo")
        req: {
        },
        run: {
          retry: {
            stop:'$run.count> 2', @eval // 终止重试条件
            delay: 1000, // 重试间隔毫秒
          }
        },
      }
    }
    

    重复执行用例(组)

    {
      test1: { @client("echo")
        req: {
          v1:'$run.index', @eval
          v2:'$run.item', @eval
        },
        run: {
          loop: {
            delay: 1000, // 重复执行间隔毫秒
            items: [  // 重复执行数据
              'a',
              'b',
              'c',
            ]
          }
        },
      }
    }
    

    如果不在意数据,只想重复执行多少次的话,可以这样设置

    {
      test1: {
        run: {
          delay: 1000,
          items: `Array(5)`, @eval
        }
      }
    }
    

    强制打印详情

    常规模式下,接口如果没有出错是不会打印数据详情的。通过设置run.dump为 true 强制打印详情数据。

    {
      test1: { @client("echo")
        req: {
        },
        run: {
          dump: true,
        }
      }
    }
    

    抽离公用逻辑以复用

    首先创建一个文件存储 Mixin 定义的文件

    // mixin.jsona
    {
      createPost: { // 抽离路由信息到 mixin
        req: {
          url: '/posts',
          method: 'post',
        },
      },
      auth1: { // 抽离鉴权到 minxin
        req: {
          headers: {
            authorization: `"Bearer " + test1.res.body.token`, @eval
          }
        }
      }
    }
    
    @mixin("mixin") // 引入 mixin.jsona 文件
    
    {
      createPost1: { @describe("写文章 1") @mixin(["createPost", "auth1"])
        req: {
          body: {
            title: "sentence", @mock
          }
        }
      },
      createPost2: { @describe("写文章 2,带描述") @mixin(["createPost", "auth1"])
        req: {
          body: {
            title: "sentence", @mock
            description: "paragraph", @mock
          }
        }
      },
    }
    

    越是频繁用到的数据越适合抽离到 Mixin 。 

    自定义函数

    某些情况下,Apitest 内置的注解不够用,你可以使用自定义函数。

    编写函数lib.js

    
    // 创建随机颜色
    exports.makeColor = function () {
      const letters = "0123456789ABCDEF";
      let color = "#";
      for (let i = 0; i < 6; i++) {
        color += letters[Math.floor(Math.random() * 16)];
      }
      return color;
    }
    
    // 判断是否是 ISO8601(2021-06-02:00:00.000Z)风格的时间字符串
    exports.isDate = function (date) {
      return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(date)
    }
    

    使用函数

    @jslib("lib") // 引入 js 文件
    
    {
      test1: {
        req: {
          body: {
            color: 'makeColor()', @eval // 调用 `makeColor` 函数生成随机颜色
          }
        },
        res: {
          body: {
            createdAt: 'isDate($)', @eval // $ 表示须校验字段,对应响应数据`res.body.createdAt`
    
            // 当然你可以直接使用 regex
            updatedAt: `/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test($)`, @eval
          }
        }
      }
    }
    

    后记

    这里列举了一下 Apitest 使用示例,详细说明请点击github.com/sigoden/apitest查看。

    4 条回复    2022-04-14 14:38:35 +08:00
    zagfai
        1
    zagfai  
       2021-06-18 16:24:11 +08:00
    直接写点 python 不行?
    sigoden
        2
    sigoden  
    OP
       2021-06-18 16:38:29 +08:00
    - 大部分人都不会 Python 。
    - Jsona 的容易写,可读性也更强
    encro
        3
    encro  
       2021-06-20 16:00:04 +08:00
    鼓励,
    不过变量替换也是一个比较重要的特性,
    比如 URL,TOKEN 可能依赖全局定义或者上一个请求。
    K1W1
        4
    K1W1  
       2022-04-14 14:38:35 +08:00
    棒,在用,现在已经挺完善了
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   2823 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 11:56 · PVG 19:56 · LAX 04:56 · JFK 07:56
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.