api-test-lib

(本文档持续完善中……,欢迎在 Issue 区留言)

Usage no npm install needed!

<script type="module">
  import apiTestLib from 'https://cdn.skypack.dev/api-test-lib';
</script>

README

api-test-lib

功能特性

(本文档持续完善中……,欢迎在 Issue 区留言)

TOC

初始化项目

建议从我们推荐的 quickstart 开始。

克隆 quickstart 项目为测试项目:

git clone https://github.com/api-test-lib/quickstart.git <your-test-project>

进入测试项目目录:

cd <your-test-project>

安装必要依赖项:

yarn

api-test-lib 项目自带 TS 类型声明文件。

推荐的测试项目结构

./
  /spec  # 存放接口规格文件
  /test  # 存放接口测试代码

定义第一个接口规格

// ./spec/greeting.ts
import { Endpoint } from "api-test-lib";

/** 把Url查询参数的规格称为QuerySpec */
export interface QuerySpec {
  name: string;
}

/** 把响应体规格称为ReplySpec */
export interface ReplySpec {
  message: string;
}

/** 声明一个通过Get方法访问的接口,构造参数为接口路径 */
export default new Endpoint.Get<QuerySpec, ReplySpec>("/greeting");

编写测试

// ./test/greeting.test.ts
import { test } from "api-test-lib";
import greeting from "../spec/greeting";

test("测试打招呼", () =>
  greeting.request({
    /** 设置请求Url查询参数 */
    query: {
      name: "TS"
    },
    /** 响应期望DSL */
    expect: {
      /** 判断响应码为200 */
      statusEquals: 200,
      /** 判断响应头包含期望的值 */
      headerContains: {
        "content-type": "application/json"
      },
      /** 判断响应体包含期望的值 */
      replyContains: {
        message: "你好,TS!"
      }
    }
  }));

执行测试

执行测试时,需要指定一个 API_TEST_HOST 环境变量,表示被测试环境的入口地址。

原理,我们会把这个环境变量的值,加上每个 Endpoint 实例化时传入的路径字符串,最终生成被测试接口完整的 Url。

例如,API_TEST_HOSThttp://127.0.0.1:3000,某个 Endpoint 的路径是/greeting,那么其完整的 Url 则是http://127.0.0.1:3000/greeting

由于 quickstart 项目已经为您做好了必要的配置,只需执行:

API_TEST_HOST="http://<host>:<port>" yarn test

就可以启动测试了。

定义通过 Post 方法访问的接口

通过 Post 方法访问的接口,一般在请求体中设置入参,但也会遇到同时使用 Url 查询参数设置的情形,此时我们把 Url 查询参数的规格类型,放到泛型声明的第三个参数中:

// ./spec/createUserProfile.ts
import { Endpoint } from "api-test-lib";

export interface QuerySpec {
  apikey: string;
}

export interface SendSpec {
  name: string;
  gender: "male" | "female" | "unknown";
  email: string;
  mobile?: string;
  address?: string;
}

export interface ReplySpec {
  error?: string;
  result?: { id: number } & SendSpec;
}

/**
 * 由于通过Post方法访问的接口,设置Url查询参数的情形相对低频,因此把这个类型信息安排为第三个参数
 */
export default new Endpoint.Post<SendSpec, ReplySpec, QuerySpec>(
  "/userprofile/create"
);

使用接口进行测试时:

// ./test/createUserProfile.test.ts
import { test } from "api-test-lib";
import createUserProfile from "../spec/createUserProfile";

test("测试创建用户资料", () =>
   createUserProfile.request({
    query: {
      apikey: "xxxxxx";
    },
    /** 设置请求体参数,将以application/json编码发送 */
    send: {
      name: "西蒙",
      gender: "male",
      email: "simon@company.com"
    },
    expect: {
      statusEquals: 200,
      replyContains: {
        result: {
          /**
           * 期望DSL支持将此处期望值设置为一个函数做更灵活的判断
           * 函数返回值为一个布尔值,表示检查是否通过
           */
          id: id => id > 0,
          name: "西蒙"
        }
      }
    }
  }));

期望 DSL 介绍

判断响应码是否等于指定值

这个期望检查几乎是最常用的,在 happy path 的测试中,我们一般都判断响应码是否等于 200:

{
  expect: {
    statusEquals: 200;
  }
}

也可以输入其他值,比如 400。

判断响应头是否包含指定值

例想判断响应体是否为 application/json 编码,我们可以这么做:

{
  expect: {
    statusEquals: 200,
    headerContains: {
      "content-type": "application/json"
    }
  }
}

如果对应字段的期望值为一个函数,则可以做更复杂的判断。

例如,我们很可能遇到 Content-Type 的返回值,还附加了内容编码的元数据:application/json; charset=utf-8

这种情形,如果像前面那样设置期望值做严格判断检查,必然是失败的,因此我们可以改造期望检查为:

{
  expect: {
    statusEquals: 200,
    headerContains: {
      "content-type": ct => ct.startsWith("application/json")
    }
  }
}

只要 Content-Type 的值以 application/json 开头,那么 startsWith 的计算结果则是 true,函数返回真值,判断通过。

判断响应体是否包含指定值

对响应体做检查也是非常高频的动作,我们可以像前面使用 headerContains 那样对响应体做检查:

{
  expect: {
    statusEquals: 200,
    replyContains: {
      message: "ok"
    }
  }
}

headerContains 不同的是,replyContains支持复杂对象的深层检查,而不像 headerContains 只支持[请求头]: [请求头的期望值,仅可能为字符串或一个检查函数]

正如前面“测试创建用户资料”测试用例中看到的那样:

expect: {
  statusEquals: 200,
  replyContains: {
    /** result是响应体根部的字段 */
    result: {
      /** 对result内部的值做深层判断 */
      id: id => id > 0,
      name: "西蒙"
    }
  }
}

由于期望检查的环境是标准的 Node 环境,因此可以利用一些语言上的特性,方便地执行一些检查。

假设响应体的 result 字段,其值为一个数组,我们判断该数组仅包含一个元素,并且那个元素也是一个 JSON 对象,它的 name 字段值应为 Simon,gender 字段值应为 male,email字段值应为 simon@gmail.com,可以这么书写期望检查:

expect: {
  statusEquals: 200,
  replyContains: {
    result: {
      /** 注意,result是一个数组对象,所以其上存在length属性 */
      length: 1,
      /** 其中下标为0的元素 */
      0: {
        /** name字段的值应为Simon */
        name: "Simon",
        /** gender字段的值应为male */
        gender: "male",
        /** email字段的值应为simon@gmail.com */
        email: "simon@gmail.com"
      }
    }
  }
}

作为对比,我们看下这段检查使用传统的 assert 库来做,代码会是什么样子:

// resp是使用Axios库对接口进行请求取得的响应值
assert.strictEqual(resp.status, 200);
assert.strictEqual(resp.data.result.length, 1);
assert.strictEqual(resp.data.result[0].name, "Simon");
assert.strictEqual(resp.data.result[0].gender, "male");
assert.strictEqual(resp.data.result[0].email, "simon@gmail.com");

一个眼神确认,就能看出使用期望 DSL 书写检查会更简洁明了。

我们可以尽情利用语言特性带来的便利来简化测试代码。假设期望检查时,字段名称需要根据一个表达式计算得出,那么可以利用 ES6 提供的“Computed property names”特性:

expect: {
  statusEquals: 200,
  replyContains: {
    result: {
      /** key变量的值作为字段名称,判断该字段的值应等于value变量 */
      [key]: value
    }
  }
}

判断响应体是否等于指定值

如果想要检查响应体严格等于某个期望值,可以使用replyEquals

expect: {
  statusEquals: 200,
  replyEquals: {
    message: "你好,TS!"
  }
}

这段期望检查,仅当响应体内容只包含一个message字段,并且其值为你好,TS!时,才通过。

由于很多时候,我们的接口会返回一大堆数据,这时我们仅会对“感兴趣”的那部分信息做检查,因此,这个replyEquals,在大多数测试场景中并不会被使用。

使用工厂函数生成含有路由参数的 Endpoint

很多时候,接口路由需要指定资源 Id 才能最终确定,例如一个节点详情的路由形式可能是:/nodes/<节点Id>/detail

这种时候,我们可以在接口 Spec 中导出一个根据入参生成指定 Endpoint 的工厂函数:

// ./spec/getNodeDetail.ts
import { Endpoint } from "api-test-lib";

export default function(id: string) {
  return new Endpoint.Get(`/nodes/${id}/detail`);
}

在测试代码中调用该工厂函数,给定节点 Id,得到其对应的 Endpoint:

// ./test/getNodeDetail.test.ts
import { test } from "api-test-lib";
import getNodeDetail from "../spec/getNodeDetail";

test("测试获取节点性情", () =>
  /** 对"/nodes/15/detail"进行测试 */
  getNodeDetail("15").request({
    // ...
  }));

使用 suite 描述测试场景

我们看到前面的测试代码示例,都是直接通过 test 函数定义一个测试用例,并且这个测试用例是模块顶级的。

我们推荐您使用 suite 函数,定义一个测试集,来组合测试用例,这个方法尤其适用于场景化测试:

import { suite, test } from "api-test-lib";

suite("作业调度全流程测试", () => {
  test("下发一个新作业", () =>
    // ...
  );

  test("等待作业执行结束", () =>
    // ...
  );

  test("检查作业执行成功", () =>
    // ...
  );
});

下面您马上可以看到,使用 suite 还可以获得通过标签管理测试用例等额外的好处。

使用标签 tags 管理测试用例集

在 suite 中定义标签

在定义一个测试用例集 suite 时,我们可以在其定义函数第二个参数传入一个对象,声明一些元数据,其中就包括非常重要的标签:

suite("XX接口测试", { tags: ["feature", "smoke", "fast"] }, () => {
  // ...
});

如上,我们定义了三个标签 feature、smoke、fast,分别表示这个测试用例集:

  • 属于功能测试
  • 并且是冒烟测试
  • 测试过程很快可以结束

如何打标签是门学问,您可以根据自己产品实际的测试场景确定标签候选清单。

通过--tags 参数选择性执行测试

当您的测试用例集已经打上丰富的标签时,可以在启动测试时传入--tags 参数,指明希望执行哪批测试,对不符合给定条件的测试则略过。

如果已经按照前面的方式配置好测试引导过程,当我们以API_TEST_HOST="http://<host>:<port>" yarn test --tags feature的方式启动测试时,将只会执行包含标签feature的那些 suite。

当然,我们的标签管理系统不会仅支持这么简单的功能。

通过--tags "feature && smoke",将执行既包含feature又包含smoke标签的那些 suite。

通过--tags "feature || smoke",将执行包含feature或包含smoke标签的那些 suite。

通过--tags "!feature && smoke",将执行不包含feature但包含smoke标签的那些 suite。

相信通过这些示例,您已经掌握了如何编写--tags参数的诀窍。

FAQ

为什么不支持 Put、Delete 等方法?

目前我们自己产品中实现的接口,仅使用 Get 和 Post 两种请求方式。如果您有支持其他 Http 方法的需求,可以在 Issue 区留言,以便我们扩充。

为什么每个 Endpoint 实例仅支持一种 Http 请求方法?

您可以会质疑为什么不在 Endpoint 实例上提供 Get、Post 等不同方法的调用,而是在实例化 Endpoint 时就必须指定 Endpoint.GetEndpoint.Post

这么做的原因是,即便是纯 RESTful 风格的 API 实现,在一个 URI 上,通过不同方法请求背后的语义仍然是不同的,如:

  • POST /users,是创建用户,在 Swagger 这类 API 定义语言中,其operationId可以取值 createUser
  • GET /users,是获取用户列表,在 Swagger 这类 API 定义语言中,其operationId可以取值getUserList

我们希望,每一个 Endpoint 实例,其语义就是确定的,这样在测试代码中,就能通过 Endpoint 实例名称,清晰地看到我们正在做什么事。

如何处理非 200 响应,响应体异构的情形?

在一些产品接口实现中,很可能遇到 200 的响应与非 200 的响应,其响应体结构是异构的情形。

对于这种情况,目前的测试框架实现还无法灵活处理,尤其是在泛型绑定时能支持多种 ReplySpec 的特性。

关于这块,我们会结合 TS 的语言特性,继续深入思考设计实现,如果您有什么好的想法,欢迎在 Issue 区留言。

Promise 还是 Async/Await 亦或其他?

众所周知,Node.js 是以单线程的工作模式执行我们编写的代码,当遇到 IO 操作如网络请求、磁盘读写时,函数调用会以异步回调的模式去接收操作完成后返回的值,并进行相关处理。

由于异步回调的形式很容易产生“回调地狱(callback hell)”的问题,因此出现了更好的选择:Promise

想在 Mocha 框架中使用 Promise 风格编写测试,只需要在测试体函数中接收一个“done”参数,它是一个函数,当你的测试完成时,就去执行它,向测试框架表明当前测试用例已完成:

test("testing with promise", done => {
  createJob()
    .request({
      // 封装请求内容
    })
    .then(response => {
      // request调用返回的是一个Promise
      // 对response做处理
      done(); // 最后别忘记调用done,表示测试用例已完成
    });
});

但这对写习惯同步代码的同学来说,仍然比较“别扭”。此时,可以使用 Async/Await 关键字来调整编码风格。

使用 Async/Await 的要诀很简单:

  • 只有在被 async 标注的函数体中,才能使用 await 关键字
  • 一个被 async 标注的函数,必然返回一个 Promise 对象
  • 可以 await 一个 Promise 对象,返回 Promise 被 resolve 后的值
  • 可以 await 一个 Promise 以外的普通 JS 对象,此时返回的就是这个 JS 对象本身

因此,前面的代码可以改造为:

// 测试体函数必须加上async关键字
test("testing with async/await", async () => {
  const response = await createJob().request({
    // 封装请求内容
  }); // 经过await返回得到response
  // 对response做处理
});

切记,此时 async 函数不可以再接收并调用 done 参数。

那么,还有没有更好的方法?我们可以观察到如下事实:

  • Endpoint.request 返回的是一个 Promise 对象
  • 测试框架发现测试体函数返回了一个 Promise 对象后,会一直等待直到 Promise 被完成,才继续后面的执行
  • 借助强大的期望 DSL,我们绝大多数对 response 做检查判断的逻辑,都可以在 expect 内部完成

基于这些事实,我们可以继续改造:

test("testing with returning promise", () => {
  return createJob().request({
    // 封装请求内容
    expect: {
      // 在这里对response做判断
    }
  });
});

这样也是可以工作的!

还能更进一步吗?我们借助 ES6 箭头函数不加花括号,直接返回一个表达式的写法,连return关键字都可以省去:

// 切记!测试体箭头函数不能加花括号!
test("testing with returning promise", () =>
  createJob().request({
    // 封装请求内容
    expect: {
      // 在这里对response做判断
    }
  }));

但是要注意,这里必须省去花括号,表示这个箭头函数直接返回 request 调用后返回的结果,即一个 Promise 对象。

如果加上花括号,又由于缺少return显式返回一个值,那么此时测试体函数就相当于返回了undefined,这会产生不可预料的行为。