Skip to content

Commit 41bebbe

Browse files
committed
增加新文章:测试技能进阶(二): Parameterized Tests
1 parent 441adc0 commit 41bebbe

File tree

1 file changed

+208
-0
lines changed

1 file changed

+208
-0
lines changed
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
+++
2+
title = "测试技能进阶(二): Parameterized Tests"
3+
date = 2024-10-12T00:35:00-07:00
4+
lastmod = 2024-10-13T11:55:54-07:00
5+
tags = ["testing", "rust", "python"]
6+
categories = ["testing", "rust", "python"]
7+
draft = false
8+
toc = true
9+
+++
10+
11+
## <span class="section-num">1</span> 前言 {#前言}
12+
13+
测试技巧具有普适性,大多是与语言无关的,只是不同语言的生态可能对测试技术的支持各不一样, <br/>
14+
比如Python和Java,基本什么库都有,而像C++,有顺手的单元测试和Mock库能用就很不错了。 <br/>
15+
16+
因为Python比较适合写POC(proof of concept), 而我日常工作的语言是Java+Rust,所以我会穿插着引用这三种语言。 <br/>
17+
18+
19+
## <span class="section-num">2</span> Parameterized Test {#parameterized-test}
20+
21+
在介绍 Parameterized Test 之前,让我们先来看个简单的计算价格与折扣的函数(实际的生产代码肯定会更复杂,但是背后的思路是相通的): <br/>
22+
23+
```python
24+
def calculate_discount(price, discount_percentage):
25+
return price - (price * discount_percentage / 100)
26+
```
27+
28+
针对这个函数,我们可能会编写多个 test case, 比如价格是 100, 给10%的折扣; 价格是200, 给20%的折扣; 价格是50, 给0的折扣;还有异常case,比如价格为负数的时候,或者折扣为负数的时候. <br/>
29+
30+
31+
### <span class="section-num">2.1</span> 单个 test case {#单个-test-case}
32+
33+
对于这么多的 case, 一个简单粗暴的方式就是把所有的 case 都写在一个 test case 里: <br/>
34+
35+
```python
36+
import pytest
37+
def test_calculate_discount():
38+
# happy path
39+
assert calculate_discount(100, 10) == 90
40+
assert calculate_discount(200, 20) == 160
41+
assert calculate_discount(50, 0) == 50
42+
# unhappy path
43+
# assert calculate_discount(-2, 10)
44+
# assert calculate_discount(10, -2)
45+
```
46+
47+
但是这样的做法一般是不推荐的,Best Practice是一个 test case 只测一种情况,因为如果一个 test case 包含多个测试条件,如果 test case fail 了,那么不看源码或者堆栈,一般还看不出是什么 case 失败了,不好排查。 <br/>
48+
49+
50+
### <span class="section-num">2.2</span> 多个 test case {#多个-test-case}
51+
52+
推荐做法就是每个测试条件定个单独的 test case。 <br/>
53+
54+
另外我们通过test case发现上面的代码没有处理异常情况,我们现在要优化下我们的代码,增加异常处理逻辑(这个就是TDD所推崇的开发哲学, test case 先行,通过test case发现问题,让test case fail掉,然后修正业务逻辑,test case再运行通过). <br/>
55+
56+
```python
57+
import pytest
58+
59+
def calculate_discount(price, discount_percentage):
60+
if price < 0:
61+
raise ValueError(f"Price must be greater than zero: {price}")
62+
if discount_percentage < 0:
63+
raise ValueError(f"Discount_percentage must be greater than zero: {discount_percentage}")
64+
return price - (price * discount_percentage / 100)
65+
66+
class TestClassCalculateDiscount:
67+
# happy path
68+
def test_calculate_discount_with_10_discount_percentage(self):
69+
assert calculate_discount(100, 10) == 90
70+
71+
def test_calculate_discount_with_20_discount_percentage(self):
72+
assert calculate_discount(200, 20) == 160
73+
74+
def test_calculate_discount_with_0_discount_percentage(self):
75+
assert calculate_discount(50, 0) == 50
76+
77+
# unhappy path
78+
def test_calculate_discount_with_negative_price(self):
79+
with pytest.raises(ValueError):
80+
assert calculate_discount(-2, 10)
81+
82+
def test_calculate_discount_with_negative_discount(self):
83+
with pytest.raises(ValueError):
84+
assert calculate_discount(10, -2)
85+
```
86+
87+
代码的确是整洁易读了,但话虽如此,我们要多写了很多的 test case. <br/>
88+
89+
如果 `calculate_discount` 变得更复杂,我们要写的 test case 肯定是更多更复杂,总不能都 copy-paste test case吧。 <br/>
90+
91+
92+
### <span class="section-num">2.3</span> Parameterized Test {#parameterized-test}
93+
94+
话题就回到 Parameterized Test 了, 它就是用来解决这个问题的,它可以让你用不同的测试数据集会运行相同的测试逻辑. <br/>
95+
还是以上面的代码为例子,你会发现 `test_calculate_discount_with_10_discount_percentage``test_calculate_discount_with_20_discount_percentage` 的测试逻辑是完全一样的,但只是数据集不同,所以我们就可以使用 Parameterized Test 来优化: <br/>
96+
97+
```python
98+
import pytest
99+
100+
class TestClassCalculateDiscount:
101+
102+
# Parameterized test for valid cases (happy path)
103+
@pytest.mark.parametrize("price, discount, expected", [
104+
(100, 10, 90),
105+
(200, 20, 160),
106+
(50, 0, 50)
107+
])
108+
def test_calculate_discount(self, price, discount, expected):
109+
assert calculate_discount(price, discount) == expected
110+
111+
# Parameterized test for invalid cases (unhappy path)
112+
@pytest.mark.parametrize("price, discount", [
113+
(-2, 10), # Invalid price
114+
(10, -2) # Invalid discount percentage
115+
])
116+
def test_calculate_discount_invalid_cases(self, price, discount):
117+
with pytest.raises(ValueError):
118+
calculate_discount(price, discount)
119+
```
120+
121+
其实就是把测试逻辑和数据进行了分离,后面需要测试新的数据集,只需要向数据集里面添加数据即可。 <br/>
122+
123+
由此可见,使用 Parameterized Test 有几个显而易见的好处: <br/>
124+
125+
首先是减少代码冗余,不需要类似的代码 copy-paste 很多次;其次是方便提到测试覆盖率,这个在上面的例子可能不明显,我们可以再修改一下 `calculate_discount` 函数,增加两个分支: <br/>
126+
127+
```python
128+
def calculate_discount(price, discount_percentage):
129+
if price < 0:
130+
raise ValueError(f"Price must be greater than zero: {price}")
131+
if discount_percentage < 0:
132+
raise ValueError(f"Discount_percentage must be greater than zero: {discount_percentage}")
133+
if price > 50000:
134+
return price - (price * (discount_percentage * 1.15) / 100)
135+
elif price > 100000:
136+
return price - (price * (discount_percentage * 1.18) / 100)
137+
else:
138+
return price - (price * discount_percentage / 100)
139+
```
140+
141+
价格超过50000, 在已有折扣基础上,再额外给折扣的15%作为折扣;价格超过100000,在已有折扣的基础上,再额外给折扣的18%作为折扣. 如果要覆盖这两个新的分支,只需要在数据集上添加大于50000 和大于100000的数据集,就可以直接覆盖到了. <br/>
142+
143+
```python
144+
@pytest.mark.parametrize("price, discount, expected", [
145+
(100, 10, 90),
146+
(200, 20, 160),
147+
(50, 0, 50),
148+
(50001, 10, 44250.885),
149+
(100001, 10, 88500.885)
150+
])
151+
def test_calculate_discount(self, price, discount, expected):
152+
assert calculate_discount(price, discount) == expected
153+
```
154+
155+
然后测试这段代码的时候,我又发现一个新的问题,这里的价格变成浮点数后,没有作小数点后几位的取整。 <br/>
156+
157+
(对于这样简单的函数,也能不断地通过写 test case 发现新问题,这无疑就是 test case 最大的价值所在了) <br/>
158+
159+
使用 Parameterized Test 还可以提高测试代码的可读性和可维护性,这部分内容还是显而易见的,就不展开了。 <br/>
160+
161+
162+
### <span class="section-num">2.4</span> Junit {#junit}
163+
164+
在Java的测试生态中,Junit是毫无疑问的龙头大哥,而在Junit5 ,Junit也引入了对 Parameterized Test 的支持,通过 `@ParameterizedTest` 这个枚举就可以将某个 test case 标注成 Parameterized Test, 通过 `@ValueSource` 传入待测试数据集: <br/>
165+
166+
```java
167+
public class Numbers {
168+
public static boolean isOdd(int number) {
169+
return number % 2 != 0;
170+
}
171+
}
172+
173+
@ParameterizedTest
174+
@ValueSource(ints = {1, 3, 5, -3, 15, Integer.MAX_VALUE}) // six numbers
175+
void isOdd_ShouldReturnTrueForOddNumbers(int number) {
176+
assertTrue(Numbers.isOdd(number));
177+
}
178+
```
179+
180+
这只是最基本的用法,Junit还支持通过函数,枚举,CSV格式甚至文件来传入待测试数据集,可谓是包罗万有,具体的用法可以参考这篇文章:[Guide to JUnit 5 Parameterized Tests](https://www.baeldung.com/parameterized-tests-junit-5)[Junit官方文档](https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests) <br/>
181+
182+
183+
### <span class="section-num">2.5</span> rstest &amp; test_case {#rstest-and-test-case}
184+
185+
Rust 也有对Parameterized Test支持的库,一个就是 [`rstest`](https://github.com/la10736/rstest), 另外一个就是 [`test_case`](https://github.com/frondeus/test-case),两者都对 Parameterized Test 有较好的支持,在公司的代码库中,两者我都见过有项目在使用,而我在工作中使用的是 `rstest`, 因为它的功能更加强大,维护者也更加活跃. <br/>
186+
187+
188+
## <span class="section-num">3</span> 总结 {#总结}
189+
190+
在了解 Parameterized Test 之前,我的每个CR基本都有 test case 覆盖,但是坐我旁边 Principle Engineer 巨佬 review 我代码的时候,总会说我的 test case 太 verbose 和 heavy, 我在想test case多还不好嘛,我的 code coverage 都超过80%了. <br/>
191+
192+
然而他的意思是,不是说我的 test case 没有覆盖到代码,我100行的变更,附上200行的 test case 也没有问题,只不过我的test case大多只是数据不一样,测试逻辑基本相同,能否抽象下,减少下code redundancy, 然后就强烈建议我去看下 `Parameterized Test` 以及 `Property Based Test`. <br/>
193+
194+
大佬的确一针见血,我的 test case 大多是复制已有的 test case, 修改下函数名,再加加减减改下数据集。 <br/>
195+
196+
经他指点,在了解 `Parameterized Test` 之后,我的确再也没有复制 test case,每次CR的test case也更精简了,CR也更容易通过了. <br/>
197+
198+
而他提到的 `Property Based Test` 则是一项更强大的测试技术,下回再分解了。 <br/>
199+
200+
201+
## <span class="section-num">4</span> 参考 {#参考}
202+
203+
- [Guide to JUnit 5 Parameterized Tests](https://www.baeldung.com/parameterized-tests-junit-5) <br/>
204+
- [Junit: Parameterized Tests](https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests) <br/>
205+
- [Parametrizing tests](https://docs.pytest.org/en/stable/example/parametrize.html) <br/>
206+
- [rstest](https://github.com/la10736/rstest) <br/>
207+
- [test_case](https://github.com/frondeus/test-case) <br/>
208+

0 commit comments

Comments
 (0)