属性测试 property-based-testing

属性测试是一种软件测试方法,通过自动生成广泛的测试用例来验证代码是否满足特定的属性或不变量,有助于发现边缘情况和潜在的错误。

测试 0 次安装 0 次浏览 更新于 3/4/2026

属性测试

概览

属性测试验证代码是否满足一般性质或不变量,针对广泛自动生成的输入,而不是测试特定例子。这种方法发现边缘情况和错误,通常基于示例的测试会遗漏。

何时使用

  • 测试具有数学属性的算法
  • 验证应始终成立不变量
  • 自动发现边缘情况
  • 测试解析器和序列化器(往返属性)
  • 验证数据转换
  • 测试排序、搜索和数据结构操作
  • 发现意外的输入组合

关键概念

  • 属性:对所有有效输入应为真的声明
  • 生成器:创建随机测试输入
  • 缩减:将失败输入简化为最简情况
  • 不变量:必须始终为真的条件
  • 往返:编码后解码返回原始值

指令

1. Hypothesis for Python

# test_string_operations.py
import pytest
from hypothesis import given, strategies as st, assume, example

def reverse_string(s: str) -> str:
    """反转字符串。"""
    return s[::-1]

class TestStringOperations:
    @given(st.text())
    def test_reverse_twice_returns_original(self, s):
        """属性:两次反转返回原始字符串。"""
        assert reverse_string(reverse_string(s)) == s

    @given(st.text())
    def test_reverse_length_unchanged(self, s):
        """属性:反转不改变长度。"""
        assert len(reverse_string(s)) == len(s)

    @given(st.text(min_size=1))
    def test_reverse_first_becomes_last(self, s):
        """属性:反转后第一个字符成为最后一个。"""
        reversed_s = reverse_string(s)
        assert s[0] == reversed_s[-1]
        assert s[-1] == reversed_s[0]

# test_sorting.py
from hypothesis import given, strategies as st

def quick_sort(items):
    """使用快速排序排序项目。"""
    if len(items) <= 1:
        return items
    pivot = items[len(items) // 2]
    left = [x for x in items if x < pivot]
    middle = [x for x in items if x == pivot]
    right = [x for x in items if x > pivot]
    return quick_sort(left) + middle + quick_sort(right)

class TestSorting:
    @given(st.lists(st.integers()))
    def test_sorted_list_is_ordered(self, items):
        """属性:每个元素<=下一个元素。"""
        sorted_items = quick_sort(items)
        for i in range(len(sorted_items) - 1):
            assert sorted_items[i] <= sorted_items[i + 1]

    @given(st.lists(st.integers()))
    def test_sorting_preserves_length(self, items):
        """属性:排序不增加/删除元素。"""
        sorted_items = quick_sort(items)
        assert len(sorted_items) == len(items)

    @given(st.lists(st.integers()))
    def test_sorting_preserves_elements(self, items):
        """属性:所有元素在结果中存在。"""
        sorted_items = quick_sort(items)
        assert sorted(items) == sorted_items

    @given(st.lists(st.integers()))
    def test_sorting_is_idempotent(self, items):
        """属性:排序两次得到相同结果。"""
        once = quick_sort(items)
        twice = quick_sort(once)
        assert once == twice

    @given(st.lists(st.integers(), min_size=1))
    def test_sorted_min_at_start(self, items):
        """属性:最小元素在首位。"""
        sorted_items = quick_sort(items)
        assert sorted_items[0] == min(items)

    @given(st.lists(st.integers(), min_size=1))
    def test_sorted_max_at_end(self, items):
        """属性:最大元素在末尾。"""
        sorted_items = quick_sort(items)
        assert sorted_items[-1] == max(items)

# test_json_serialization.py
from hypothesis import given, strategies as st
import json

# 定义JSON可序列化对象的策略
json_strategy = st.recursive(
    st.none() | st.booleans() | st.integers() | st.floats(allow_nan=False) | st.text(),
    lambda children: st.lists(children) | st.dictionaries(st.text(), children),
    max_leaves=10
)

class TestJSONSerialization:
    @given(json_strategy)
    def test_json_round_trip(self, obj):
        """属性:编码后解码返回原始值。"""
        json_str = json.dumps(obj)
        decoded = json.loads(json_str)
        assert decoded == obj

    @given(st.dictionaries(st.text(), st.integers()))
    def test_json_dict_keys_preserved(self, d):
        """属性:所有字典键被保留。"""
        json_str = json.dumps(d)
        decoded = json.loads(json_str)
        assert set(decoded.keys()) == set(d.keys())

# test_math_operations.py
from hypothesis import given, strategies as st, assume
import math

class TestMathOperations:
    @given(st.integers(), st.integers())
    def test_addition_commutative(self, a, b):
        """属性:a + b = b + a"""
        assert a + b == b + a

    @given(st.integers(), st.integers(), st.integers())
    def test_addition_associative(self, a, b, c):
        """属性:(a + b) + c = a + (b + c)"""
        assert (a + b) + c == a + (b + c)

    @given(st.integers())
    def test_addition_identity(self, a):
        """属性:a + 0 = a"""
        assert a + 0 == a

    @given(st.floats(allow_nan=False, allow_infinity=False))
    def test_abs_non_negative(self, x):
        """属性:abs(x) >= 0"""
        assert abs(x) >= 0

    @given(st.floats(allow_nan=False, allow_infinity=False))
    def test_abs_idempotent(self, x):
        """属性:abs(abs(x)) = abs(x)"""
        assert abs(abs(x)) == abs(x)

    @given(st.integers(min_value=0))
    def test_sqrt_inverse_of_square(self, n):
        """属性:sqrt(n^2) = n 对于非负n"""
        assert math.isclose(math.sqrt(n * n), n)

# test_with_examples.py
from hypothesis import given, strategies as st, example

class TestWithExamples:
    @given(st.integers())
    @example(0)  # 确保测试零
    @example(-1)  # 确保测试负数
    @example(1)  # 确保测试正数
    def test_absolute_value(self, n):
        """属性:abs(n) >= 0,具有特定示例。"""
        assert abs(n) >= 0

# test_stateful.py
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
import hypothesis.strategies as st

class StackMachine(RuleBasedStateMachine):
    """用状态属性测试堆栈数据结构。"""

    def __init__(self):
        super().__init__()
        self.stack = []

    @rule(value=st.integers())
    def push(self, value):
        """将值推入堆栈。"""
        self.stack.append(value)

    @rule()
    def pop(self):
        """从堆栈中弹出值。"""
        if self.stack:
            self.stack.pop()

    @invariant()
    def stack_size_non_negative(self):
        """不变量:堆栈大小从不为负。"""
        assert len(self.stack) >= 0

    @invariant()
    def peek_equals_last_push(self):
        """不变量:Peek返回最后推入的值。"""
        if self.stack:
            # 最后一项应为最近推入的
            assert self.stack[-1] is not None

TestStack = StackMachine.TestCase

2. fast-check for JavaScript/TypeScript

// string.test.ts
import * as fc from 'fast-check';

describe('String Operations', () => {
  test('reverse twice returns original', () => {
    fc.assert(
      fc.property(fc.string(), (s) => {
        const reversed = s.split('').reverse().join('');
        const doubleReversed = reversed.split('').reverse().join('');
        return s === doubleReversed;
      })
    );
  });

  test('concatenation length', () => {
    fc.assert(
      fc.property(fc.string(), fc.string(), (s1, s2) => {
        return (s1 + s2).length === s1.length + s2.length;
      })
    );
  });

  test('uppercase is idempotent', () => {
    fc.assert(
      fc.property(fc.string(), (s) => {
        const once = s.toUpperCase();
        const twice = once.toUpperCase();
        return once === twice;
      })
    );
  });
});

// array.test.ts
import * as fc from 'fast-check';

function quickSort(arr: number[]): number[] {
  if (arr.length <= 1) return arr;
  const pivot = arr[Math.floor(arr.length / 2)];
  const left = arr.filter(x => x < pivot);
  const middle = arr.filter(x => x === pivot);
  const right = arr.filter(x => x > pivot);
  return [...quickSort(left), ...middle, ...quickSort(right)];
}

describe('Sorting Properties', () => {
  test('sorted array is ordered', () => {
    fc.assert(
      fc.property(fc.array(fc.integer()), (arr) => {
        const sorted = quickSort(arr);
        for (let i = 0; i < sorted.length - 1; i++) {
          if (sorted[i] > sorted[i + 1]) return false;
        }
        return true;
      })
    );
  });

  test('sorting preserves length', () => {
    fc.assert(
      fc.property(fc.array(fc.integer()), (arr) => {
        return quickSort(arr).length === arr.length;
      })
    );
  });

  test('sorting preserves elements', () => {
    fc.assert(
      fc.property(fc.array(fc.integer()), (arr) => {
        const sorted = quickSort(arr);
        const originalSorted = [...arr].sort((a, b) => a - b);
        return JSON.stringify(sorted) === JSON.stringify(originalSorted);
      })
    );
  });

  test('sorting is idempotent', () => {
    fc.assert(
      fc.property(fc.array(fc.integer()), (arr) => {
        const once = quickSort(arr);
        const twice = quickSort(once);
        return JSON.stringify(once) === JSON.stringify(twice);
      })
    );
  });
});

// object.test.ts
import * as fc from 'fast-check';

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

const userArbitrary = fc.record({
  id: fc.integer(),
  name: fc.string({ minLength: 1 }),
  email: fc.emailAddress(),
  age: fc.integer({ min: 0, max: 120 }),
});

describe('User Validation', () => {
  test('serialization round trip', () => {
    fc.assert(
      fc.property(userArbitrary, (user) => {
        const json = JSON.stringify(user);
        const parsed = JSON.parse(json);
        return JSON.stringify(parsed) === json;
      })
    );
  });

  test('age validation', () => {
    fc.assert(
      fc.property(userArbitrary, (user) => {
        return user.age >= 0 && user.age <= 120;
      })
    );
  });
});

// custom generators
const positiveIntegerArray = fc.array(fc.integer({ min: 1 }), { minLength: 1 });

test('sum of positives is positive', () => {
  fc.assert(
    fc.property(positiveIntegerArray, (arr) => {
      const sum = arr.reduce((a, b) => a + b, 0);
      return sum > 0;
    })
  );
});

// test with shrinking
test('find minimum failing case', () => {
  try {
    fc.assert(
      fc.property(fc.array(fc.integer()), (arr) => {
        // This will fail for arrays with negative numbers
        return arr.every(n => n >= 0);
      })
    );
  } catch (error) {
    // fast-check will shrink to minimal failing case: [-1] or similar
    console.log('Minimal failing case found:', error);
  }
});

3. junit-quickcheck for Java

// ArrayOperationsTest.java
import com.pholser.junit.quickcheck.Property;
import com.pholser.junit.quickcheck.runner.JUnitQuickcheck;
import com.pholser.junit.quickcheck.generator.InRange;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
import java.util.*;

@RunWith(JUnitQuickcheck.class)
public class ArrayOperationsTest {

    @Property
    public void sortingPreservesLength(List<Integer> list) {
        List<Integer> sorted = new ArrayList<>(list);
        Collections.sort(sorted);
        assertEquals(list.size(), sorted.size());
    }

    @Property
    public void sortedListIsOrdered(List<Integer> list) {
        List<Integer> sorted = new ArrayList<>(list);
        Collections.sort(sorted);

        for (int i = 0; i < sorted.size() - 1; i++) {
            assertTrue(sorted.get(i) <= sorted.get(i + 1));
        }
    }

    @Property
    public void sortingIsIdempotent(List<Integer> list) {
        List<Integer> onceSorted = new ArrayList<>(list);
        Collections.sort(onceSorted);

        List<Integer> twiceSorted = new ArrayList<>(onceSorted);
        Collections.sort(twiceSorted);

        assertEquals(onceSorted, twiceSorted);
    }

    @Property
    public void reverseReverseIsIdentity(List<String> list) {
        List<String> once = new ArrayList<>(list);
        Collections.reverse(once);

        List<String> twice = new ArrayList<>(once);
        Collections.reverse(twice);

        assertEquals(list, twice);
    }
}

// StringOperationsTest.java
@RunWith(JUnitQuickcheck.class)
public class StringOperationsTest {

    @Property
    public void concatenationLength(String s1, String s2) {
        assertEquals(s1.length() + s2.length(), (s1 + s2).length());
    }

    @Property
    public void uppercaseIsIdempotent(String s) {
        String once = s.toUpperCase();
        String twice = once.toUpperCase();
        assertEquals(once, twice);
    }

    @Property
    public void trimRemovesWhitespace(String s) {
        String trimmed = s.trim();
        if (!trimmed.isEmpty()) {
            assertFalse(Character.isWhitespace(trimmed.charAt(0)));
            assertFalse(Character.isWhitespace(trimmed.charAt(trimmed.length() - 1)));
        }
    }
}

// MathOperationsTest.java
@RunWith(JUnitQuickcheck.class)
public class MathOperationsTest {

    @Property
    public void additionCommutative(int a, int b) {
        assertEquals(a + b, b + a);
    }

    @Property
    public void additionAssociative(int a, int b, int c) {
        assertEquals((a + b) + c, a + (b + c));
    }

    @Property
    public void absoluteValueNonNegative(int n) {
        assertTrue(Math.abs(n) >= 0);
    }

    @Property
    public void absoluteValueIdempotent(int n) {
        assertEquals(Math.abs(n), Math.abs(Math.abs(n)));
    }

    @Property
    public void divisionByNonZero(
        int dividend,
        @InRange(minInt = 1, maxInt = Integer.MAX_VALUE) int divisor
    ) {
        int result = dividend / divisor;
        assertTrue(result * divisor <= dividend + divisor);
    }
}

通用属性测试

通用属性

  • 幂等性f(f(x)) = f(x)
  • 恒等性f(x, identity) = x
  • 交换律f(a, b) = f(b, a)
  • 结合律f(f(a, b), c) = f(a, f(b, c))
  • 逆元f(inverse_f(x)) = x

数据结构属性

  • 往返decode(encode(x)) = x
  • 保持:操作保持长度、元素或结构
  • 排序:元素保持所需顺序
  • 界限:值保持在有效范围内
  • 不变量:类不变量始终成立

最佳实践

✅ 做

  • 专注于一般属性,而不是特定案例
  • 测试数学属性(交换律、结合律)
  • 验证往返编码/解码
  • 使用缩减找到最小失败案例
  • 结合基于示例的测试以了解已知边缘情况
  • 测试应始终成立的不变量
  • 生成现实的输入分布

❌ 不做

  • 测试必然成立的属性
  • 过度限制输入生成
  • 忽略缩减的测试失败
  • 用属性测试替换所有示例测试
  • 测试实现细节
  • 生成无效输入而不加约束
  • 忘记在生成器中处理边缘情况

工具和库

  • Python:Hypothesis
  • JavaScript/TypeScript:fast-check, jsverify
  • Java:junit-quickcheck, jqwik
  • Scala:ScalaCheck
  • Haskell:QuickCheck(原始)
  • C#:FsCheck

示例

另见:test-data-generation, mutation-testing, continuous-testing 用于全面的测试策略。