基于性质测试

我们首先回到上一章的问题。函数式的代换原则有个最大的好处就是可以说是完全无态的(stateless),代码的顺序不会影响到执行的结果。这些特性都非常适合测试。而竟然函数式编程中的函数是一个标准的数学定义上的「函数」,那很多函数是具有一定性质的。比如我们之前遇到的compose函数,其实是符合结合律的,即

\[f · g · h = f · (g · h)\]

我们可以基于这个性质做一个测试,比如任意定义一些一元函数:

f = lambda x: x + 1
g = lambda x: x ** 2
h = lambda x: x - 3

我们可以不需要直接写出测试值,比如下面这个例子:

assert compose(f, g, h)(1) == 5

这种写法无可厚非,但是事实上会有以下几个问题,就是我们容易错过一些边界条件,并且很有可能我们在编造测试例的时候,就涉及大量计算,当测试没有通过的时候,我们也很难知道到底是我们在编写测试例时出错还是本身的代码实现有错。而对于一个函数式编程,我们发现了上面的一个函数特性,就可以直接写出下面的测试例子:

assert compose(f, compose(g, h))(1) == compose(compose(f, g), h)(1)

性质测试可能出现的问题

当然,性质测试可能遇到一些问题无法解决,最常见的问题就是,对于一个太过靠近业务的方法/函数,我们很难找到一个所谓的「性质」可以测试。

其次,某些性质,是一种「充分条件」。它不能保证原先的函数是不是成立,当然最好的方法就是找到一个充分必要条件,或者可以等价替换的性质作为测试例子。譬如,上面的例子中我们再增加一个I(x) = x的函数就基本上可以论证其「幺半群」的特征:

I = lambda x: x

assert compose(I, f)(1) == compose(f, I)(1)

最后,一些实现可能可以巧妙地避过测试,不过这本身不是问题,因为在一般的单元测试中,也有类似的问题。我们下一节会介绍通过自动生成例子的方式来规避这个问题。

自动生成测试例

我们上一节介绍了如何利用函数的性质作为测试的内容。我们可以发现,性质测试其实是对测试值不敏感的,只要符合定义的值都可以用来测试。这就告诫我们,我们可以实现一些生成测试例的方式,来直接测试,譬如我们在下面实现了两个生成随机例子的迭代器的函数:

from itertools import chain
from random import randint, choices
from string import ascii_letters, digits, punctuation, whitespace

all_chars = ascii_letters + digits + punctuation + whitespace

def int_generator(
        num=200,
        max_v=65535,
        min_v=-65535,
        must_have=[0]
    ):
    return chain(
        (randint(min_v, max_v) for _ in range(num - len(must_have))),
        must_have
    )
    
def str_generator(
        num=200,
        min_length=0,
        max_length=320,
        must_have=["", digits, punctuation, whitespace],
        choice_chars=all_chars
    ):
    return chain(
        (
            "".join(choices(choice_chars, k=randint(min_length, max_length)))
            for _ in range(num - len(must_have))
        ),
        must_have
    )

回到上面的例子,我们就可以直接调用生成的例子来测试,而不用想具体值。这个可以方式我们错过一些常用的边界条件,也可以防止一些实现可以巧妙地避过一些测试,当然这也很方便,我们不用思考寻找和计算测试例了。

for x in int_generator():
    assert compose(f, compose(g, h))(1) == compose(compose(f, g), h)(1)

def invert_str(x):
    return x[::-1]

for s in int_generator():
    assert invert_str(invert_str(s)) == s

Hypothesis和其他

最为出名的实现性质测试的模块是Haskell中的QuickCheck。当然,Python中也有个实现的第三方模块Hypothesis,里面内置了上面我们提到的自动生成测试例的工具,并能和pytest做非常好地连用,使得测试可以非常方便。

比如,我们实现了上面代码中翻转str的一个方法,并想通过特性invert_str(invert_str(s)) == s这个例子来完成测试,使用Hypothesis则可以用下面的方式完成:

from hypothesis import strategies as st, given


@given(s=s.text())
def test_invert_str(s):
    assert invert_str(invert_str(s)) == s

此外,hypothesis也提供了一些方便的组合方法,比如生成List[str]的方式,用st.list(st.text())即可。

当然,基于性质的测试也给了很多其他的启发,一个非常经典的例子就是checklist模块,它试图解决NLP领域的测试集生成的模块。它的思路在于,如果训练了一个NLP模块,它在特定领域是有效的,则它对一些我们人工生成的简单例句理应也是有效的。这个项目本身就是受到基于性质测试的启发,在此处的性质是模型预测某一类自然语句正确的能力,可以说是一个非常聪明的推广想法。

比如,如果一个NLP模型是为了判断评价是否正面,那么生成的句子I think it is great.肯定能判断出来其正确性。我们基于此,可以用不同的主语、不同的形容词,生成非常多的例子,来对一个模型做测试,譬如下面的例子:

from checklist.editor import Editor

editor = Editor()

ret = editor.template('This is {a:adj} movie.', adj=['good', 'great', 'awesome', 'excellent'])

此外,这个模块也提供了非常方便的Notebook内的可视化操作,可谓是相当方便。