什么是财产?
What is a property?

原始链接: https://alperenkeles.com/posts/what-is-a-property/

## 基于属性的测试的细微之处 虽然基于属性的测试 (PBT) 通常被描述为定义属性(正确性规则)和生成器(输入域),但实际上两者更为交织。核心属性是一个普遍量化的语句——一个返回布尔值的函数——但现实场景引入了诸如前置条件之类的复杂因素。这些前置条件定义了有效输入,需要要么丢弃无效测试,要么更有效地使用*依赖生成器*,通过构造生成有效数据。 作者对 PBT 框架的探索揭示了一个常见问题:生成器通常需要执行与被测系统相关的计算(例如构建数据库),从而模糊了随机生成和逻辑之间的界限。这导致一个范围,属性逻辑可以被转移到生成器中,从而简化两者。 最终,PBT 并非关于严格分离的属性和生成器。像 QuickCheck 的 `forAll` 组合器就证明了这一点,它允许在测试定义*内部*进行生成。作者强调,现代 PBT 库认识到这种集成,像 Hypothesis 和 Hegel 这样的工具提供了无缝融合生成和断言的机制。关键在于,有效的 PBT 通常需要承认并拥抱定义有效输入和验证属性之间的相互作用,而不是严格遵守抽象的分离。

## 技术术语的困境 一篇 Hacker News 的讨论强调了掌握技术概念的困难,并非由于概念本身,而是由于术语的模糊性和泛滥。用户们一致认为,技术学习的重要部分在于理解一个词在特定语境下*具体*指的是什么意思——这个问题由于抽象概念和分层系统而更加严重。 对话指出精确定义的重要性,并将之与数学和编程语言研究相提并论,在这些领域,“属性”等术语具有特定含义。然而,当这些定义被应用于更广泛、非学术的环境时,往往会变得模糊。 参与者建议诸如创建备忘单和放慢速度以澄清理解等策略。一篇链接的文章介绍了基于属性的测试,通过关注具体示例提供了一种有用的方法。最终,该讨论强调了在一个看似简单的名词可能承载着复杂含义的网络中,导航的挑战。
相关文章

原文

When talking about Property-Based Testing, we typically talk in very abstract terms. There are properties, which define the correctness; there are generators, which define the domain; the PBT framework gives us APIs for writing property-based tests that combine the properties with the generators to find bugs. It's all very nice and simple.

A (surprisingly) large chunk of my time goes into exploring different PBT frameworks, many times porting an existing PBT workload to use a new one instead of the other. This requires me to build abstractions on what a PBT framework is, which should have been very easy if the simple definition I gave in the first paragraph captured what PBT is. Unfortunately it doesn't, so let's see what the issue is. A property is a universally quantified computation that must hold for all possible inputs. The simplest model of a property in a programming language is a function that returns a boolean, such as the one below:

property :: a -> Bool

For instance, \l -> reverse (reverse l) == l is a property, it asserts double reversion leads to the original list. This gets slightly complicated with preconditions, which are rules that state if an input is valid or not. So we can write things like:

data Database = ...

execute :: Database -> Query -> Database
query :: Database -> Query -> [[Value]]

(==>) :: Bool -> Bool -> Maybe Bool
(==>) precondition property =
    if precondition then Just property else Nothing

prop_insert_select :: Database -> String -> [Value] -> Maybe Bool
prop_insert_select db table values =
    let insert = Insert table values
        select = Select table "*"
    in
        hasTable db table ==> (values `elem` query (execute db insert) select)

Where ==> is the implication operator, which states that if the precondition (the left-hand side) is not satisfied, then the property cannot be tested. In our case, the precondition is hasTable db table, which checks if the database has the specified table. If it doesn't, then we don't care about the result, we just discard it. If the database has the table, then we execute the insert and check if the select query returns the values we inserted.

Now that we have a property at hand, we need some random generators for it. We can write Arbitrary instances for all the input types and let QuickCheck handle the rest.

data Value = Number Int | String String | ...

instance Arbitrary Value where
    arbitrary = oneof [Number <$> arbitrary, String <$> arbitrary, ...]

instance Arbitrary Database where
    arbitrary = ...

Well, not really. When we write these instances, what do you think QuickCheck does? It generates a random database, a random string, a list of random Values, and then runs the prop_insert_select function. In what percent of cases do you think a random string is a valid table name that exists in the database?

What we want is a dependent generator, where some values can depend on the others:

genTable :: Gen Table
genTable = ...

tableName :: Table -> String
tableName = ...

genValuesFor :: Database -> String -> Gen [Value]
genValuesFor db table = ...

gen :: Gen (Database, String, [Value])
gen = do
        numTables <- choose (1, 10)
        tables <- vectorOf numTables genTable
        let db0 = emptyDatabase
        let db = foldl createTable db0 tables
        let tableNames = map tableName tables
    table <- elements tableNames
    values <- genValuesFor db table
    return (db, table, values)

Now we don't need to worry about the precondition failure because the inputs are valid by construction. The table is selected from the already existing list of tables in the database, so it will never fail. How do we run the tests? Here's a conceptual API for it:

quickCheck :: Gen t -> (t -> Maybe Bool) -> Int -> Gen (Maybe t)
quickCheck gen property n =
    if n == 0 then pure Nothing
    else do
                input <- gen
                case property input of
                        Just True -> quickCheck gen property (n - 1)
                        Just False -> pure (Just input)
                        Nothing -> quickCheck gen property (n - 1)

This is not how QuickCheck actually works (for instance, it ignores shrinking and the actual test runner machinery), but the details of that are probably left best to another post, because I want to focus on something different. The fact that the generator is not independent from the property. To be fair, that is a common requirement, you cannot just randomly sample data in the hopes of running into interesting inputs, you need to be aware of the system under test. But we have a more pressing situation here, the generator runs computations that are seemingly not related to the random generation itself. The foldl createTable ... call runs with the database to add the generated tables to it. This is in contrast to our usual mental model of a random generator which makes some random decisions to construct some datatype.

Instead here, the Database is too complex to generate from scratch, so we generate a really simple version and use its own API for constructing it. While we're at it, we could go even further. The generator already returns the (db, table) pair, which then the property checks if hasTable db table to detect validity. We know that for this specific generator, the db always has the table, so we can just remove it. For other generators, we could just add it as a check within the generator and make the generator partial instead of total, make the property total instead of partial. In the example below, the generator returns Gen (Maybe ...) instead of plain Gen, and the property returns Bool instead of Maybe Bool because the hasTable check has moved into the generator.

gen :: Gen (Maybe (Database, String, [Value]))
gen = do
    db <- arbitrary :: Gen Database
    table <- arbitrary :: Gen String
    if hasTable db table then do
        values <- arbitrary :: Gen [Value]
        return $ Just (db, table, values)
    else
        return Nothing

prop_insert_select :: (Database, String, [Value]) -> Bool
prop_insert_select (db, table, values) =
    let insert = Insert table values
        select = Select table "*"
    in
        values `elem` query (execute db insert) select

If we can move some part of the property into the generator, can't we do that with the rest?

gen :: Gen (Maybe Bool)
gen = do
    db <- arbitrary :: Gen Database
    table <- arbitrary :: Gen String
    if hasTable db table then do
        values <- arbitrary :: Gen [Value]
        let insert = Insert table values
            select = Select table "*"
        in
            return $ Just (values `elem` query (execute db insert) select)
    else
        return Nothing

Voila, we're now back to the original property-only version of the code, only now we're in the generator land, so we can actually change the generation inline without separating it into two parts and rewriting something from scratch. QuickCheck already has support for this style of property-based test writing without making you write your property under Gen:

prop_insert_select :: Property
prop_insert_select =
    forAll arbitrary $ \db ->
    forAll arbitrary $ \table ->
    hasTable db table ==>
    forAll arbitrary $ \values ->
        let insert = Insert table values
            select = Select table "*"
        in
            values `elem` query (execute db insert) select

In this style, every forAll combinator takes a generator and produces a context with access to the generated value, which while hiding the fact that you're working with generators, allow you to write dependent generators without hassle. Here, the property is not a function that returns a boolean, it's the test that captures the generation as well as the assertion. To be fair, nothing I wrote here is revolutionary, forAll has been a part of QuickCheck for the past 26 years, every person writing PBTs is aware of the fact that property-based tests aren't properties and generators completely separated from each other, but rather a combination of the two. The Hypothesis intro puts it especially well from a practical perspective:

you write tests which should pass for all inputs in whatever range you describe, and let Hypothesis randomly choose which of those inputs to check

Essentially, Property-Based Tests leverage properties as universally quantified statements about the program under test, but many times, they cannot use them without breaking the abstraction boundaries. Thinking about this is especially important as we implement libraries, for instance, the Rust port of Haskell's QuickCheck, quickcheck gets the abstraction boundary wrong.

Its quicktest expects a Testable instance, which has a function fn result(&self, _: &mut Gen) -> TestResult. This instance is implemented for tuples of up to 8 elements as follows:

impl<T: Testable,
     $($name: Arbitrary + Debug),*> Testable for fn($($name),*) -> T {
    #[allow(non_snake_case)]
    fn result(&self, g: &mut Gen) -> TestResult {
        let self_ = *self;
        let a: ($($name,)*) = Arbitrary::arbitrary(g);
        let ( $($name,)* ) = a.clone();
        let mut r = safe(move || {self_($($name),*)}).result(g);

        if r.is_failure() {
            let mut a = a.shrink();
            while let Some(t) = a.next() {
                let ($($name,)*) = t.clone();
                let mut r_new = safe(move || {self_($($name),*)}).result(g);
                if r_new.is_failure() {
                    {
                        let ($(ref $name,)*) : ($($name,)*) = t;
                        r_new.arguments = Some(debug_reprs(&[$($name),*]));
                    }

                                                            r = r_new;

                                                            a = t.shrink()
                }
            }
        }

        r
    }

Where the line let a: ($($name,)*) = Arbitrary::arbitrary(g); calls the arbitrary for the input tuple and passes it to the property that computes the result in let mut r = safe(move || {self_($($name),*)}).result(g);. The design here assumes the exact boundary we just said was inadequate because computations might need to be interleaved with the generation. Proptest, as far as I can tell, makes the same decision. The comparison against QuickCheck mentions differences that I personally do not find that much impactful, while not mentioning the capability that this blog post focuses on. The newly introduced Hegel keeps the tc: TestCase argument that allows for mixing generation with the test case that allows the original functionality in Haskell via a different mechanism that Hypothesis has been using for some time.

The article wasn't really meant to lead to a particular result, but rather to explore what we need expressible in a PBT library in general. I hope it's been fun to read and insightful, if you have any objections, I would love to discuss them over at [email protected].

My related work on the topic:

联系我们 contact @ memedata.com