Mojo的测试框架及测试套件的详解

时间:2024-10-27 08:05:32

Mojo包含一个用于开发和执行单元测试的框架。该框架还支持在API引用的文档字符串(也称为文档字符串)中测试代码示例。Mojo测试框架由一组定义为Mojo标准库一部分的断言和Mojo测试命令行工具组成。

开始


让我们从编写和运行mojo测试的简单例子开始。

1.写测试


对于使用Mojo测试框架的第一个示例,创建一个名为test_quickstart的文件。Mojo包含以下代码:

# Content of test_quickstart.mojo
from testing import assert_equal

def inc(n: Int) -> Int:
    return n + 1

def test_inc_zero():
    # This test contains an intentional logical error to show an example of
    # what a test failure looks like at runtime.
    assert_equal(inc(0), 0)

def test_inc_one():
    assert_equal(inc(1), 2)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

在这个文件中,inc()函数是测试目标。名称以test_开头的函数是测试。通常目标应该位于与其测试分开的源文件中,但是对于这个简单的示例,您可以在同一个文件中定义它们。

如果测试函数在执行时引发错误,则失败,否则通过。本例中的两个测试使用assert_equal()函数,如果提供的两个值不相等,则会引发错误。

test_inc_zero()的实现包含一个有意的逻辑错误,以便在本教程的下一个步骤中执行测试时可以看到一个失败的测试示例。

2.进行测试


然后在包含该文件的目录下,在shell中执行以下命令:

mojo test test_quickstart.mojo
  • 1

您应该看到与此相似的输出(请注意,这个示例从所显示的输出中选择了完全文件系统路径):

Testing Time: 1.193s

Total Discovered Tests: 2

Passed : 1 (50.00%)
Failed : 1 (50.00%)
Skipped: 0 (0.00%)

******************** Failure: 'ROOT_DIR/test_quickstart.mojo::test_inc_zero()' ********************

Unhandled exception caught during execution

Error: At ROOT_DIR/test_quickstart.mojo:8:17: AssertionError: `left == right` comparison failed:
   left: 1
  right: 0

********************
输出首先是总结发现、通过、失败和跳过的测试数量。此后,每个失败的测试都会连同其错误消息一起报告。
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

testing模块


Mojo标准库包括一个测试模块,该模块定义了几个用于实现测试的断言函数。如果满足条件,每个断言返回None,如果不满足则引发错误。

  • assert_true():断言输入值为True。
  • assert_false():断言输入值为False。
  • assert_equal():判断输入值是否相等。
  • assert_not_equal():断言输入值不相等。
  • assert_almost_equal():断言输入值在一个公差范围内相等。

布尔断言在失败时报告一条基本错误消息。

from testing import *
assert_true(False)
  • 1
  • 2

输出:

Unhandled exception caught during execution

Error: At Expression [1] wrapper:14:16: AssertionError: condition was unexpectedly False
  • 1
  • 2
  • 3

每个函数还接受一个可选的msg关键字参数,用于在断言失败时提供一条自定义消息。

assert_true(False, msg="paradoxes are not allowed")
  • 1

输出:

Unhandled exception caught during execution

Error: At Expression [2] wrapper:14:16: AssertionError: paradoxes are not allowed
  • 1
  • 2
  • 3

为了比较浮点值,您应该使用assert_almost_equal(),它允许您指定绝对或相对公差。

result = 10 / 3
assert_almost_equal(result, 3.33, atol=0.001, msg="close but no cigar")
  • 1
  • 2
Unhandled exception caught during execution

Error: At Expression [3] wrapper:15:24: AssertionError: 3.3333333333333335 is not close to 3.3300000000000001 with a diff of 0.0033333333333334103 (close but no cigar)
  • 1
  • 2
  • 3

测试模块还定义了一个上下文管理器assert_raise(),以断言给定的代码块正确地引发预期的错误。

def inc(n: Int) -> Int:
    if n == Int.MAX:
         raise Error("inc overflow")
    return n + 1

print("Test passes because the error is raised")
with assert_raises():
    _ = inc(Int.MAX)

print("Test fails because the error isn't raised")
with assert_raises():
    _ = inc(Int.MIN)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

输出:

Unhandled exception caught during execution

Test passes because the error is raised
Test fails because the error isn't raised
Error: AssertionError: Didn't raise at Expression [4] wrapper:18:23
  • 1
  • 2
  • 3
  • 4
  • 5

上面的示例将inc()的返回值赋给丢弃模式。如果没有它,Mojo编译器会检测到返回值未使用,并优化代码以消除函数调用。

您还可以为assert_raise()提供一个可选的contains参数,以指示只有在错误消息包含指定的子字符串时测试才通过。其他错误被传播,导致测试失败。

print("Test passes because the error contains the substring")
with assert_raises(contains="required"):
    raise Error("missing required argument")

print("Test fails because the error doesnt contain the substring")
with assert_raises(contains="required"):
    raise Error("invalid value")
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

输出:

Unhandled exception caught during execution

Test passes because the error contains the substring
Test fails because the error doesnt contain the substring
Error: invalid value
  • 1
  • 2
  • 3
  • 4
  • 5

写单元测试


Mojo单元测试只是一个满足所有这些需求的函数:

  • 名称以test_开头。
  • 不接受任何参数。
  • 返回None或object类型的值。
  • 引发错误以指示测试失败。
  • 在模块作用域定义,而不是作为Mojo结构方法定义。

您可以使用def或fn来定义测试函数。因为测试函数总是引发一个错误来表示失败,所以任何使用fn定义的测试函数都必须包含raise声明。

通常,应该使用Mojo标准库测试模块中的断言实用程序来实现测试。您可以在同一个测试函数中包含多个相关的断言。但是,如果断言在执行期间引发错误,则测试函数立即返回,跳过任何后续断言。

您必须在以test前缀或后缀命名的Mojo源文件中定义Mojo单元测试。您可以在目录层次结构中组织测试文件,但是测试文件不能是Mojo包的一部分(也就是说,测试目录不应该包含__init__)mojo文件)。

下面是一个测试文件的示例,其中包含在名为my_target_module(此处未显示)的源模块中定义的函数的三个测试。

# File: test_my_target_module.mojo

from my_target_module import convert_input, validate_input
from testing import assert_equal, assert_false, assert_raises, assert_true

def test_validate_input():
	assert_true(validate_input("good"), msg="'good' should be valid input")
	assert_false(validate_input("bad"), msg="'bad' should be invalid input")

def test_convert_input():
	assert_equal(convert_input("input1"), "output1")
	assert_equal(convert_input("input2"), "output2")

def test_convert_input_error():
	with assert_raises():
		_ = convert_input("garbage")
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

单元测试的唯一标识由测试文件的路径和测试函数的名称组成,两者之间用::分隔。所以上面例子中的测试id是:

  • test_my_target_module.mojo::test_validate_input()
  • test_my_target_module.mojo::test_convert_input()
  • test_my_target_module.mojo::test_convert_error()

mojo test 命令


mojo命令行界面包括用于运行测试或收集测试列表的mojo test命令。

运行测试


默认情况下,mojo test命令会运行使用下列选项之一指定的测试:

  • 带有绝对或相对文件路径的单个测试ID,以只运行该测试。
  • 一个绝对或相对文件路径,用于运行该文件中的所有测试。
  • 一个单一的绝对或相对目录路径,以递归遍历该目录层次结构并运行找到的所有测试。

如果需要,可以多次使用-I选项,在导入Mojo模块和包时搜索的目录列表中添加额外的路径。例如,考虑一个具有以下目录结构的项目:

.
├── src
│   ├── example.mojo
│   └── my_math
│       ├── __init__.mojo
│       └── utils.mojo
└── test
    └── my_math
        ├── test_dec.mojo
        └── test_inc.mojo
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

在项目根目录下,你可以像下面这样执行test目录下的所有测试:

$ mojo test -I src test
Testing Time: 3.433s

Total Discovered Tests: 4

Passed : 4 (100.00%)
Failed : 0 (0.00%)
Skipped: 0 (0.00%)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

可以运行只包含在test_dec中的测试。Mojo文件如下:

$ mojo test -I src test/my_math/test_dec.mojo
Testing Time: 1.175s

Total Discovered Tests: 2

Passed : 2 (100.00%)
Failed : 0 (0.00%)
Skipped: 0 (0.00%)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

你可以通过提供文件的完全限定ID来运行单个测试,如下所示:

$ mojo test -I src 'test/my_math/test_dec.mojo::test_dec_valid()'
Testing Time: 0.66s

Total Discovered Tests: 1

Passed : 1 (100.00%)
Failed : 0 (0.00%)
Skipped: 0 (0.00%)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

收集测试列表


通过包含——collect-only或——co选项,可以使用mojo test发现并打印测试列表。

作为一个例子,考虑运行测试部分中显示的项目结构。下面的命令生成测试目录层次结构中定义的所有测试的列表。

mojo test --co test
  • 1

输出显示了目录、测试文件和单个测试的层次结构(注意,这个例子在输出中省略了文件系统的完整路径):

<ROOT_DIR/test/my_math>
  <ROOT_DIR/test/my_math/test_dec.mojo>
    <ROOT_DIR/test/my_math/test_dec.mojo::test_dec_valid()>
    <ROOT_DIR/test/my_math/test_dec.mojo::test_dec_min()>
  <ROOT_DIR/test/my_math/test_inc.mojo>
    <ROOT_DIR/test/my_math/test_inc.mojo::test_inc_valid()>
    <ROOT_DIR/test/my_math/test_inc.mojo::test_inc_max()>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

生成JSON格式化输出


默认情况下,mojo test生成简洁、可读的输出。或者,通过包含——diagnostic-format JSON选项,可以生成更适合其他工具输入的JSON格式输出。

例如,可以在test_quickstart中运行测试。使用以下命令输出JSON格式的mojo文件:

mojo test --diagnostic-format json test_quickstart.mojo
  • 1

输出给出了每个测试和汇总结果的详细结果(请注意,本例在输出中省略了文件系统的完整路径):

{
  "children": [
    {
      "duration_ms": 60,
      "error": "Unhandled exception caught during execution",
      "kind": "executionError",
      "stdErr": "",
      "stdOut": "Error: At ROOT_DIR/test_quickstart.mojo:8:17: AssertionError: `left == right` comparison failed:\r\n   left: 1\r\n  right: 0\r\n",
      "testID": "ROOT_DIR/test_quickstart.mojo::test_inc_zero()"
    },
    {
      "duration_ms": 51,
      "error": "",
      "kind": "success",
      "stdErr": "",
      "stdOut": "",
      "testID": "ROOT_DIR/test_quickstart.mojo::test_inc_one()"
    }
  ],
  "duration_ms": 1171,
  "error": "",
  "kind": "executionError",
  "stdErr": "",
  "stdOut": "",
  "testID": "ROOT_DIR/test_quickstart.mojo"
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

您也可以为测试集合生成JSON输出。作为一个例子,考虑运行测试部分中显示的项目结构。下面的命令以JSON格式收集测试目录层次结构中定义的所有测试:

mojo test --diagnostic-format json --co test
  • 1

输出如下(注意,这个例子省略了文件系统的完整路径):

{
  "children": [
    {
      "children": [
        {
          "id": "ROOT_DIR/test/my_math/test_dec.mojo::test_dec_valid()",
          "location": {
            "endColumn": 5,
            "endLine": 5,
            "startColumn": 5,
            "startLine": 5
          }
        },
        {
          "id": "ROOT_DIR/test/my_math/test_dec.mojo::test_dec_min()",
          "location": {
            "endColumn": 5,
            "endLine": 9,
            "startColumn": 5,
            "startLine": 9
          }
        }
      ],
      "id": "ROOT_DIR/test/my_math/test_dec.mojo"
    },
    {
      "children": [
        {
          "id": "ROOT_DIR/test/my_math/test_inc.mojo::test_inc_valid()",
          "location": {
            "endColumn": 5,
            "endLine": 5,
            "startColumn": 5,
            "startLine": 5
          }
        },
        {
          "id": "ROOT_DIR/test/my_math/test_inc.mojo::test_inc_max()",
          "location": {
            "endColumn": 5,
            "endLine": 9,
            "startColumn": 5,
            "startLine": 9
          }
        }
      ],
      "id": "ROOT_DIR/test/my_math/test_inc.mojo"
    }
  ],
  "id": "ROOT_DIR/test/my_math"
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51

编写API文档测试


Mojo测试框架还支持测试文档字符串中的代码示例。这有助于确保API文档中的代码示例是正确的并且是最新的。

识别可执行代码


Mojo测试框架要求你明确指定要执行的代码块。

在Mojo文档字符串中,由标准三反引号分隔的代码块是仅用于显示的代码块。它出现在API文档中,但mojo test没有将其识别为测试,也没有尝试执行代码块中的任何代码。

""" Non-executable code block example.

The generated API documentation includes all lines of the following code block,
but `mojo test` does not execute any of the code in it.

  • 1
  • 2
  • 3
  • 4
  • 5

mojo test does NOT execute any of this code block

a = 1
print(a)

"""
  • 1

相比之下,以```开头的隔离代码块不仅出现在API文档中,而且mojo test将其视为可执行测试。如果代码抛出任何错误,则测试失败,否则测试通过。

""" Executable code block example.

The generated API documentation includes all lines of the following code block
*and* `mojo test` executes it as a test.

```mojo
from testing import assert_equals

b = 2
assert_equals(b, 2)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

“”"

有时,你可能想在测试中执行一行代码,但不想在API文档中显示这行代码。要实现这一点,请在代码行前加上%#前缀。例如,可以使用这种技术从文档中省略import语句和断言函数。

```python
""" Executable code block example with some code lines omitted from output.

The generated API documentation includes only the lines of code that do *not*
start with `%#`. However, `mojo test` executes *all* lines of code.

```mojo
%# from testing import assert_equal
c = 3
print(c)
%# assert_equal(c, 3)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

“”"

### 文档测试套件和范围界定
---
Mojo测试框架将每个文档字符串视为单独的测试套件。换句话说,单个测试套件可以对应于单个包、模块、函数、结构体、结构方法等的文档字符串。
​
给定文档字符串中的每个可执行代码块都是同一个测试套件的单个测试。mojo test命令按照测试套件在文档字符串中出现的顺序顺序执行测试。如果特定测试套件中的一个测试失败,则跳过同一测试套件中的所有后续测试。
​
测试套件中的所有测试都在相同的范围内执行,并且该范围内的测试执行是有状态的。这意味着,例如,在一个测试中创建的变量可以被同一个测试套件中的后续测试访问。

```python
""" Stateful example.

Assign 1 to the variable `a`:

```mojo
%# from testing import assert_equal
a = 1
%# assert_equal(a, 1)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

Then increment the value of a by 1:

a += 1
%# assert_equal(a, 2)
  • 1
  • 2

“”"

>测试套件范围不会嵌套。换句话说,模块的测试套件范围完全独立于模块中定义的函数或结构体的测试套件范围。例如,这意味着如果一个模块的测试套件创建了一个变量,那么该变量就不能被该模块中的函数测试套件访问。

### 文档测试标识符
---
文档测试标识符的格式是<path>@<test-suite>::<test>。这最好通过一个例子来解释。考虑运行测试部分中显示的项目结构。src目录下的源文件可能包含my_math包的文档字符串、utils。Mojo模块以及该模块中的各个函数。可以通过执行以下命令收集完整的测试列表:
```python
mojo test --co src
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

输出显示了目录、测试文件和单个测试的层次结构(注意,这个例子在输出中省略了文件系统的完整路径):

<ROOT_DIR/src/my_math>
  <ROOT_DIR/src/my_math/__init__.mojo>
    <ROOT_DIR/src/my_math/__init__.mojo@__doc__>
      <ROOT_DIR/src/my_math/__init__.mojo@__doc__::0>
      <ROOT_DIR/src/my_math/__init__.mojo@__doc__::1>
  <ROOT_DIR/src/my_math/utils.mojo>
    <ROOT_DIR/src/my_math/utils.mojo@__doc__>
      <ROOT_DIR/src/my_math/utils.mojo@__doc__::0>
    <ROOT_DIR/src/my_math/utils.mojo@inc(stdlib\3A\3Abuiltin\3A\3Aint\3A\3AInt).__doc__>
      <ROOT_DIR/src/my_math/utils.mojo@inc(stdlib\3A\3Abuiltin\3A\3Aint\3A\3AInt).__doc__::0>
      <ROOT_DIR/src/my_math/utils.mojo@inc(stdlib\3A\3Abuiltin\3A\3Aint\3A\3AInt).__doc__::1>
    <ROOT_DIR/src/my_math/utils.mojo@dec(stdlib\3A\3Abuiltin\3A\3Aint\3A\3AInt).__doc__>
      <ROOT_DIR/src/my_math/utils.mojo@dec(stdlib\3A\3Abuiltin\3A\3Aint\3A\3AInt).__doc__::0>
      <ROOT_DIR/src/my_math/utils.mojo@dec(stdlib\3A\3Abuiltin\3A\3Aint\3A\3AInt).__doc__::1>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

这个结果中出现了几个不同的测试套件:

Test suite scope File Test suite name
Package src/my_math/__init__.mojo __doc__
Module src/my_math/ __doc__
Function src/my_math/ inc(stdlib\3A\3Abuiltin\3A\3Aint\3A\3AInt).__doc__

然后在特定的测试套件中,测试按照它们在文档字符串中出现的顺序编号,从0开始。