1 前言

1.1 test case的局限

想要更好地理解什么是 Property based testing, 就来先看下已有 test case 的局限,再来观察它解决了什么问题。

用之前《测试技能进阶(二): Parameterized Tests》中计算折扣的函数为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def calculate_discount(price, discount_percentage):
    if price < 0:
        raise ValueError(f"Price must be greater than zero: {price}")
    if discount_percentage < 0:
        raise ValueError(f"Discount_percentage must be greater than zero: {discount_percentage}")
    if price > 50000:
        return price - (price * (discount_percentage * 1.15) / 100)
    elif price > 100000:
        return price - (price * (discount_percentage * 1.18) / 100)
    else:
        return price - (price * discount_percentage / 100)

即使我们使用了 Parameterized Test, 把测试逻辑和测试数据集作了分离,但是还是有两个缺点:

  1. 我们的测试数据集还是要手工构造,即使现在不需要写新的 test case, 手工构造数据集还是很麻烦
  2. 第二个问题更严重,就是我们的构建的数据集可能不是完备的,如果数据集没有办法覆盖所有的条件分支,那我们仍然可能发现不了代码中的Bug

2 Property Based Testing

而 Property Based Testing 就是想解决这个问题,它希望可以结合人脑对特定问题域的理解和机器的运算能力,使用更少的时间来生成更优的测试case.

Property Based Testing 这个概念是由 Haskell 项目 QuickCheck 1在1999年引入的,它的理念是,程序员应该只定义某个测试case, 参数需要满足的标准(specification), 然后程序就会自动生成大量满足这个标准的随机数,用这些随机数来测试这个 test case。

而因为测试数据是随机生成的,所以你意料之内的数据,或者意料之外的数据都会被用来测试, 既省去了费时费力构造不同数据作数据集来测试的烦恼,又能保证数据集的完备性, 经常可以帮助你发现意想不到的bug.

这就是声明式定义的一种,你只需要声明你想干什么(用什么样的数据测试什么函数),而非命令式定义(你需要定义你要怎么做).

人力应该是很珍贵,而机器的计算资源却是很便宜,应该让机器代替人去做生成数据的事。

举例来说, 以上面的 calculate_discount 函数为例,如果我们告诉程序, pricediscount_percentage 应该是整数(specification), 那么 Quickcheck 就会生成各种整数, 从 Integer.Min 到 Integer.Max 不等,用来测试我们的程序.

如果还是觉得这个概念比较抽象,可以来看下具体的例子:

3 Hypothesis

Python Property Based Testing的测试框架叫 Hypothesis 2(假想),这个项目名字也是起得非常有水平,结合Property Based Testing的哲学,可谓信雅达.

假设我们现在要实现一个简单的数据压缩的算法: Run-length Encoding 3(RLE),通常用于压缩包含连续重复数据的序列, 这种编码方法特别适用于那些有大量重复字符或值的数据.

它的基本原理是:

  1. 统计连续重复的数据元素的数量。
  2. 用一个计数值和数据值的组合来替代这些重复的数据。

比如字符串: AABBBCCCC, RLE 编码后: 2A3B4C. 2A 表示两个连续的 A, 3B 表示三个连续的 B, 4C 表示四个连续的 C

Python实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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

如果我们的代码实现没有问题的话,对于任意的字符串,编码后的字符串,解码后的结果应该和原来的字符串一致的,这个就是我们的测试逻辑:

1
2
3
4
5
6
7
from hypothesis import given
from hypothesis.strategies import text


@given(text()) # 入参的标准是:任意的字符串,hypothesis 框架就会自动生成随机数,并调用test_decode_inverts_encode
def test_decode_inverts_encode(s):
    assert decode(encode(s)) == s

使用 pytest 运行上面的用例,结果如下:

 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
> pytest property_based_testing.py
=================================== test session starts ====================================
platform darwin -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0
rootdir: /Users/ramsayleung/code/python/test_technique
plugins: hypothesis-6.115.0
collected 1 item

property_based_testing.py F                                                          [100%]

========================================= FAILURES =========================================
________________________________ test_decode_inverts_encode ________________________________

@given(text())
>   def test_decode_inverts_encode(s):

property_based_testing.py:29:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
property_based_testing.py:30: in test_decode_inverts_encode
assert decode(encode(s)) == s
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

input_string = ''

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)
                       E       UnboundLocalError: cannot access local variable 'character' where it is not associated with a value
                       E       Falsifying example: test_decode_inverts_encode(
                           E           s='',
                           E       )

                       property_based_testing.py:17: UnboundLocalError
                       ================================= short test summary info ==================================
                       FAILED property_based_testing.py::test_decode_inverts_encode - UnboundLocalError: cannot access local variable 'character' where it is not associated ...
                       ==================================== 1 failed in 0.14s =====================================

可以看到,当 input_string ='' 是空字符串的时候, encode 函数抛出异常了,说 character 变量未定义。原来是 encode 函数没有对空字符串这个 corner case 作处理,那么就加个判断条件,修复一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def encode(input_string):
    if not input_string:
        return []
    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

既然我们知道空字符串是个特殊的 case, 因为 hypothesis 生成的都是任意的随机数,不一定每次都会测到空字符串,那我们就自己指定一个 case:

1
2
3
4
5
6
7
from hypothesis import example, given, strategies as st


@given(st.text())
@example("") # 手工指定空字符串这个 corner case
def test_decode_inverts_encode(s):
    assert decode(encode(s)) == s

pytest 重新运行,测试就通过了。但是,对 hypothesis 框架还没有建立信心的你我就不确定,它是否真的生成很多随机来运行这个 test case 呢?

有两个方法可以验证:

方法一:最简单粗暴的方式,把 s 变量给打印出来,毕竟眼见为实:

1
2
3
4
5
@given(st.text())
@example("")
def test_decode_inverts_encode(s):
    print(s)
    assert decode(encode(s)) == s

然后通过 pytest -s 参数要求 pytest 将写入到 stdout 的内容给打印出来

 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
> pytest property_based_testing.py -s
======================================= test session starts =======================================
platform darwin -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0
rootdir: /Users/ramsayleung/code/python/test_technique
plugins: hypothesis-6.115.0
collected 1 item

property_based_testing.py

O


񢄏«
𥛗Îbó
𜆮å
񰘰9
gah󭾔𛧁

i򼯜+ó»򮩸b񝕨
S!ÕTå&𰵩í¤ýäó÷F
øôyµ
Ī
sLz$ï
_𠵈
Ü
A

R󃝷{©¾

   ìõ
   æ􂐛BÝ1*􅄢ëóg𮎈¼ ?𩓁
   Òör @PP􎾂ö񳱊ûÁ½¬HÈ6#
   a𣽗¶󿅌𧑁x~󗜬韹ûð󴯮#Z󅖫𳖅ûf>
   i
   ....
   ======================================== 1 passed in 0.15s ========================================

这一堆都是什么字符呢, 都乱码了。

毕竟我们告诉 hypothesis 框架的是,我们参数接受的标准是任意的字符串, hypothesis 就非常尽职地帮我们生成了各种字符串,这个测试数据集可比我们自己手工构建的范围大得多,这就是 property based testing 的优势所在.

第二种方法是使用 hypothesis 框架提供的命令行参数 =–hypothesis-show-statistics=,用于打印统计信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
> pytest property_based_testing.py --hypothesis-show-statistics
======================================= test session starts =======================================
platform darwin -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0
rootdir: /Users/ramsayleung/code/python/test_technique
plugins: hypothesis-6.115.0
collected 1 item

property_based_testing.py .                                                                 [100%]
====================================== Hypothesis Statistics ======================================

property_based_testing.py::test_decode_inverts_encode:

- during generate phase (0.03 seconds):
- Typical runtimes: < 1ms, of which < 1ms in data generation
- 100 passing examples, 0 failing examples, 0 invalid examples

- Stopped because settings.max_examples=100


======================================== 1 passed in 0.05s ========================================

上面运行了 100 条数据,如果你觉得还想跑更多,可以通过 settings 装饰器指定更多:

1
@settings(max_examples=500)

4 Quickcheck & Proptest

而在Rust生态,就有两个 Property Based Testing 的库,一个是由Rust社区知名开发者,ripgrep 4和 regex 库作者移植自 Haskell Quickcheck 库的 quickcheck 5(名字也一并移植了), 另外一个是思路继承自 Python Hypothesis 的 Proptest 6(这位直接用property based testing技术来命名了,不得不说,命名真的是门艺术)

两者的社区接受度都相差无几(star, 使用者数量), 而在公司内部,我也发现 quickcheck 和 proptest 都有人用,坐我旁边的Principle Engineer 用的是 proptest, 而另外一个现在和我共事的同事,她的之前团队用的就是 quickcheck,看到都势均力敌嘛。

翻开 quickcheck 和 proptest 的API 文档之后,我发现我更喜欢 quickcheck 的接口风格,虽说它的活跃度更低一些,我最后还是选择了使用 quickcheck.

下面就来介绍一下我在Rust上使用 quickcheck 的心得:

假设我们现在有一个可以反转列表的函数 reverse:

1
2
3
4
5
6
7
fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
    let mut rev = vec!();
    for x in xs {
        rev.insert(0, x.clone())
    }
    rev
}

对于任意类型的列表,反转之后再反转的结果,肯定是和原结果一样的,那么我们就可以开始声明我们的标准(specification), 那就是任意的列表,可以是字符串列表,整型列表或者是其他的结构体列表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#[cfg(test)]
mod tests{
    use quickcheck_macros::quickcheck;
    use crate::reverse;

    #[quickcheck]
    fn double_reversal_is_identity_isize(xs: Vec<isize>) -> bool {
        xs == reverse(&reverse(&xs))
    }

    #[quickcheck]
    fn double_reversal_is_identity_string(xs: Vec<String>) -> bool {
        xs == reverse(&reverse(&xs))
    }
}

Rust 的unit test 是不支持带参数的,=#[quickcheck]= 这个宏就会自动将 double_reversal_is_identity_isize 转换成 property based test case, 而得益于Rust的类型系统, quickcheck 就能推断出入参就是我们声明的标准 Vec<isze>, 任意 isize 类型的数组.

4.1 Struct with quickcheck

如果上面的例子觉得过于简单的话,现在就让我们看个复杂一点的例子, 一个简单的图书管理系统,支持会员,借书,还书功能:

 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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
use chrono::{Duration, NaiveDate};
use std::collections::HashMap;

#[derive(Debug, Clone, PartialEq)]
struct Book {
    isbn: String,
    title: String,
    author: String,
    publication_year: u16,
}

#[derive(Debug, Clone, PartialEq)]
struct Member {
    id: u32,
    name: String,
    email: String,
}

#[derive(Debug, Clone)]
struct Loan {
    book_isbn: String,
    member_id: u32,
    due_date: NaiveDate,
}

#[derive(Debug, Clone)]
struct Library {
    books: HashMap<String, Book>,
    members: HashMap<u32, Member>,
    loans: Vec<Loan>,
    current_date: NaiveDate,
}

impl Library {
    fn new(current_date: NaiveDate) -> Self {
        Library {
            books: HashMap::new(),
            members: HashMap::new(),
            loans: Vec::new(),
            current_date,
        }
    }

    fn add_book(&mut self, book: Book) -> Result<(), String> {
        if self.books.contains_key(&book.isbn) {
            Err("Book with this ISBN already exists".to_string())
        } else {
            self.books.insert(book.isbn.clone(), book);
            Ok(())
        }
    }

    fn add_member(&mut self, member: Member) -> Result<(), String> {
        if self.members.contains_key(&member.id) {
            Err("Member with this ID already exists".to_string())
        } else {
            self.members.insert(member.id, member);
            Ok(())
        }
    }

    fn loan_book(&mut self, book_isbn: &str, member_id: u32) -> Result<(), String> {
        if !self.books.contains_key(book_isbn) {
            return Err("Book not found".to_string());
        }
        if !self.members.contains_key(&member_id) {
            return Err("Member not found".to_string());
        }
        if self.loans.iter().any(|loan| loan.book_isbn == book_isbn) {
            return Err("Book is already on loan".to_string());
        }
        let due_date = self.current_date + Duration::days(14);
        self.loans.push(Loan {
            book_isbn: book_isbn.to_string(),
            member_id,
            due_date,
        });
        Ok(())
    }

    fn return_book(&mut self, book_isbn: &str) -> Result<(), String> {
        if let Some(index) = self
            .loans
            .iter()
            .position(|loan| loan.book_isbn == book_isbn)
        {
            self.loans.remove(index);
            Ok(())
        } else {
            Err("Book is not currently on loan".to_string())
        }
    }
}

通过上面的简单代码,就实现了新增图书,新增会员,借书,和还书功能。现在就让我们来结合 quickcheckArbitrary 接口,实现生成任意的图书和会员,以便用于测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use quickcheck::{Arbitrary, Gen};
impl Arbitrary for Book {
    fn arbitrary(g: &mut Gen) -> Self {
        Book {
            // isbn必须以`ISBN` 开头,后接任意的大于等于0,小于uint32.max_value
            // 的整型
            isbn: format!("ISBN-{}", u32::arbitrary(g)),
            title: String::arbitrary(g), // 任意的字符串
            author: String::arbitrary(g), // 任意的字符串
            publication_year: *g.choose(&[2014_u16, 2022_u16, 2025_u16]).unwrap(), // 2014,2022或2025年出版的书
        }
    }
}

impl Arbitrary for Member {
    fn arbitrary(g: &mut Gen) -> Self {
        Member {
            id: u32::arbitrary(g), // 任意大于0,小于uint32.max_value的整型
            name: String::arbitrary(g), // 任意字符串
            // 任意字符开头, 以@example.com 结尾的字符
            email: format!("{}@example.com", String::arbitrary(g)),
        }
    }
}

现在就让我们来看下借助 quickcheck 编写的 test case, 注意参数为 BookMember 类型的 case, quickcheck 就会以我们上面定义的标准,自动给我们生成符合规定的 BookMember 参数.

 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
#[cfg(test)]
mod tests {
    use chrono::NaiveDate;
    use quickcheck_macros::quickcheck;

    use crate::book::Member;

    use super::{Book, Library};

    #[quickcheck]
    fn adding_book_increases_book_count(book: Book) -> bool {
        let mut library = Library::new(NaiveDate::from_ymd_opt(2024, 10, 14).unwrap());
        let initial_count = library.books.len();
        library.add_book(book.clone()).unwrap();
        library.books.len() == initial_count + 1 && library.books.contains_key(&book.isbn)
    }

    #[quickcheck]
    fn cannot_loan_nonexistent_book(book_isbn: String, member_id: u32) -> bool {
        let mut library = Library::new(NaiveDate::from_ymd_opt(2024, 10, 14).unwrap());
        library.loan_book(&book_isbn, member_id).is_err()
    }

    #[quickcheck]
    fn can_return_loaned_book(book: Book, member: Member) -> bool {
        let mut library = Library::new(NaiveDate::from_ymd_opt(2024, 10, 14).unwrap());
        library.add_book(book.clone()).unwrap();
        library.add_member(member.clone()).unwrap();
        library.loan_book(&book.isbn, member.id).unwrap();
        library.return_book(&book.isbn).is_ok()
    }
}

通过 quickcheck 我们就可以只专注测试逻辑,可以假定测试数据集是完备的了。可能看到 BookMember, 你会觉得 quickcheck 并没有做太多事情,你手工也可以构造。

但是我在的实际工作中,我就需要构造一个超过23个成员变量的 struct, 大部分还是 optional, 然后需要将这个 struct 写入到 parquet 文件,然后再测试读取逻辑。 不同成员变量的值可取的范围实在太多了,再叠加上 optional 的可能性,构造数据的代码写得相当恶心.

所以有了 quickcheck 之后,我只需要为这个 struct 实现 Arbitrary 接口,剩下的就由 quickcheck 替我生成,所以我直接和PE大佬说:

property test saves me life, now I couldn’t live without it.

5 总结

本来想抒发感想写点结语,但是看到 Hypothesis 作者写的 The purpose of Hypothesis7 来说明他开发的 Hypothesis 的动机,他的文章甚至用来给这个《测试技能进阶》系列总结都相当妥当。

我就试翻译下他文章的部分段落, 更推荐阅读原文,可谓是用心良苦,字字珠玑:

请容我狂妄一下,Hypothesis 的目标是希望可以让这个世界迈进到一个全新,由高质量软件打造的新世代。

正如人们所说,软件正在吞噬整个世界。但软件本身却很烂,它充满bug,又不安全,还经常被设计得很烂,这样的软件可谓是万恶之源.

而软件测试的状况甚至更糟糕,虽然大家都认同应该对代码进行测试,但是你能问心无愧地说,你经手过的代码都有被充分测试么?

问题在于,实在是太难写出好的测试了, 你写测试用例的时候,通常持有和你写代码时一样的假设与误区,你写的测试用例自然无法发现你当初埋下的bug (精辟)

与此同时,有各种各样让测试变成更好的工具却基本无人使用,最初的 Quickcheck 是1999年推出的,但是大多数开发者甚至从未听说过它,更别提使用了(开山始祖的Quickcheck在GitHub只有700多个Star,就知道作者所言不虚)。 虽然其他语言有些半成品的实现,但是大部分都不值得一试。

而 Hypothesis 的目标正是正本清源,把先进的测试技术传递给大众,并提供一个高质量的实现,让人们可以接纳它。

希望可以集百家之所长,附以个人微薄之力,让软件测试变得更好。

系列文章: