测试生成器


测试生成器是一种 track 专属的软件,用于自动生成练习的测试。它的做法是把练习的 JSON 测试用例转换成该 track 所用语言编写的测试。

优势

拥有测试生成器的一些好处是:

  1. 可以更快地添加练习
  2. 自动完成添加练习中“无聊”的部分
  3. 轻松让测试与最新的规范数据保持同步

使用场景

一般来说,运行测试生成器是为了:

  1. 为_新_练习生成测试
  2. 更新_已有_练习的测试

为新练习生成测试

为一个新练习添加测试生成器后,就可以生成它的测试文件。只要测试生成器本身已经实现,为新练习生成测试,会比从零开始手写省下(远远)更多的功夫。

更新已有练习的测试

练习有了测试生成器之后,你可以重新运行它,让练习与最新的规范数据保持更新/同步。我们建议定期这样做,检查有没有需要更新的问题测试用例,或者有没有你想加入的新测试。

起点

为一个练习实现测试生成器时,有两种可能的起点:

  1. 练习是新的,因此还没有任何测试
  2. 练习已经存在,因此已有测试
Caution

如果已经存在测试,请让测试生成器生成的测试不要破坏已有的解答。

设计

大体上说,测试文件有两种生成方式:

  • 代码:测试文件(大部分)由代码生成
  • 模板:测试文件(大部分)用模板生成

我们发现,基于代码的做法会让测试生成器的代码相当复杂,而基于模板的做法则更简单。

我们推荐的做法是:

  1. 读取练习的规范数据
  2. 排除练习的 tests.toml 文件中标记为 include = false 的测试用例
  3. 把练习的规范数据转换成模板可用的格式
  4. 把练习的规范数据传给练习专属的模板

这种做法的关键好处在于,每个练习都有自己的模板,这:

  • 让测试文件的生成方式一目了然
  • 让它们更容易调试
  • 让修改它们变得安全,不必担心破坏别的练习
Caution

设计测试生成器时,请尽量:

  • 减少测试生成器内部对规范数据的预处理
  • 降低模板之间的耦合

实现

测试生成器通常(大部分)用该 track 的语言编写。

Caution

你当然可以自由使用其他语言,但每多一种语言,都会让 track 的维护和贡献变得更困难。 因此,我们建议尽量使用该 track 的语言,因为这样维护和贡献都会更容易。

格式化

如果你的 track 有格式化代码的工具,可以考虑在渲染模板_之后_把它作为后处理步骤运行。

规范数据

测试生成器处理的核心数据,是练习的 canonical-data.json 文件。这个文件定义在 exercism/problem-specifications 代码仓库中,该仓库为许多 Exercism 练习定义了共享的元数据。

Caution

并不是所有练习都有 canonical-data.json 文件! 如果没有,你就得手动创建测试,因为测试生成器没有可处理的数据。

结构

规范数据定义在一个 JSON 对象里。这个对象包含一个 "cases" 字段,里面装着测试用例。这些测试用例(通常)与你的 track 中的测试一一对应。

每个测试用例都有若干属性,其中最重要的是 description、property、输入值和期望值。下面是 leap 练习的 canonical-data.json 文件的一个(部分)示例:

{
  "exercise": "leap",
  "cases": [
    {
      "uuid": "6466b30d-519c-438e-935d-388224ab5223",
      "description": "year not divisible by 4 in common year",
      "property": "leapYear",
      "input": {
        "year": 2015
      },
      "expected": false
    },
    {
      "uuid": "4fe9b84c-8e65-489e-970b-856d60b8b78e",
      "description": "year divisible by 4, not divisible by 100 in leap year",
      "property": "leapYear",
      "input": {
        "year": 1996
      },
      "expected": true
    }
  ]
}

测试生成器的主要职责,就是把这些 JSON 数据转换成 track 专属的测试。上面的 JSON 转换成 Nim 测试代码后大概是这样的:

import unittest
import leap

suite "Leap":
  test "year not divisible by 4 in common year":
    check isLeapYear(2015) == false

  test "year divisible by 4, not divisible by 100 in leap year":
    check isLeapYear(1996) == true

canonical-data.json 文件的结构有详细的文档,也有 JSON schema 定义。

嵌套

有些练习的规范数据使用了嵌套。也就是说,cases 数组中的每个元素可以是:

  1. 一个普通测试用例(没有子测试用例)
  2. 一组测试用例(一个或多个子测试用例)
Note

要判断一个元素属于哪种类型,可以检查它是否存在某种类型独有的字段。 最好的做法大概是用 "cases" 键,因为只有测试用例组才会有它。

下面是一个嵌套测试用例的例子:

{
  "cases": [
    {
      "uuid": "e9c93a78-c536-4750-a336-94583d23fafa",
      "description": "data is retained",
      "property": "data",
      "input": {
        "treeData": ["4"]
      },
      "expected": {
        "data": "4",
        "left": null,
        "right": null
      }
    },
    {
      "description": "insert data at proper node",
      "cases": [
        {
          "uuid": "7a95c9e8-69f6-476a-b0c4-4170cb3f7c91",
          "description": "smaller number at left node",
          "property": "data",
          "input": {
            "treeData": ["4", "2"]
          },
          "expected": {
            "data": "4",
            "left": {
              "data": "2",
              "left": null,
              "right": null
            },
            "right": null
          }
        }
      ]
    }
  ]
}
Caution

如果你的 track 不支持分组测试,你就需要:

  • 遍历/展平 cases 层级,只保留最内层(叶子)的测试用例
  • 把测试用例的描述与它的父级描述组合起来,生成唯一的测试名称

输入值和期望值

测试用例的 input 和 expected 键内容差别很大。大多数情况下,它们是标量值(比如数字、布尔值或字符串)或简单的对象。不过偶尔也会遇到比较复杂的值,可能需要做一些预处理,比如伪代码中的 lambda、要在学员代码上执行的一组操作等等。

场景

测试用例有一个可选的 scenarios 字段。测试生成器可以用这个字段对某些测试用例做特殊处理。最常见的用法是忽略某类测试,比如带有 "unicode" 场景的测试,因为你的 track 所用的语言可能不支持 Unicode。

完整的场景列表见这里。

读取 canonical-data.json 文件

读取 canonical-data.json 文件有几种办法:

  1. 直接从 problem-specifications 代码仓库获取(例如 https://raw.githubusercontent.com/exercism/problem-specifications/main/exercises/leap/canonical-data.json)。
  2. 把 problem-specifications 代码仓库作为 Git 子模块添加到 track 代码仓库。
  3. 从 configlet 缓存中读取。 缓存位置取决于用户的系统,但你可以用 configlet info -o -v d | head -1 | cut -d " " -f 5 以编程方式取得该位置。

track 专属的测试用例

如果你的 track 想加入一些额外的、track 专属的测试用例(规范数据里没有的),一种办法是创建一个 additional-test-cases.json 文件,测试生成器可以把它与 canonical-data.json 文件合并,然后再交给模板渲染。

模板

用哪种模板引擎,多半取决于具体的 track。理想情况下,你希望模板越直白越好,所以不必在意代码重复之类的问题。

模板本身的数据来自测试生成器,测试生成器会遍历模板并渲染它们。

Note

为了帮助模板保持简单,可以在测试生成器一侧做一点预处理,或者定义一些“过滤器”,或使用你的模板所支持的任何扩展机制。

使用 configlet

configlet 是 track 的主要维护工具,可以用它来:

  • 为新练习创建练习文件:运行 bin/configlet create --practice-exercise <slug>
  • 同步已有练习的 tests.toml 文件:运行 bin/configlet sync --tests --update --exercise <slug>
  • 把练习的规范数据抓取到本地磁盘(这是上面两条命令的副作用)

因此,把 configlet 和测试生成器结合起来使用,可以打造出一些非常强大的工作流。

命令行界面

你会希望测试生成器既好用_又_强大。为此,我们建议创建一个或多个脚本文件。

Note

你可以自由选择最适合你的 track 的脚本文件格式。 shell 脚本和 PowerShell 脚本都是常见且好用的选择。

下面是一个 shell 脚本示例,它把 configlet 和测试生成器结合起来,快速搭建一个新练习:

bin/fetch-configlet
bin/configlet create --practice-exercise <slug>
path/to/test-generator <slug>

从零开始构建

在动手构建测试生成器之前,建议你先看看几个现成的测试生成器,感受一下其他 track 是怎么实现的:

如果你有任何问题,论坛是最好的提问地方。 论坛上关于 Rust以及 JavaScript 测试生成器的讨论也许会有帮助。

最小可行产品

我们建议逐步构建测试生成器,从最小可行产品开始。最简版本可以只是读取练习的 canonical-data.json,然后把数据直接传给模板。

先专注做好一个练习,最好是像 leap 这样简单的练习。等它跑通之后,再逐步加入更多练习。

并且尽量让测试生成器保持简单。

Note

理想情况下,贡献者只需要粘贴/修改现有的模板,而无需理解测试生成器的内部工作原理。

使用或贡献

如何使用或贡献测试生成器,取决于具体的 track。相关说明可以在 track 的 README.md、CONTRIBUTING.md 或测试生成器代码所在的目录里找到。