软件测试的主要工作目标是验证实际结果与预期结果是一致的,在自动化软件测试中,通过断言来实现这一目的。Pytest 中断言是通过 Python 原生的 assert 语句实现的,对 Python 原生的 assert 语句进行了优化,当发生断言失败时,错误信息更加丰富,方便测试时快速定位问题原因。

正文字数 5195

保留所有权利,禁止转载

不管是做 API 测试、Web 测试还是 APP 测试中,测试用例是否执行成功,都是通过比较实际结果与预期结果是否一致来判断的。当预期结果与实际结果一致,则表示测试用例执行通过,当预期结果与实际结果不一致,则表示测试用例执行失败。对预期结果与实际结果进行比较的过程,在自动化软件测试中是通过断言来实现的。

优秀的测试框架都提供了断言的方法,比如 TestNG 中的 assertTrue、 assertEquals、assertSame 等等。前面给大家介绍过 Pytest 的使用方法《基于 Pytest 框架的自动化测试开发实践(万字长文入门篇)》,本文将详细介绍 Pytest 的断言,与 TestNG 相比它更加简单,只有一个 assert 语句,但是功能非常强大并且简单易用。

01 — Python 原生的 assert

Python 中 assert 语句通常用来对代码进行必要的检查,确定某种情况一定发生,或者一定不会发生。

Python 的 assert 语句的语法是这样的:

assert expression1 ["," expression2]

expression1 往往是一个条件表达式,如果条件表达式为 True,则什么也不做,相当于执行了 pass 语句;如果条件表达式为 False,便会抛出异常 AssertionError,并返回具体的错误信息 expression2。看一个实际例子:

# content of my_assertion.py
def assertion():
    assert [1, 2, 3] == [1, 2, 4], "left is [1,2,3], right is [1,2,4]"

if __name__ == '__main__':
    assertion()

执行一下上面的代码看看结果:

$ python my_assertion.py 
Traceback (most recent call last):
  File "my_assertion.py", line 5, in <module>
    assertion()
  File "my_assertion.py", line 2, in assertion
    assert [1, 2, 3] == [1, 2, 4], "left is [1,2,3], right is [1,2,4]"
AssertionError: left is [1,2,3], right is [1,2,4]

可见,assert 后面的条件表达式为 False,抛出了 AssertionError,并显示了错误信息 left is [1, 2, 3], right is [1, 2, 4]。

不过,这里还有一点小小的缺憾。并没有明确告诉开发人员,条件判断失败的具体位置。需要开发人员自己对比才发现,==左边的第三个元素和右边的第三个元素不一样。

02 — Pytest 的 assert 优点

软件测试工作,经常会遇到断言失败的情况。如果每次失败,都需要测试工程师人眼去观察失败的具体原因和出错的位置,那将是非常耗时的。强大的 Pytest 也考虑到了广大测试工程师面临的问题,因此对 Python 原生的 assert 语句进行了优化和改进,主要在是当断言失败时,将错误的具体信息和位置显示出来,让测试工程师对失败原因一目了然。

还是上面的例子,将其放入到测试用例(test_开头的函数)中:

# content of test_assertion.py
def test_assertion():
    assert [1, 2, 3] == [1, 2, 4], "left is [1,2,3], right is [1,2,4]"
执行测试用例后的信息输出如下

    def test_assertion():
>       assert [1, 2, 3] == [1, 2, 4], "left is [1,2,3], right is [1,2,4]"
E       AssertionError: left is [1,2,3], right is [1,2,4]
E       assert [1, 2, 3] == [1, 2, 4]
E         At index 2 diff: 3 != 4
E         Full diff:
E         - [1, 2, 4]
E         ?        ^
E         + [1, 2, 3]
E         ?

是不是有种很爽的感觉?pytest 明确显示出了错误的位置是 index 为 2 的元素左右不相等。这一点点小小的改进大大提高了测试失败时定位出错原因的效率。

在测试用例中执行 assert 语句,才有上面的效果,这是因为 Pytest 对 assert 语句进行了重写。在非测试用例中的 assert 语句,比如测试项目中的一些 utils 函数中,使用 assert 还是 Python 原生的效果。

03 — Pytest 断言的用法

在自动化测试用例中,最常用的断言是相等断言,就是断言预期结果和实际结果是一致的。通常我们断言的预期结果和实际结果的数据类型是字符串、元组、字典、列表和对象。Pytest 通过 assert 和==能够完美支持对这些数据类型的相等断言。下面来介绍几种常见的数据类型的断言操作。

4.1 断言字符串

断言字符串非常简单,只需要将预期和实际的字符串,分别写在==两边,当发生断言失败时,将会列出第一个不相等元素的下标。下面是几个在实际测试工作中经常用到的几种字符串断言方式。

# content of test_assertions.py
class TestAssertions(object):
    def test_string_1(self):
        assert "spam" == "eggs"

    def test_string_2(self):
        assert "foo 1 bar" == "foo 2 bar"

    def test_string_3(self):
        assert "foo\nspam\nbar" == "foo\neggs\nbar"

    def test_string_4(self):
        def f():
            return "streaming"
        assert f().startswith('S')

执行一下这些测试用例,看下输出效果,核心部分如下:

============================================================ FAILURES ============================================================
__________________________________________________ TestAssertions.test_string_1 __________________________________________________
​
self = <test_assertions.TestAssertions object at 0x10911a4d0>
​
    def test_string_1(self):
>       assert "spam" == "eggs"
E       AssertionError: assert 'spam' == 'eggs'
E         - eggs
E         + spam
​
tests/test_assertions.py:3: AssertionError
__________________________________________________ TestAssertions.test_string_2 __________________________________________________
​
self = <test_assertions.TestAssertions object at 0x10911a890>
​
    def test_string_2(self):
>       assert "foo 1 bar" == "foo 2 bar"
E       AssertionError: assert 'foo 1 bar' == 'foo 2 bar'
E         - foo 2 bar
E         ?     ^
E         + foo 1 bar
E         ?     ^
​
tests/test_assertions.py:6: AssertionError
__________________________________________________ TestAssertions.test_string_3 __________________________________________________
​
self = <test_assertions.TestAssertions object at 0x10911c2d0>
​
    def test_string_3(self):
>       assert "foo\nspam\nbar" == "foo\neggs\nbar"
E       AssertionError: assert 'foo\nspam\nbar' == 'foo\neggs\nbar'
E           foo
E         - eggs
E         + spam
E           bar
​
tests/test_assertions.py:9: AssertionError
__________________________________________________ TestAssertions.test_string_4 __________________________________________________
​
self = <test_assertions.TestAssertions object at 0x109106a90>
​
    def test_string_4(self):
        def f():
            return "streaming"

>       assert f().startswith('S')
E       AssertionError: assert False
E        +  where False = <built-in method startswith of str object at 0x1090f7bb0>('S')
E        +    where <built-in method startswith of str object at 0x1090f7bb0> = 'streaming'.startswith
E        +      where 'streaming' = <function TestAssertions.test_string_4.<locals>.f at 0x10914b440>()
​
tests/test_assertions.py:15: AssertionError

再次感觉到测试结果一目了然。

4.2 断言函数或者接口返回值

对函数返回值、接口返回值的断言,应该是软件自动化测试中最常见的场景了。这里以函数返回值的断言为例,

def test_function():
    def f():
        return [1, 2, 3]

    assert f() == [1, 2, 4]

执行这个测试用例,看下输出的错误信息:

============================================================ FAILURES ============================================================
_________________________________________________________ test_function __________________________________________________________

    def test_function():
        def f():
            return [1, 2, 3]

>       assert f() == [1, 2, 4]
E       assert [1, 2, 3] == [1, 2, 4]
E         At index 2 diff: 3 != 4
E         Full diff:
E         - [1, 2, 4]
E         ?        ^
E         + [1, 2, 3]
E         ?        ^

tests/test_assertions.py:22: AssertionError

可以看到,输出信息中包含了函数的返回值,并且显示了返回值与预期结果不一致的元素是 index 为 2 的元素。

4.3 断言集合类型

断言列表、元组、字典和集合等类型在测试中也是很常见的,对于具有嵌套的集合数据,pytest 的 assert 依然能够精确地显示出来出错的位置。比如下面这段测试用例代码:

class TestCollections(object):
    def test_dict(self):
        assert {"a": 0, "b": 1, "c": 0} == {"a": 0, "b": 2, "d": 0}

    def test_dict2(self):
        assert {"a": 0, "b": {"c": 0}} == {"a": 0, "b": {"c": 2}}

    def test_list(self):
        assert [0, 1, 2] == [0, 1, 3]

    def test_list2(self):
        assert [0, 1, 2] == [0, 1, [1, 2]]

    def test_set(self):
        assert {0, 10, 11, 12} == {0, 20, 21}

执行上面的测试代码,核心输出会是下面这样:

============================================================ FAILURES ============================================================
___________________________________________________ TestCollections.test_dict ____________________________________________________

self = <test_assertions.TestCollections object at 0x10b0d2d10>

    def test_dict(self):
>       assert {"a": 0, "b": 1, "c": 0} == {"a": 0, "b": 2, "d": 0}
E       AssertionError: assert {'a': 0, 'b': 1, 'c': 0} == {'a': 0, 'b': 2, 'd': 0}
E         Omitting 1 identical items, use -vv to show
E         Differing items:
E         {'b': 1} != {'b': 2}
E         Left contains 1 more item:
E         {'c': 0}
E         Right contains 1 more item:
E         {'d': 0}...
E         
E         ...Full output truncated (6 lines hidden), use '-vv' to show

tests/test_assertions.py:27: AssertionError
___________________________________________________ TestCollections.test_dict2 ___________________________________________________

self = <test_assertions.TestCollections object at 0x10b0d2a90>

    def test_dict2(self):
>       assert {"a": 0, "b": {"c": 0}} == {"a": 0, "b": {"c": 2}}
E       AssertionError: assert {'a': 0, 'b': {'c': 0}} == {'a': 0, 'b': {'c': 2}}
E         Omitting 1 identical items, use -vv to show
E         Differing items:
E         {'b': {'c': 0}} != {'b': {'c': 2}}
E         Full diff:
E         - {'a': 0, 'b': {'c': 2}}
E         ?                     ^
E         + {'a': 0, 'b': {'c': 0}}...
E         
E         ...Full output truncated (2 lines hidden), use '-vv' to show

tests/test_assertions.py:30: AssertionError
___________________________________________________ TestCollections.test_list ____________________________________________________

self = <test_assertions.TestCollections object at 0x10b0c1190>

    def test_list(self):
>       assert [0, 1, 2] == [0, 1, 3]
E       assert [0, 1, 2] == [0, 1, 3]
E         At index 2 diff: 2 != 3
E         Full diff:
E         - [0, 1, 3]
E         ?        ^
E         + [0, 1, 2]
E         ?        ^

tests/test_assertions.py:33: AssertionError
___________________________________________________ TestCollections.test_list2 ___________________________________________________

self = <test_assertions.TestCollections object at 0x10b0d6c10>

    def test_list2(self):
>       assert [0, 1, 2] == [0, 1, [1, 2]]
E       assert [0, 1, 2] == [0, 1, [1, 2]]
E         At index 2 diff: 2 != [1, 2]
E         Full diff:
E         - [0, 1, [1, 2]]
E         ?        ----  -
E         + [0, 1, 2]

tests/test_assertions.py:36: AssertionError
____________________________________________________ TestCollections.test_set ____________________________________________________

self = <test_assertions.TestCollections object at 0x10b0c1a50>

    def test_set(self):
>       assert {0, 10, 11, 12} == {0, 20, 21}
E       AssertionError: assert {0, 10, 11, 12} == {0, 20, 21}
E         Extra items in the left set:
E         10
E         11
E         12
E         Extra items in the right set:
E         20
E         21...
E         
E         ...Full output truncated (4 lines hidden), use '-vv' to show

tests/test_assertions.py:39: AssertionError

可以看到对于嵌套的字典和列表,也能显示出不一致数据的具体位置。对于过长的数据,默认是会被 truncated,可以通过-vv 显示全部信息。

除了相等断言,还可以进行大于、小于、不等于、in/not in 等类型的断言。

对于对象的断言,可以进行对象的类型断言、对象本身的断言。这里就不在一一举例,只要记住断言是使用 assert 语句,使用方法与在 Python 语言中的使用方法完全一致就可以了。

更多断言的例子,大家可以参考 Pytest 的官方文档:https://docs.pytest.org/en/latest/example/reportingdemo.html44 个断言的例子,非常全面,几乎涵盖了所有的相等断言的场景。,这里一共有

04 — Pytest 断言 Excepiton

除了支持对代码正常运行的结果断言之外,Pytest 也能够对 Exception 和 Warnning 进行断言,来断定某种条件下,一定会出现某种异常或者警告。在功能测试和集成测试中,这两类断言用的不多,这里简单介绍一下。

对于异常的断言,Pytest 的语法是:with pytest.raises(异常类型),可以看下面的这个例子:

def test_zero_division():
    with pytest.raises(ZeroDivisionError):
        1 / 0

这个测试用例断言运算表达式 1 除以 0 会产生 ZeroDivisionError 异常。除了对异常类型进行断言,还可以对异常信息进行断言,比如:

def test_zero_division():
    with pytest.raises(ZeroDivisionError) as excinfo:
        1 / 0
    assert 'division by zero' in str(excinfo.value)

这个测试用例,就断言了 excinfo.value 的内容中包含 division by zero 这个字符串,这在需要断言具体的异常信息时非常有用。

对于 Warnning 的断言,其实与 Exception 的断言的用法基本一致。这里就不介绍了,关于更多的 Exception 和 Warnning 的断言可以参考 Pytest 的官方文档https://docs.pytest.org/en/latest/assert.html#assertions-about-expected-exceptions

05 — 为断言添加自定义功能

通过前面的介绍,感觉 Pytest 的 assert 挺完美了,又简单又清晰。但是在实际的测试工作中,还会遇到一些实际问题,比如在断言时,最好【自动】添加一些日志,避免我们在测试代码中手动加入日志。还有,最好能将断言的信息,【自动】集成到一些测试报告中,比如 Allure 中(关于 Allure 报告大家可以看之前的文章《用 Pytest+Allure 生成漂亮的 HTML 图形化测试报告》)。这样就能避免在每一个测试脚本中手动写很多重复的代码,从而让我们将更多的时间和精力放到编写测试用例上。

有了这样的想法,接下来看看如何实现。

Pytest 中提供了一个 Hook 函数 pytest_assertrepr_compare,这个函数会在测试脚本的 assert 语句执行时被调用。因此,可以实现这个函数,在函数中添加写日志和集成 allure 测试报告代码。

完整的代码如下所示:

# content of conftest.py
def pytest_assertrepr_compare(config, op, left, right):
    left_name, right_name = inspect.stack()[7].code_context[0].lstrip().lstrip('assert').rstrip('\n').split(op)
    pytest_output = assertrepr_compare(config, op, left, right)
    logging.debug("{0} is\n {1}".format(left_name, left))
    logging.debug("{0} is\n {1}".format(right_name, right))
    with allure.step("校验结果"):
        allure.attach(str(left), left_name)
        allure.attach(str(right), right_name)
    return pytest_output

通过 inspect 获取调用栈信息,从中得到测试脚本中 assert 语句中 op 操作符两边的字符串名称,在日志和测试报告中会用到。接着执行 assertrepr_compare 输出错误详细信息,这些信息就是在执行断言失败时的输出内容,pytest_assertrepr_compare 函数没有对其做任何修改。接着添加了 debug 日志输出和 allure 测试报告的内容,最后再将 assert 的错误信息返回给调用处。

实现了这个函数后,测试脚本不需要做任何修改,依然是直接使用 assert 进行断言。但是能够自动记录日志和生成 allure 测试报告了。

06 — 禁止 Pytest 的 assert 特性

如果不想要 Pytest 中的 assert 的效果,而是希望保持 Python 原生的 assert 效果,只需要在执行测试是指定一个选项:

--assert=plain

这样所有测试用例中的 assert 都变成了 Python 原生的 assert 效果了,如果只想某一个模块保持 Python 原生的 assert 效果,那么就在对应模块的 docstring 中添加 PYTEST_DONT_REWRITE 字符串就好了,也就是在 py 文件的最上面添加类似下面的 docstring 内容:

"""
Disable rewriting for a specific module by adding the string:
PYTEST_DONT_REWRITE
"""

不过,我想应该没有人会这么干,因为 Pytest 的 assert 还是更好用一些。

07 — 总结

本文对比了 Python 原生的 assert 与 Pytest 中的 assert 的区别,详细介绍了 Pytest 中 assert 的用法,并根据测试工作的实际需求,演示了如何通过 pytest_assertrepr_compare 这个 Hook 函数在断言时增加日志和报告输出。希望对你有帮助。

参考资料

[1] https://morioh.com/tutorials

[2] https://docs.pytest.org/en/latest/example/reportingdemo

思维导图版
pytest断言详解


↙↙↙阅读原文可查看相关链接,并与作者交流