1 前言

测试技巧具有普适性,大多是与语言无关的,只是不同语言的生态可能对测试技术的支持各不一样, 比如Python和Java,基本什么库都有,而像C++,有顺手的单元测试和Mock库能用就很不错了。

因为Python比较适合写POC(proof of concept), 而我日常工作的语言是Java+Rust,所以我会穿插着引用这三种语言。

2 Parameterized Test

在介绍 Parameterized Test 之前,让我们先来看个简单的计算价格与折扣的函数(实际的生产代码肯定会更复杂,但是背后的思路是相通的):

1
2
def calculate_discount(price, discount_percentage):
    return price - (price * discount_percentage / 100)

针对这个函数,我们可能会编写多个 test case, 比如价格是 100, 给10%的折扣; 价格是200, 给20%的折扣; 价格是50, 给0的折扣;还有异常case,比如价格为负数的时候,或者折扣为负数的时候.

2.1 单个 test case

对于这么多的 case, 一个简单粗暴的方式就是把所有的 case 都写在一个 test case 里:

1
2
3
4
5
6
7
8
9
import pytest
def test_calculate_discount():
    # happy path
    assert calculate_discount(100, 10) == 90
    assert calculate_discount(200, 20) == 160
    assert calculate_discount(50, 0) == 50
    # unhappy path
    # assert calculate_discount(-2, 10)
    # assert calculate_discount(10, -2)

但是这样的做法一般是不推荐的,Best Practice是一个 test case 只测一种情况,因为如果一个 test case 包含多个测试条件,如果 test case fail 了,那么不看源码或者堆栈,一般还看不出是什么 case 失败了,不好排查。

2.2 多个 test case

推荐做法就是每个测试条件定个单独的 test case。

另外我们通过test case发现上面的代码没有处理异常情况,我们现在要优化下我们的代码,增加异常处理逻辑(这个就是TDD所推崇的开发哲学, test case 先行,通过test case发现问题,让test case fail掉,然后修正业务逻辑,test case再运行通过).

 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
import pytest

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}")
    return price - (price * discount_percentage / 100)

class TestClassCalculateDiscount:
    # happy path
    def test_calculate_discount_with_10_discount_percentage(self):
        assert calculate_discount(100, 10) == 90

    def test_calculate_discount_with_20_discount_percentage(self):
        assert calculate_discount(200, 20) == 160

    def test_calculate_discount_with_0_discount_percentage(self):
        assert calculate_discount(50, 0) == 50

    # unhappy path
    def test_calculate_discount_with_negative_price(self):
        with pytest.raises(ValueError):
            assert calculate_discount(-2, 10)

    def test_calculate_discount_with_negative_discount(self):
        with pytest.raises(ValueError):
            assert calculate_discount(10, -2)

代码的确是整洁易读了,但话虽如此,我们要多写了很多的 test case.

如果 calculate_discount 变得更复杂,我们要写的 test case 肯定是更多更复杂,总不能都 copy-paste test case吧。

2.3 Parameterized Test

话题就回到 Parameterized Test 了, 它就是用来解决这个问题的,它可以让你用不同的测试数据集会运行相同的测试逻辑. 还是以上面的代码为例子,你会发现 test_calculate_discount_with_10_discount_percentagetest_calculate_discount_with_20_discount_percentage 的测试逻辑是完全一样的,但只是数据集不同,所以我们就可以使用 Parameterized Test 来优化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import pytest

class TestClassCalculateDiscount:

    # Parameterized test for valid cases (happy path)
    @pytest.mark.parametrize("price, discount, expected", [
        (100, 10, 90),
        (200, 20, 160),
        (50, 0, 50)
    ])
    def test_calculate_discount(self, price, discount, expected):
        assert calculate_discount(price, discount) == expected

    # Parameterized test for invalid cases (unhappy path)
    @pytest.mark.parametrize("price, discount", [
        (-2, 10),   # Invalid price
        (10, -2)    # Invalid discount percentage
    ])
    def test_calculate_discount_invalid_cases(self, price, discount):
        with pytest.raises(ValueError):
            calculate_discount(price, discount)

其实就是把测试逻辑和数据进行了分离,后面需要测试新的数据集,只需要向数据集里面添加数据即可。

由此可见,使用 Parameterized Test 有几个显而易见的好处:

首先是减少代码冗余,不需要类似的代码 copy-paste 很多次;其次是方便提到测试覆盖率,这个在上面的例子可能不明显,我们可以再修改一下 calculate_discount 函数,增加两个分支:

 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)

价格超过50000, 在已有折扣基础上,再额外给折扣的15%作为折扣;价格超过100000,在已有折扣的基础上,再额外给折扣的18%作为折扣. 如果要覆盖这两个新的分支,只需要在数据集上添加大于50000 和大于100000的数据集,就可以直接覆盖到了.

1
2
3
4
5
6
7
8
9
@pytest.mark.parametrize("price, discount, expected", [
    (100, 10, 90),
    (200, 20, 160),
    (50, 0, 50),
    (50001, 10, 44250.885),
    (100001, 10, 88500.885)
])
def test_calculate_discount(self, price, discount, expected):
    assert calculate_discount(price, discount) == expected

然后测试这段代码的时候,我又发现一个新的问题,这里的价格变成浮点数后,没有作小数点后几位的取整。

(对于这样简单的函数,也能不断地通过写 test case 发现新问题,这无疑就是 test case 最大的价值所在了)

使用 Parameterized Test 还可以提高测试代码的可读性和可维护性,这部分内容还是显而易见的,就不展开了。

2.4 Junit

在Java的测试生态中,Junit是毫无疑问的龙头大哥,而在Junit5 ,Junit也引入了对 Parameterized Test 的支持,通过 @ParameterizedTest 这个枚举就可以将某个 test case 标注成 Parameterized Test, 通过 @ValueSource 传入待测试数据集:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class Numbers {
    public static boolean isOdd(int number) {
        return number % 2 != 0;
    }
}

@ParameterizedTest
@ValueSource(ints = {1, 3, 5, -3, 15, Integer.MAX_VALUE}) // six numbers
void isOdd_ShouldReturnTrueForOddNumbers(int number) {
    assertTrue(Numbers.isOdd(number));
}

这只是最基本的用法,Junit还支持通过函数,枚举,CSV格式甚至文件来传入待测试数据集,可谓是包罗万有,具体的用法可以参考这篇文章:Guide to JUnit 5 Parameterized Tests1Junit官方文档 2

2.5 rstest & test_case

Rust 也有对Parameterized Test支持的库,一个就是 rstest3, 另外一个就是 test_case 4, 两者都对 Parameterized Test 有较好的支持,在公司的代码库中,两者我都见过有项目在使用,而我在工作中使用的是 rstest, 因为它的功能更加强大,维护者也更加活跃.

3 总结

在了解 Parameterized Test 之前,我的每个CR基本都有 test case 覆盖,但是坐我旁边 Principle Engineer 巨佬 review 我代码的时候,总会说我的 test case 太 verbose 和 heavy, 我在想test case多还不好嘛,我的 code coverage 都超过80%了.

然而他的意思是,不是说我的 test case 没有覆盖到代码,我100行的变更,附上200行的 test case 也没有问题,只不过我的test case大多只是数据不一样,测试逻辑基本相同,能否抽象下,减少下code redundancy, 然后就强烈建议我去看下 Parameterized Test 以及 Property Based Test.

大佬的确一针见血,我的 test case 大多是复制已有的 test case, 修改下函数名,再加加减减改下数据集。

经他指点,在了解 Parameterized Test 之后,我的确再也没有复制 test case,每次CR的test case也更精简了,CR也更容易通过了.

而他提到的 Property Based Test 则是一项更强大的测试技术,下回再分解了。

系列文章: