人人都能学编程
English 开始学习
专项 · 软件测试第 4 课 / 共 5 课免费阅读

表格与性质测试 · 软件测试

怎么测试很多情况,又不用写很多测试?

课程目录

为什么需要它

有些函数需要几十个用例:每个价格区间、每种日期格式。每个都写一个函数很累,还把规律藏起来了。有两种做法能更好地扩展:输入和期望输出组成的表格,以及对任何输入都必须成立的性质,用几百个随机输入来检查。

直觉理解

表格驱动测试:一个循环,很多行。

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
逐步执行
  1. L10每一行就是一个测试:边界 1 和 5,以及刚越过它们的值。
  2. 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
逐步执行
  1. L5固定的种子:每次运行都是同样的列表。
  2. L7数值范围小,重复就很常见,还包括负数。
  3. L9相同的元素,个数也相同。
  4. L16丢掉了重复项:被个数规则抓住。

常见错误

无法重现的随机测试

如果随机测试失败了一次,你却再也拿不到同样的输入,就没法调试。要用带种子的生成器。

在测试里把代码重写一遍

测试如果用和代码一样的方法算答案,就会重复同一个 bug。用已知的值,或者独立的性质。

只生成乖巧的随机数据

全是互不相同的正数的随机列表,永远测不到重复、负数和空列表。调整生成器,把它们包括进来。

小测验

1. 对任何列表都能检查的、正确的 `reverse(xs)` 具有的性质是哪个?

  1. Areverse(reverse(xs)) == xs
  2. Breverse(xs)[0] == 1
  3. Creverse(xs) != xs
查看答案

答案:A

反转两次得到原列表,对每个列表都成立。(对 [1, 2, 1] 来说 reverse(xs) == xs 也没问题。)

2. 为什么要给性质测试里的随机生成器设种子?

  1. A这样失败的输入可以复现
  2. B让数字更随机
  3. CPython 要求这样
查看答案

答案:A

同样的种子,同样的输入:你可以重跑失败的用例并调试。

动手练习

3 道练习,分三层:先预测程序的输出,再按新需求改程序,最后从零写一个。代码在浏览器里运行,提交马上判题。

  1. 预测输出跑一遍表格

    准确写出它打印的内容。

  2. 改需求保持顺序

    dedupe(items) 应该去掉重复项,保留每个元素第一次出现,并且保持原来的顺序。「dedupe(xs) 保持顺序」这个性质检查失败了,因为集合不保证顺序。修好它。

  3. 从零写性质检查器

    写一个 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.
HypothesisHypothesisA Python library for property-based testing.