用 Hypothesis 来自动化单元测试

2020-12-22 15:39:33 浏览数 (1)

高质量的代码离不开单元测试,而设计单元测试的用例往往又比较耗时,而且难以想到一些极端情况,本文讲述如何使用 Hypothesis 来自动化单元测试

刷过力扣算法题的同学都知道,有时候觉得代码已经很完善了,一提交才发现很多情况没有考虑到。然后感叹力扣的单元测试真的牛比。

因此,高质量的代码离不开单元测试,如果现在还没有写过单元测试,建议先去学习以下常用的单元测试库[1],只要实践过,才能感受到本文开头提到的那些痛点。

Hypothesis 是一个 Python 库,用于让单元测试编写起来更简单,运行时功能更强大,可以在代码中查找您不会想到的极端情况。它稳定,强大且易于添加到任何现有测试框架中。它的工作原理是让您编写断言每种情况都应该正确的测试,而不仅仅是您偶然想到的那些。

Hypothesis 的基础知识

典型的单元测试需要自己写一些测试用例,然后编写测试函数,通过一段代码运行它,然后根据预期结果检查结果。

Hypothesis 有所不同。它是基于属性进行单元测试。它通过生成与您的规范匹配的任意数据并检查在这种情况下程序是否仍然有效。如果找到了一个失败的用例,它将采用该示例并将其测试用例范围缩减缩减为一定尺寸,然后对其进行简化,直到找到一个仍会导致问题的小得多的示例。然后将其保存,后续单元测试时仍会使用这些用例。

现在就让我们看看怎么用吧。

Hypothesis 快速入门

1、安装

可以通过 pip 安装,也可以通过源代码安装[2],也可以安装一些扩展[3],如下:

代码语言:javascript复制
pip install hypothesis
pip install hypothesis[pandas,django]

2、使用

先写一段代码,保存在 mycode.py 中,功能是对字符串进行特定的编码和解码,内容如下:

代码语言:javascript复制
def encode(input_string):
    count = 1
    prev = ""
    lst = []
    for character in input_string:
        if character != prev:
            if prev:
                entry = (prev, count)
                lst.append(entry)
            count = 1
            prev = character
        else:
            count  = 1
    entry = (character, count)
    lst.append(entry)
    return lst


def decode(lst):
    q = ""
    for character, count in lst:
        q  = character * count
    return q

对这段代码进行单元测试,往往需要写很多测试用例,现在我们使用 hypothesis 来自动为我们测试,编写 test_mycode.py (文件名随意),内容如下:

代码语言:javascript复制
from hypothesis import given
from mycode import decode,encode
from hypothesis.strategies import text
import unittest


class TestEncoding(unittest.TestCase):
    @given(text())
    def test_decode_inverts_encode(self, s):
        self.assertEqual(decode(encode(s)), s)


if __name__ == "__main__":
    unittest.main()

可以看出,这里并没有出现具体的测试用例,而是使用来 text 的策略,相当于 hypothesis 自动穷举来可能的情况,也可以看出它很容易可其他测试框架集成,这里是 unittest。现在来运行一下看看效果:

代码语言:javascript复制
(py38env) ➜  tmp python test_mycode.py
Falsifying example: test_decode_inverts_encode(
    self=<__main__.TestEncoding testMethod=test_decode_inverts_encode>, s='',
)
E
======================================================================
ERROR: test_decode_inverts_encode (__main__.TestEncoding)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_mycode.py", line 9, in test_decode_inverts_encode
    def test_decode_inverts_encode(self, s):
  File "/Users/aaron/py38env/lib/python3.8/site-packages/hypothesis/core.py", line 1162, in wrapped_test
    raise the_error_hypothesis_found
  File "test_mycode.py", line 10, in test_decode_inverts_encode
    self.assertEqual(decode(encode(s)), s)
  File "/Users/aaron/tmp/mycode.py", line 14, in encode
    entry = (character, count)
UnboundLocalError: local variable 'character' referenced before assignment

----------------------------------------------------------------------
Ran 1 test in 0.048s

FAILED (errors=1)

这里测试出当字符串为 '' 的时候会抛出 UnboundLocalError 的异常。现在我们来修复这个 bug,然后把所有的测试用例 s 给打印出来,看看它用了哪些测试用例。

encode 函数加入以下代码:

代码语言:javascript复制
if not input_string:
    return []

test_mycode.py 文件打印出测试用例:

代码语言:javascript复制
@given(text())
def test_decode_inverts_encode(self, s):
    print(f"{s=}")
    self.assertEqual(decode(encode(s)), s)

再次执行:

代码语言:javascript复制
(py38env) ➜  tmp python test_mycode.py
s=''
s='1'
s='0'
s='0'
s='0'
s='Ā'
s='U000cf5e5'
s='0'
s=''
s='0'
s='0'
s='E'
s=")dù'x18U0003deb3¤jd"
s='U0005bc37x07U000537a1ÝÀãiÎU000ce9e5x0b'
s='U0005bc37U0005bc37U000537a1ÝÀãiÎU000ce9e5x0b'
s='U0005bc37U000537a1U000537a1ÝÀãiÎU000ce9e5x0b'
s='ÀU000537a1U000537a1ÝÀãiÎU000ce9e5x0b'
s='U000965e1x12x85&U000f500aÄÃc'
s='nU0004466cx86Îx07'
s='ÊU00063f1ex01Gx88'
s='ÚVn'
s='VVn'
s='U0008debf湆è'
s='U0008debf湆è'
s='U0008debf湆'
s='U0008debfU0008debf'
s='U0008debfU0008debfó]½àqx82#U00015196U0001c8beg'
s='U0008debfgó]½àqx82#U00015196U0001c8beg'
s='?'
s='Î'
s='ÎU00085b9e'
s="Î8'?U00057c38Ù;x07U000a5ea8Ò»=U00091d5b~8뺈"
s='U000d6497Ý>'
s='U000e0f01'
s='U000e0f01Å0y¢KN®'
s='U000e0f01Å0y¢KN®'
s='U00050a06'
s='ÅU000b98b3かU000ba80aá`Ã-Êux8cx90³FÔ"'
s='x8eU0004612ax83ç'
s='x8e'
s='x8ex98U000fb3e0U0010d2b3x10x82x94Ð渥'
s='¥W'
s='pU000e5a2aE·`ì'
s='U000b80f8x12U000c2d54'
s='.U000703de'
s='6U00010ffaU000f7994x8e'
s='116U000f7994x8e'
s='1?6U000f7994x8e'
s='4?6U000f7994x8e'
s='4x8e6U000f7994x8e'
s='0'
s='U0006a564´Ðx93üx9eb&ix1cÑ'
s='U000ceb6f'
s='U000ceb6fxa0x08'
s='U000ceb6fxa0x08'
s='U000ceb6fꄃx08'
s='U000ceb6fꄃ匀U0007cc15U000b2aaa×**'
s='U000ceb6fꄃ匀'
s='匀ꄃ匀'
s='Jx14?ö'
s='q)'
s='q)'
s='qU00060931'
s='q6'
s='U000e3441'
s='U000e3441U00019958¯'
s='x13'
s='U000f34dbk'
s='Kp&tÛà'
s='nöx93'
s='nnx93'
s='U00019c8dѳU00056cbdU000e3b2fU00058d302'
s='x90=Rx8bßx03'
s='x9a'
s='U000147e7'
s='U000147e7x85U0007a3ef'
s='U000147e7U00050a070Â>'
s='U000a4089x0eC RÁx02x97x9cüÌïSSU0006cbc5;ÿ~x16x019VÇU000a32fdQ÷x15'
s='ÞÚ¾x19©Z®'
s='ਸ਼æ'
s='U000cd45a'
s='U000cd45aU000e15cbÑx08Jueb3eúßx07Ix91x9ax18x16Çx80x1a'
s='x8f}ºx0eqx0b'
s='x0e}ºx0eqx0b'
s="U000e05a3&¶º[fõx8bÜR'ͼtx97íWx05U000caea9U0008fd74U000e8f1c¹?dfƾx13"
s='x10U000e12e2ùU0006f96erýU00014bafx00x95U000dbc92ÉU00081613µU0003b865ZU0008cc3c'
s='úU000b561fx8fÎ'
s='tàÖ÷'
s='àx92©ÌU000618fax92'
s='U000aaf94x94x84U000cda69U0005291aU000a63deþ¿Ox8a>U000b458bÊ.U00086f07x1a'
s='U0009754e?U_xa0x13PQx18ºx07U0006c9c5.Á'
s='U00102456'
s='³WᵎÕ'
s='x14x1c'
s='x14'
s='x14U00105bcd"x10Ôx99U000a5032RU00056c44V&÷> U000aaff2ñ®U000d7570%ª!U00032553´8x^«'
s='x00U000e2ac4¼ÄUrB'
s='x00U000e2ac4¼ÄUrB'
s='x00U000e2ac4¼ÄUrB'
s='ªx1aUx8aÇU000b2fb9U0005a586'
.
----------------------------------------------------------------------
Ran 1 test in 0.180s

OK

从执行结果可以看出,'' 首先被测试,其次 hypothesis 使用了大量的极端测试用例,减轻了手写的负担,大大提升了效率。

虽然 hypothesis 具有自动记忆功能,你仍然可以显式的指定某个测试用例一直被测试,而且这是推荐的做法,比如我想在每次的测试中都测试 '',可以这样写:

代码语言:javascript复制
from hypothesis import given, example
from hypothesis.strategies import text


@given(text())
@example("")
def test_decode_inverts_encode(s):
    assert decode(encode(s)) == s

这一点非常有用,提升了测试代码的可读性,可以用来告诉开发人员或者未来的自己,输入的字符串必须要考虑 '' 的情形。

此外,执行单元测试,不一定要使用 unittest.main(),也可以这样,是不是很方便:

代码语言:javascript复制
if __name__ == "__main__":
    test_decode_inverts_encode()

3、其他策略参考

代码语言:javascript复制
from hypothesis import given
import hypothesis.strategies as st


@given(st.integers(), st.integers())
def test_ints_are_commutative(x, y):
    assert x   y == y   x


@given(x=st.integers(), y=st.integers())
def test_ints_cancel(x, y):
    assert (x   y) - y == x


@given(st.lists(st.integers()))
def test_reversing_twice_gives_same_list(xs):
    # This will generate lists of arbitrary length (usually between 0 and
    # 100 elements) whose elements are integers.
    ys = list(xs)
    ys.reverse()
    ys.reverse()
    assert xs == ys


@given(st.tuples(st.booleans(), st.text()))
def test_look_tuples_work_too(t):
    # A tuple is generated as the one you provided, with the corresponding
    # types in those positions.
    assert len(t) == 2
    assert isinstance(t[0], bool)
    assert isinstance(t[1], str)

从哪里开始

以上仅仅是抛砖引玉,hypothesis 还有很多自动化的特性,不再一一列举,最好的学习方法是边做,边尝试。hypothesis 是一个开源项目,有着详细的官方文档[4],GitHub 仓库[5]这里都是你开启自动化测试的好地方:

最后,如果你觉得本文有用,请点赞、收藏、再看三连支持,感谢老铁。上次没抽完的,继续抽,这次就放 30 个吧,公众号后台回复或长按下方二维码回复「抽奖」即可获取抽奖链接。

参考资料

[1]

库: https://realpython.com/python-testing/

[2]

源代码安装: https://github.com/HypothesisWorks/hypothesis/blob/master/CONTRIBUTING.rst

[3]

扩展: https://hypothesis.readthedocs.io/en/latest/extras.html

[4]

官方文档: https://hypothesis.readthedocs.io/en/latest/quickstart.html#running-tests

[5]

GitHub 仓库: https://github.com/HypothesisWorks/hypothesis/

0 人点赞