表格与性质测试 · 软件测试
怎么测试很多情况,又不用写很多测试?
课程目录为什么需要它
有些函数需要几十个用例:每个价格区间、每种日期格式。每个都写一个函数很累,还把规律藏起来了。有两种做法能更好地扩展:输入和期望输出组成的表格,以及对任何输入都必须成立的性质,用几百个随机输入来检查。
直觉理解
表格驱动测试:一个循环,很多行。
CASES = [(1, 5), (1.01, 9), (5, 9), (6, 11)]
for weight, want in CASES:
assert shipping(weight) == want, (weight, shipping(weight))pytest 里写成 @pytest.mark.parametrize("weight, want", CASES),每一行都会作为一个单独的测试报告。
基于性质的测试:有时你很难说出确切答案,但你知道它必须遵守的规则。对任何列表,排序函数的输出都必须:
- 长度相同,
- 元素相同(每个元素的个数也相同),
- 按顺序排列。
生成很多随机输入,包括棘手的(空的、有重复的、有负数的),对每个都检查这些规则。用固定的种子(random.Random(1)),这样失败可以复现。Python 的 Hypothesis 库就是做这个的,它还会把失败的输入缩小到最小。
定义
表格驱动测试
One test that loops over rows of (input, expected).一个遍历多行(输入, 期望值)的测试。
基于性质的测试
A test that checks rules that must hold for any input, on many generated inputs.在大量生成的输入上,检查对任何输入都必须成立的规则。
随机种子
The starting value of a random generator. The same seed gives the same "random" numbers, so failures can be repeated.随机数生成器的起始值。同样的种子产生同样的「随机」数,所以失败可以重现。
示例
一张用例表
动手试试价格区间的每个边界都在一个列表里。
import math
def shipping(weight_kg):
if weight_kg <= 1:
return 5
if weight_kg <= 5:
return 9
return 9 + 2 * math.ceil(weight_kg - 5)
CASES = [(0.5, 5), (1, 5), (1.01, 9), (5, 9), (5.5, 11), (7, 13)]
failures = 0
for weight, want in CASES:
got = shipping(weight)
if got != want:
failures += 1
print("FAIL", weight, "want", want, "got", got)
print(len(CASES), "cases,", failures, "failures")6 cases, 0 failures
- L10每一行就是一个测试:边界 1 和 5,以及刚越过它们的值。
- L12一个循环跑完所有行,报告每个失败及其输入。
检查性质
动手试试在 300 个随机列表上运行排序,逐个检查三条规则。
import random
from collections import Counter
def looks_sorted(fn):
rng = random.Random(1)
for _ in range(300):
xs = [rng.randint(-3, 3) for _ in range(rng.randint(0, 6))]
out = fn(list(xs))
if len(out) != len(xs) or Counter(out) != Counter(xs):
return False
if any(out[i] > out[i + 1] for i in range(len(out) - 1)):
return False
return True
print(looks_sorted(sorted))
print(looks_sorted(lambda xs: sorted(set(xs))))
print(looks_sorted(lambda xs: sorted(xs, reverse=True)))True False False
- L5固定的种子:每次运行都是同样的列表。
- L7数值范围小,重复就很常见,还包括负数。
- L9相同的元素,个数也相同。
- L16丢掉了重复项:被个数规则抓住。
常见错误
无法重现的随机测试
如果随机测试失败了一次,你却再也拿不到同样的输入,就没法调试。要用带种子的生成器。
在测试里把代码重写一遍
测试如果用和代码一样的方法算答案,就会重复同一个 bug。用已知的值,或者独立的性质。
只生成乖巧的随机数据
全是互不相同的正数的随机列表,永远测不到重复、负数和空列表。调整生成器,把它们包括进来。
小测验
1. 对任何列表都能检查的、正确的 `reverse(xs)` 具有的性质是哪个?
查看答案
答案:A
反转两次得到原列表,对每个列表都成立。(对 [1, 2, 1] 来说 reverse(xs) == xs 也没问题。)
2. 为什么要给性质测试里的随机生成器设种子?
查看答案
答案:A
同样的种子,同样的输入:你可以重跑失败的用例并调试。
动手练习
3 道练习,分三层:先预测程序的输出,再按新需求改程序,最后从零写一个。代码在浏览器里运行,提交马上判题。
- 预测输出跑一遍表格
准确写出它打印的内容。
- 改需求保持顺序
dedupe(items)应该去掉重复项,保留每个元素第一次出现,并且保持原来的顺序。「dedupe(xs) 保持顺序」这个性质检查失败了,因为集合不保证顺序。修好它。 - 从零写性质检查器
写一个
check_sort(fn):如果fn在 200 个随机列表上都表现得像正确的排序,就返回True;一旦某个列表违反规则,就返回False:- 长度和输入相同
- 元素相同,每个元素的个数也相同
- 按顺序排列
评测会用它检查
sorted,以及几个坏掉的排序:丢掉重复项的、倒序的、改了负数的、只排了一部分的。用random.Random(种子),生成的列表要包括负数、重复项、空列表和长度超过 6 的列表。
本课术语
| 术语 | 中文 | 英文定义 |
|---|---|---|
| Table-driven test | 表格驱动测试 | Many (input, expected) rows checked in one loop. |
| Property-based test | 基于性质的测试 | Rules checked on many generated inputs. |
| Hypothesis | Hypothesis | A Python library for property-based testing. |