在 构建下一代 HTTP API - 总览 那篇文章中,我调侃道:
如果你爱一个人,让他写测试,因为那是天堂;如果你恨一个人,让他写测试,因为那是地狱。- 《北京程序员在纽约》
不得不承认的是,为代码中各种潜在的组合绞尽脑汁撰写单元测试,实在不是一件容易的事情。我个人喜欢在一些项目中对于关键路径撰写单元测试,确保后续的功能更新或者重构不会影响关键流程;然后有空的时候再去补上更多的单元测试,以及在遇到某个 bug 时,补上会导致这个 bug 的测试。单元测试如果覆盖得好,对项目的贡献不仅仅是减少产品出问题的机会,更重要的是它给我们自己以足够的信心:这代码无论我怎么折腾新功能,内部怎么重构,只要测试通过,我就有信心没有大问题。
对于一个 API 项目来说,单元测试要好写很多,因为哪些应该通过的 case,哪些不该通过的 case,一目了然;然而, 它又繁琐得多 — 各种 header / path / query / request body 的组合,可以非常复杂,一个个去写无异于在浪费自己的生命。所以 API 测试天然适合做生成式测试(Generative testing),或者说 property based testing(基于特性的测试)。
生成式测试
考虑到很多读者也许第一次接触生成式测试,或者接触过但了解不深,这里我简单讲讲我自己的理解。
传统的单元测试,是基于用例的测试(test by example),我们会给定要测试的数据和期望的结果,触发要测试的代码,等测试代码运行结束后,做断言,看结果是否和我们所期待的一致。单元测试的问题在于测试的完备性 — 我们的测试是否涵盖了所有可能的输入的组合 — 事实上几乎没有项目能够达到完备性,即便测试覆盖率达到 100%,我们也很难说测试是完备的。
而生成式测试试图解决这个问题。我们并不写具体的测试用例,而是直接描述测试的意图,让程序自动来生成测试所需要的数据和期望的结果,然后触发要测试的代码,做断言,看最终结果是否和我们所期待的一致。
那么,程序如何生成测试所需要的数据和期望的结果呢?这就是生成式测试主要的工作之一。我们看一段生成式测试的伪代码:
代码语言:javascript复制for uri <- all_possible_urls(),
headers <- all_possible_headers(),
body <- all_possible_body(),
{expected_code, expected_body_schema} <- good_response() do
conn = send(method, uri, headers, body)
assert conn.status == expected_code
assert Schema.valid?(expected_body_schema, conn.response_body)
end
这段代码要测试一个 API,它描述了我们测试的方法:生成一个 request,发送,然后获得返回结果,验证返回的结果是期望的 status 和期望的 response body。在描述 request 时,我们没有描述具体的 uri,具体的 header,body,而是告诉程序给我满足特定结构的数据,然后程序会随机生成符合要求的数据。一般来说,生成式测试会有一个默认的上限,比如 1000,那么一个测试运行了一千组不同的数据还没有问题,就会停下来。这时,测试就是成功的。
那么如果测试失败呢?生成式测试会尽可能的缩小测试的数据,找到可以复现问题的最小的数据集。比如一个函数在字符串长度超过 140 时会出错,生成式测试使用 1000 字符的字符串找到了这个问题,之后它会一路缩小,直到找到问题的边界 141。这将有助于帮助程序员快速定位问题所在,极高地提升他们解决问题的速度。
目前大部分编程语言都支持生成式测试,并且提供基础数据结构的生成。比如你告诉生成式测试需要一个字符串,一个整数,一个整数数组等,它都能随机给你生成对应的数据。通过组合这些基础数据结构,我们可以生成几乎任何复杂的数据结构,从而对几乎任何函数做生成式测试。
Quenya 如何生成 API 测试?
虽然撰写生成式测试会让开发者撰写测试的效率大大提升,Quenya 还是希望能帮助开发者来自动生成生成式测试。这听上去似乎很难,但因为有 OpenAPI spec 为基础,所以反而顺理成章。我们可以遍历 Open API spec 里面的每个 operation,从里面取出 request 相关的数据的 schema,然后生成测试数据集,发送请求,得到响应,最后用 response 的 schema 来验证,流程如下:
如何生成测试数据集?
想法很简单直观,接下来我们只需要解决一个核心问题:如何从一个描述了数据类型,可以做数据校验的 JSON schema 中生成可用于生成式测试的数据集?
这个问题进一步可以分解成两个问题:
- 如何通过 JSON schema 生成正确的数据?
- 如何通过 JSON schema 生成错误的数据?
为了解决这个问题,我做了一个新的库,叫 json_data_faker
(github.com/tyrchen/json_data_faker)。为啥需要做一个新的库?因为我感觉通过 JSON schema 生成随机的测试数据,是一个比较公共的需求,不光 Quenya 需要它,其它项目也许也会用到它。
这个库的接口很简单,给定一个 JSON schema,会返回一个 stream,如果从这个 stream 里读取数据,会得到一个满足 JSON schema 的数据结构,比如下面的代码,我们期待获得一个 Todo 结构的数组:
代码语言:javascript复制iex> object_schema = %{
"properties" => %{
"body" => %{
"maxLength" => 140,
"minLength" => 3,
"type" => "string"
},
"created" => %{
"format" => "date-time",
"type" => "string"
},
"id" => %{
"format" => "uuid",
"type" => "string"
},
"status" => %{
"enum" => [
"active",
"completed"
],
"type" => "string"
},
"updated" => %{
"format" => "date-time",
"type" => "string"
}
},
"required" => [
"body"
],
"type" => "object"
}
iex> schema = %{
"items" => object_schema,
"type" => "array"
}
iex> schema |> JsonDataFaker.generate() |> Enum.take(1) |> List.first()
[
%{
"body" => "Do you think I am easier to be played on than a pipe?",
"created" => "2020-11-28T01:15:35.268463Z",
"id" => "13543d9c-0f37-482d-84d6-52b2cb8c1b3f",
"status" => "active",
"updated" => "2020-11-28T01:15:35.268478Z"
},
%{
"body" => "When sorrows come, they come not single spies, but in battalions.",
"created" => "2020-11-28T01:15:35.268502Z",
"id" => "c95ef972-05c9-4132-9525-09c99a15bf01",
"status" => "completed",
"updated" => "2020-11-28T01:15:35.268517Z"
}
...
]
可以看到使用起来非常简单,这个库的代码也并不复杂,一百来行代码而已。
看到这里你也许有一个疑问,为什么这个接口要返回一个 stream?这是因为生成式测试需要这样的数据集,它在运行的时候,可以不断从这个 stream 里取数据,直到测试结束。
那么,如何实现这样的功能呢?不同的语言有不同的方法,比如 Elixir,有一个生成式测试的库叫 StreamData,它提供根据现有 stream map/reduce 出新的 stream 的能力。比如说我们要生成 JSON schema 里一个有特定 format 的 string,我们可以这样:
代码语言:javascript复制
defp generate_string(%{"format" => "date-time"}),
do: stream_gen(fn -> 30 |> Faker.DateTime.backward() |> DateTime.to_iso8601() end)
defp generate_string(%{"format" => "uuid"}), do: stream_gen(&Faker.UUID.v4/0)
defp generate_string(%{"format" => "email"}), do: stream_gen(&Faker.Internet.email/0)
如果要生成一个图片的 url,可以这样:
代码语言:javascript复制defp generate_string(%{"format" => "image_uri"}) do
stream_gen(fn ->
w = Enum.random(1..4) * 400
h = Enum.random(1..4) * 400
"https://source.unsplash.com/random/#{w}x#{h}"
end)
end
当我们把基础类型都组织好,生成一个 object,无非就是 reduce 一下:
代码语言:javascript复制defp generate_by_type(%{"type" => "object"} = schema) do
stream_gen(fn ->
Enum.reduce(schema["properties"], %{}, fn {k, inner_schema}, acc ->
v = inner_schema |> generate_by_type() |> Enum.take(1) |> List.first()
Map.put(acc, k, v)
end)
end)
end
如何生成测试?
有了测试数据集,生成测试便不在话下。接下来就是如何验证测试结果是否正确。这个时候,上文中问的那两个问题就很重要了:
- 如何通过 JSON schema 生成正确的数据?我们需要验证正确的输入会导致正确的 response,比如 200 OK,而不是 500 Internal error 什么的
- 如何通过 JSON schema 生成错误的数据?我们需要验证错误的输入会导致正确的 response,比如 400 Bad request,而不是 200 OK 或者 500 Internal error 什么的
所以对于一个 operation,我们至少要生成两类生成式测试:
- 各种正确输入的组合
- 至少一处错误输入的组合,包含三种可能:
- 错误的请求 body
- 错误的请求 header
- 错误的请求路径,主要是 path 参数不合法,或者构建的 query 不合法
Quenya 自动生成的测试目前只能涵盖 1,还不能涵盖 2。要涵盖 2,需要为 json_data_faker
提供根据 schema 生成恰好不满足 schema 要求的数据。因为时间关系,我还没来得及做这一块。
最后,以 quenya 项目里 petstore 这个例子为例,我们看看自动生成了多少行测试:
代码语言:javascript复制$ tokei test
-------------------------------------------------------------------------------
Language Files Lines Code Comments Blanks
-------------------------------------------------------------------------------
Elixir 21 2912 2652 0 260
-------------------------------------------------------------------------------
Total 21 2912 2652 0 260
-------------------------------------------------------------------------------
整整两千六百行代码!!试想一个开发者照着 OpenAPI spec 写出这么多行测试代码要花多久?我猜起码一周时间。对于一个年薪 25w 美金的工程师来说,一周就是 5000 美金,Quenya 直接帮你省下了。
petstore spec 里一共 20 条路由,所以生成了 20 个测试,每个测试运行了 100(缺省)个组合,运行结果如下:
代码语言:javascript复制$ mix test
....................
Finished in 3.1 seconds
20 properties, 0 failures
Randomized with seed 686139
我们可以更改配置,让每个测试运行 1000 个组合:
代码语言:javascript复制config :stream_data, max_runs: 1000
再运行测试:
代码语言:javascript复制$ mix test
Compiling 64 files (.ex)
Generated petstore app
....................
Finished in 26.4 seconds
20 properties, 0 failures
20 个生成式测试(等同于两万个单元测试)运行了 26s,几乎是之前的 10 倍。
因为 petstore 是完全由 Quenya 生成,没有做任何修改,所以它每条路由都有一个 FakeHandler
返回 mocking data。因而,我们用 Quenya 生成的测试,测试了 Quenya 生成的 API 代码,证明了 API 代码至少在 happy path 上没有问题。而 Quenya 生成的测试代码和 API 代码都正常工作,说明了至少 Quenya builder 和 Quenya 本身的主流程没有问题。
以己之矛攻己之盾,真是妙不可言。
One more thing
自动生成代码固然是一件节省人力的好事儿,但我们时时刻刻脑子里要想着如何让生成的代码具有高度的灵活性,以便开发者可以定制生成的测试。这和生成 API 代码的定制化要求是一致的。如果失去了定制化的能力,那么生成代码的好处就减了一多半,应用的场景也会处处受限。切记!切记!
有读者会问,API 测试的需求非常固定,还需要哪门子定制?
再仔细想想。如果日后你一个个实现了 API 的业务逻辑,这业务逻辑包含对数据库的读写 —— 比如 createPet,最终会把 API 的请求存入数据库中。那么问题来了,我们目前自动生成的测试直接执行的话,会混入数据库的读写,这带来两个问题:
- 生成式测试的测试速度直线下降,因为有了本不该有的数据库的外部依赖
- 很多依赖前置条件的生成式测试会失败,比如 getPet,因为没有 createPet 作为前置条件,所以 getPet 一定会返回 404
所以在这里,我们需要对数据库这一层做 mocking。然而,在生成 API 测试的时候,我们并不知道这 API 的业务逻辑背后究竟是一个 gRPC 调用,还是一个对数据库的读写,还是其他什么逻辑。所以我们要允许开发者去扩展这些测试,在执行 API 请求的上下文中进行必要的 mocking。具体思路如下:
这样,我们把生成的测试也 pipeline 化,并且在整个 pipeline 中提供了相应的 hook。如果开发者不做任何 precondition / mocking / cleaning,那么测试就按照缺省的方式去运行。