组合测试

Combinatorial Testing (CT)

PICT (Microsoft): Pairwise Independent Combinatorial Testing
ACTS (NIST): Advanced Combinatorial Testing System

组合测试是一种专门针对待测系统中的各类 “因素组合” 进行测试的方法,其利用一个精心构造的覆盖表 (Covering Array) 作为测试用例集,能以较小的测试代价获得较高的故障检测能力。

例如,针对 Font Effect 测试场景,我们可以建立如下的组合测试输入空间模型 (以 PICT 支持的格式为例):

# pict.model
Strike through: On, Off
Double strike through: On, Off
Superscript: On, Off
Subscript: On, off
Small caps: On, off
All caps: On, off
Hidden: On, off

随后,就可以利用组合测试覆盖表生成工具构造满足特定组合覆盖需求 (例如,t = 2) 的测试用例集:

./pict font.model /o:2

对于先进的覆盖表生成工具,所生成覆盖表的规模随测试模型中参数个数呈对数增长的关系。尤其在 t = 2 的情况下,即使有超过 100 个输入参数,也仅需要十余条测试用例即可达到 100% 的 2-way 覆盖。组合测试因因此非常适合对大量参数或配置间的交互作用进行测试。

s0:  0, 1
s1:  0, 1
s2:  0, 1
s3:  0, 1
s4:  0, 1
s5:  0, 1
s6:  0, 1
s7:  0, 1
s8:  0, 1
s9:  0, 1
s10: 0, 1
s11: 0, 1
s12: 0, 1
s13: 0, 1
s14: 0, 1
s15: 0, 1
s16: 0, 1
s17: 0, 1
s18: 0, 1
s19: 0, 1
s20: 0, 1
s21: 0, 1
s22: 0, 1
s23: 0, 1
s24: 0, 1
s25: 0, 1
s26: 0, 1
s27: 0, 1
s28: 0, 1
s29: 0, 1
s30: 0, 1
s31: 0, 1
s32: 0, 1
s33: 0, 1

对于很多真实待测系统,其参数取值之间往往会存在很多复杂的依赖关系。例如,对于 Font Effect 测试场景,一段文字不能既设置成 “Superscript” 又设置成 “Subscript”:

IF [Superscript] = "On" THEN [Subscript] == "Off";

违反约束的测试用例是无效 (无法执行) 的测试用例。通过在测试模型中明确指明系统中涉及的约束,就可以使用覆盖表生成工具构造得到一个满足约束的约束覆盖表 (Constrained Covering Array)。

约束覆盖表生成

给定一个组合测试模型,如何构造得到一个规模尽可能小的约束覆盖表是一个极其困难的组合优化问题,目前主要有数学方法、贪心算法和启发式搜索方法等覆盖表生成方法。对于贪心算法,常见的生成方式包括:

  • One-Test-At-a-Time (OTAT): 每次尝试构造一条能覆盖尽可能多未覆盖组合的测试用例;
  • In-Parameter-Order (IPO): 先实现前 t 个参数的覆盖,然后不断进行水平扩展和垂直扩展,在每次给参数赋值时都选择能最大化组合覆盖的参数取值。

Cohen M B, Dwyer M B, Shi J. Constructing interaction test suites for highly-configurable systems in the presence of constraints: A greedy approach. IEEE Transactions on Software Engineering (TSE), 2008, 34(5): 633-650.

Yu L, Lei Y, Nourozborazjany M, et al. An efficient algorithm for constraint handling in combinatorial test generation International Conference on Software Testing, Verification and Validation (ICST). 2013: 242-251.

基于 Minimum Forbidden Tuples 的约束处理

在约束处理上,一种方式是利用最小禁止元组 (Minimum Forbidden Tuples) 的概念,即首先基于给定的约束分析产生一个最小禁止元组的集合。例如,上述 Superscript 和 Subscript 间的约束即对应如下最小禁止元组 (这两个参数的特定取值组合不能同时出现):

Superscript = "On" AND Subscript = "On"

随后,在生成过程中就可以通过和该集合中的元素进行比较来判断一个 (partial) test case 是否满足约束。该方法对约束的处理效率非常高,PICT 和 ACTS 都采用这一方法来处理约束。

基于 Constraint Solver 的约束处理

另外一种方式是利用约束求解器 (Constraint Solver),即首先将约束编码为某种约束表达式,然后就可以利用约束求解器判断一个 (partial) test case 是否满足约束。

例如,如果使用 SAT 求解器,我们可以使用如下 VALUE_TO_VAR 所示的编码方式,将每个参数取值编码为一个 boolean variable。在此基础上,我们就可以构造如 FORMULA 所示的约束 CNF 表达式,其中包括基本一致性约束 (at-least and at-most constraints) 和用户指定的约束 (P2 和 P3 不能同时取 1)。最后,通过调用 SAT 求解器的 solve() 方法,就可以判断一个 (partial) test case 是否满足约束。

from pysat.formula import CNF
from pysat.solvers import Solver

VALUE_TO_VAR = {
  ("P1", 0): 1,   # x1
  ("P1", 1): 2,   # x2
  ("P2", 0): 3,   # x3
  ("P2", 1): 4,   # x4
  ("P3", 0): 5,   # x5
  ("P3", 1): 6,   # x6
  ("P3", 2): 7,   # x7
  ("P4", 0): 8,   # x8
  ("P4", 1): 9,   # x9
  ("P4", 2): 10,  # x10
}

# DIMACS 风格的 CNF:正数表示 x,负数表示 not x。
FORMULA = CNF(from_clauses=[
  # at-least & at-most
  [1, 2],[-1, -2],
  [3, 4],
  [-3, -4],
  [5, 6, 7],
  [-5, -6],
  [-5, -7],
  [-6, -7],
  [8, 9, 10],
  [-8, -9],
  [-8, -10],
  [-9, -10],
  # forbidden tuple
  [-4, -6],
])

class ConstraintSolver:
  def __init__(self) -> None:
    self._solver = Solver(bootstrap_with=FORMULA)

  def isSatisfied(self, test_case: dict[str, int]) -> bool:
    assumptions = [VALUE_TO_VAR[item] for item in test_case.items()]
    result = self._solver.solve(assumptions=assumptions)
    print(assumptions, result)
    return result

  def close(self) -> None:
    self._solver.delete()

if __name__ == "__main__":
  solver = ConstraintSolver()

  solver.isSatisfied({"P1": 0, "P2": 0, "P3": 1, "P4": 2})  # True
  solver.isSatisfied({"P2": 1, "P3": 1})  # False
  solver.isSatisfied({"P2": 1})           # True

  solver.close()

约束求解器是一种非常强大的问题求解工具,只要能把问题编码为特定的约束表达式,即可调用先进的求解器来获得问题的解。例如,我们甚至可以把约束覆盖生成问题本身编码为一个 SAT 的约束表达式,然后直接利用 SAT 求解器生成约束覆盖表:

from itertools import combinations, count, product
from pathlib import Path
from pysat.formula import CNF
from pysat.solvers import Solver

PARAMETERS = [
  [0, 1],     # P1
  [0, 1],     # P2
  [0, 1, 2],  # P3
  [0, 1, 2],  # P4
]

ARRAY_SIZE = 9
CNF_FILE = Path(__file__).with_name("demo_ca.cnf")

def encode(array_size: int) -> tuple[CNF, dict[tuple[int, int, int], int]]:
  formula = CNF()
  new_var = count(1)

  # x[row, parameter, value] 表示该行的参数取这个值。
  x = {
    (row, parameter, value): next(new_var)
    for row in range(array_size)
    for parameter, values in enumerate(PARAMETERS)
    for value in values
  }

  for row in range(array_size):
    for parameter, values in enumerate(PARAMETERS):
      variables = [x[row, parameter, value] for value in values]

      # 每个参数至少取一个值,并且至多取一个值。
      formula.append(variables)
      for first, second in combinations(variables, 2):
        formula.append([-first, -second])

    # forbidden tuple: not (P2 = 1 and P3 = 1)
    formula.append([-x[row, 1, 1], -x[row, 2, 1]])

  # 每个合法的 2-way 组合都必须至少出现在一行中。
  for first_parameter, second_parameter in combinations(range(4), 2):
    for first_value, second_value in product(
      PARAMETERS[first_parameter], PARAMETERS[second_parameter]
    ):
      if (first_parameter, first_value, second_parameter, second_value) == (1, 1, 2, 1):
        continue

      witnesses = []
      for row in range(array_size):
        witness = next(new_var)
        witnesses.append(witness)
        first = x[row, first_parameter, first_value]
        second = x[row, second_parameter, second_value]

        # witness <-> (first and second)
        formula.append([-witness, first])
        formula.append([-witness, second])
        formula.append([witness, -first, -second])

      formula.append(witnesses)

  return formula, x

def generate_covering_array() -> list[list[int]]:
  formula, x = encode(ARRAY_SIZE)
  formula.to_file(CNF_FILE)

  with Solver(bootstrap_with=formula) as solver:
    if not solver.solve():
      raise RuntimeError(f"array size = {ARRAY_SIZE} is UNSAT")

    model = set(solver.get_model())
    return [
      [
        next(value for value in values if x[row, parameter, value] in model)
        for parameter, values in enumerate(PARAMETERS)
      ]
      for row in range(ARRAY_SIZE)
    ]

if __name__ == "__main__":
  covering_array = generate_covering_array()

  print(f"CNF written to {CNF_FILE}")
  print(f"array size = {len(covering_array)}")
  print("P1 P2 P3 P4")
  for test_case in covering_array:
    print(*test_case)