CppCon 2015 学习:RapidCheck Property based testing for C++
也是介绍一个什么库
RapidCheck 是一个用于 C++ 的基于属性的测试库。它帮助开发人员自动化地生成输入数据,并且验证这些数据是否满足预定的属性或条件。与传统的单元测试不同,属性测试关注的是验证程序的行为,而不是检查具体的输出值。
基本概念:
- 属性测试(Property-Based Testing):通过定义一组属性(如某些条件或规则)来描述程序的预期行为。测试框架将自动生成大量的随机数据,并验证这些数据是否能满足属性要求。
例如,假设我们有一个排序函数,它的属性应该是“排序后的数组应该是非递减的”。 - 快速生成测试数据:
RapidCheck
会自动生成各种随机数据,并测试这些数据是否符合预期的属性。这些数据往往包括边界情况、极端情况,甚至是常规情况。 - 自动化失败案例:如果某个属性未通过,
RapidCheck
会尽量缩小失败输入的范围,并给出最小的失败示例,帮助开发人员快速定位问题。
如何使用 RapidCheck
- 安装:
- 使用
RapidCheck
需要首先将其集成到项目中,可以通过源代码方式或通过包管理器(例如vcpkg
)来安装。
- 使用
- 编写属性测试:
- 属性测试一般定义一个符合条件的规则,然后让框架通过生成随机数据来验证这个规则。例如,我们希望验证一个排序算法:
以上代码定义了一个属性:“排序一个数组应该得到一个已排序的数组”。#include <rapidcheck.h> #include <vector> #include <algorithm> bool is_sorted(const std::vector<int>& vec) {return std::is_sorted(vec.begin(), vec.end()); } // 定义属性 rc::prop("Sorting a vector should result in a sorted vector", [](const std::vector<int>& vec) {std::vector<int> copy = vec; // 复制数据std::sort(copy.begin(), copy.end()); // 排序return is_sorted(copy); // 验证是否排序正确 });
RapidCheck
会生成随机的整数数组并自动验证这个属性。 - 运行测试:
- 使用
rapidcheck::check
或其他命令运行这些属性测试,RapidCheck
会尝试各种随机的输入数据来测试这些属性,并报告任何不符合条件的情况。
rc::check("Sorting a vector should result in a sorted vector", [](const std::vector<int>& vec) {std::vector<int> copy = vec;std::sort(copy.begin(), copy.end());return is_sorted(copy); });
- 使用
- 测试失败时的自动简化:
- 如果某个测试失败,
RapidCheck
会自动简化失败的输入数据,找到导致问题的最小示例,以便快速定位问题。
- 如果某个测试失败,
优势
- 高效的边界测试:
RapidCheck
自动生成各种边界数据、随机数据,确保代码在极端情况下也能正常工作。 - 更少的样本,更多的覆盖:通过属性测试,可以避免手动编写大量不同的测试用例,且覆盖范围较大。
- 快速定位问题:失败时提供最小的输入案例,帮助快速定位问题。
总结
RapidCheck 是一种高效的、基于属性的测试方法,它通过生成随机数据来验证代码是否符合预定的行为特性。它不仅可以发现边界情况和极端输入,还能帮助开发者快速定位错误,非常适合用于验证复杂逻辑和算法的正确性。
这段代码是一个典型的 单元测试(Unit Testing)示例,使用了 C++ 测试框架 Catch2。它用来验证一个函数 concat
的功能是否符合预期。我们来逐行分析:
代码分析:
TEST_CASE("concatenates two strings") {const auto s = concat("foo", "bar");REQUIRE(s == "foobar");
}
TEST_CASE("concatenates two strings")
:- 这是 Catch2 测试框架的一个宏,用于定义一个测试用例。
"concatenates two strings"
是这个测试用例的描述,帮助我们理解这个测试的目的。TEST_CASE
的作用是将一个特定的测试逻辑封装成一个单元,以便于运行时进行执行。
const auto s = concat("foo", "bar");
:- 这里调用了一个假设存在的函数
concat
,传入两个字符串"foo"
和"bar"
。 concat
的作用应该是将这两个字符串连接起来(即 “foo” 和 “bar” 连接为 “foobar”)。const auto s
使用了auto
关键字,表示s
的类型是由编译器自动推导的,假设concat
返回的是一个std::string
类型,所以s
是一个字符串。
- 这里调用了一个假设存在的函数
REQUIRE(s == "foobar");
:REQUIRE
是 Catch2 框架提供的断言宏。它的作用是检查一个条件是否为真。- 在这个例子中,它检查
s
(即concat("foo", "bar")
的结果)是否等于"foobar"
。 - 如果条件不成立(即
s
不等于"foobar"
),测试会失败,且框架会报告错误。 - 如果条件成立,测试会通过,且不做任何报告。
测试逻辑:
- 测试的目的是验证
concat
函数的正确性。具体来说,测试它是否能够正确地将两个字符串 “foo” 和 “bar” 连接成一个字符串 “foobar”。 REQUIRE
断言确保了concat("foo", "bar")
返回的结果是"foobar"
,如果不是,测试就会失败。
总结:
这段代码是一个简单的单元测试示例,目的是确保 concat
函数正确地将两个字符串连接成一个新的字符串。测试框架会自动运行这个测试,并根据 REQUIRE
的条件判断测试是否通过。如果通过,测试成功;如果失败,测试框架会给出详细的错误信息。
这种单元测试方法在软件开发中非常常见,用来验证小的功能单元(如函数)是否按照预期工作。
这段代码是一个单元测试的例子,使用的是 C++ 测试框架 Catch2。它验证了一个 concat
函数是否能正确地将两个字符串连接起来。我们来逐行分析:
代码分析:
TEST_CASE("given 'foo' and 'bar',"" yields 'foobar'") { const auto s = concat("foo", "bar"); REQUIRE(s == "foobar");
}
TEST_CASE("given 'foo' and 'bar'," " yields 'foobar'")
:- 这是 Catch2 测试框架的
TEST_CASE
宏,用于定义一个测试用例。 "given 'foo' and 'bar', yields 'foobar'"
是该测试用例的描述文本。- 测试用例描述了输入是两个字符串
'foo'
和'bar'
,期望的输出是'foobar'
。 - 注意,字符串的分割是允许的,两个字符串会连接成一个完整的描述:
"given 'foo' and 'bar', yields 'foobar'"
。
- 测试用例描述了输入是两个字符串
- 这是 Catch2 测试框架的
const auto s = concat("foo", "bar");
:- 这行代码调用了一个函数
concat
,传入两个字符串"foo"
和"bar"
。 concat
函数的目标是将这两个字符串连接在一起,假设它返回一个std::string
,因此s
会保存这个连接后的字符串。- 使用
auto
关键字,编译器会推断出s
的类型,这里推断为std::string
。
- 这行代码调用了一个函数
REQUIRE(s == "foobar");
:REQUIRE
是 Catch2 提供的一个断言宏,用于验证给定的条件是否为真。- 在此例中,它检查
s
的值是否等于"foobar"
,即检查concat("foo", "bar")
是否正确返回"foobar"
。 - 如果
s == "foobar"
为假,测试会失败,并且测试框架会报告错误。
测试逻辑:
- 测试用例的目的是验证
concat
函数的行为,即它能否正确地将两个输入字符串"foo"
和"bar"
连接成"foobar"
。 - 如果
s
的值不是"foobar"
,测试就会失败,框架会输出错误信息;如果值相等,测试成功。
总结:
这段代码定义了一个简单的单元测试,目的是验证 concat
函数将 "foo"
和 "bar"
合并成 "foobar"
是否正常工作。测试框架会自动运行该测试,并判断 REQUIRE
的条件是否成立。
- 测试用例的描述
"given 'foo' and 'bar', yields 'foobar'"
清楚地说明了输入和期望的输出。 - 这种写法使得测试用例更具可读性和自文档化性质,任何人查看时都能清楚地知道测试的目标和期望行为。
这种单元测试方法广泛应用于软件开发中,帮助开发者确保函数的正确性,并可以方便地自动化运行和验证。
这段代码展示了如何通过属性测试来验证 concat
函数的行为。让我们逐行分析并解释其背后的逻辑。
背景:
你要验证的属性是:给定两个输入字符串 a
和 b
,经过 concat(a, b)
函数的连接后,返回的字符串 c
应该满足以下条件:
c
以a
开头c
以b
结尾c.size()
等于a.size() + b.size()
这个属性可以作为一个测试条件来验证concat
函数的正确性。
代码分析:
bool property(const std::string &a, const std::string &b) { const auto c = concat(a, b); return c.size() == a.size() + b.size();
}
bool property(const std::string &a, const std::string &b)
:- 这个函数接受两个
std::string
类型的参数a
和b
,返回一个bool
类型的值,表示给定的属性是否成立。 - 这是一个检查属性的函数,它验证
concat(a, b)
是否满足我们设定的条件。
- 这个函数接受两个
const auto c = concat(a, b);
:- 这里调用了
concat
函数,将a
和b
作为参数传入,并将返回的结果存储在变量c
中。 c
是std::string
类型,它保存了两个字符串a
和b
连接后的结果。
- 这里调用了
return c.size() == a.size() + b.size();
:- 这行代码检查
c
的大小是否等于a
和b
的大小之和,即:c.size()
是否等于a.size() + b.size()
。 - 如果条件成立,说明
concat
函数正确地连接了两个字符串,否则返回false
。
- 这行代码检查
属性的含义:
在属性测试中,我们定义了一个 属性,它描述了 concat
函数的期望行为。这个属性包括:
c
必须具有a
和b
的总长度:c.size() == a.size() + b.size()
。- 通过这个属性,我们可以对
concat
函数进行验证,确保它在不同输入情况下的行为是符合预期的。
如何使用:
你可以通过将 property
函数与不同的字符串 a
和 b
配合使用,进行多次测试。例如:
TEST_CASE("concat returns correct size") {REQUIRE(property("foo", "bar")); // Should return trueREQUIRE(property("hello", "world")); // Should return trueREQUIRE(property("", "empty")); // Should return trueREQUIRE(property("abc", "")); // Should return true
}
通过这种方式,你能够自动化地测试 concat
函数,确保它始终返回正确大小的连接字符串。
总结:
- 这段代码定义了一个属性测试函数
property
,用于验证concat
函数是否能按预期连接两个字符串并返回正确的字符串长度。 - 属性测试是一种有力的测试方法,可以帮助你在多个输入上确保函数行为一致,并捕捉潜在的错误。
如何让我们信服?
- 尝试随机的东西:可以通过随机输入来测试程序的行为,这是一个探索性的测试方式。
- 折中的方法:介于全面测试(exhaustive testing)和“我能忍受写多少就写多少”之间的一种平衡。
- 确实有效:这种方法是有效的,能够帮助你发现潜在的 bug。
QuickCheck
- QuickCheck 是一个轻量级的工具,最早用于 Haskell 程序的随机测试(Koen Claessen 和 John Hughes,ICFP 2000)。
- QuickCheck 的一个重要示例代码:
prop_concatsize a b = length (concat a b) == length a + length b
- 这个例子展示了如何通过属性测试来确保
concat
函数的行为正确:连接两个字符串后,其长度应等于两个字符串长度的总和。
我创建了 RapidCheck
- RapidCheck 是我基于 Haskell/Erlang 中 QuickCheck 的基本概念所创建的,感谢 Hughes 和 Claessen 的贡献。
- 特点:
- 非常少的样板代码(boilerplate)。
- 功能丰富,包括:
- 多种生成器(generators)和组合器(combinators)。
- 测试用例收缩(test case shrinking)。
- 有状态的测试框架(stateful testing framework)。
RapidCheck 的属性测试
- 在 RapidCheck 中,可以定义类似于 Haskell 中的属性:
bool property(const std::string &a, const std::string &b) { const auto c = concat(a, b); return c.size() == a.size() + b.size();
}
这个 property
函数和 Haskell 示例类似,验证 concat
函数的输出字符串大小是否正确。
使用 RapidCheck 运行测试
- 使用 RapidCheck 来运行测试非常简单:
rc::check(&property);
或者可以直接传递一个 lambda 函数:
rc::check([](const std::string &a, const std::string &b) { const auto c = concat(a, b); return c.size() == a.size() + b.size();
});
测试结果
- Falsifiable after 21 tests and 17 shrinks:
- 经过 21 次测试和 17 次收缩后,发现测试失败。
- 错误的输入是一个
std::tuple<std::string, std::string>
类型,其中的值是:("", "aaaaaaaaaaaaaaaa")
。
这里的意思是,当concat
被输入一个空字符串和一个较长的字符串时,它没有按预期工作。
总结:
- RapidCheck 是一个基于属性的随机测试工具,它借鉴了 Haskell 中 QuickCheck 的思想。通过定义属性(如
concat(a, b)
的大小属性)来验证代码的正确性。 - 通过这种方法,你能够发现测试用例中不易察觉的潜在错误,并且不需要编写大量的测试代码。
Shrinking(收缩)
- 在属性测试中,“收缩”是一个重要概念。它指的是在测试过程中,当发现某些输入导致错误时,工具会不断地尝试简化输入数据,以便找出 最小 的触发错误的输入。这有助于开发者更快定位并修复问题。
- 你提供的数字列表经过收缩处理后,从最初复杂的情况缩减到了
77567
,然后进一步缩小到65536
,这意味着该测试用例是通过某个非常特定的、最小的输入数据触发的。
复杂案例 vs. 简单案例
- 复杂案例(Complex case):原始输入数据看起来是很大的数据集合,包含了多种数字。测试系统对这些输入数据进行了多次“收缩”,最终找到了一个 最小的输入,即
77567
,然后又缩小到65536
。通过这种方式,可以最小化问题并加速调试过程。 - 最小案例(Minimal case):这个过程是测试的精髓,因为它能帮助开发者关注到具体触发错误的最小条件,从而避免在庞大的数据中迷失方向。
属性测试的优势
- 能够发现你未曾考虑到的 bug:由于属性测试依赖随机生成的输入数据,它能覆盖到你可能没有考虑到的边界情况和特定条件,从而发现潜在的 bug。
- 少量代码实现更多覆盖:通过定义一些简单的属性测试,你可以获得比传统单元测试更多的测试覆盖,且测试代码量更少。
- 最小反例:当发现错误时,属性测试会给出最小的反例。这意味着你只需关注一小段数据,而不需要处理庞大的测试输入,这可以大大提高调试效率。
- 帮助你思考代码的目的,而不是行为:属性测试让你专注于代码应该做什么,而不是它现在具体做了什么。这有助于你在设计时从更高层次考虑程序的正确性和稳定性。
总结:
- 收缩(Shrinking) 是属性测试中的一个关键功能,它帮助开发者从复杂的输入数据中找到最小的错误触发条件。
- 属性测试的优势在于它能够覆盖到更多的情况,发现更多潜在的 bug,而且通过最小反例,调试更加高效。它促使开发者思考代码的意图和目的,而不仅仅是当前的行为。
RapidCheck的生成器
在 RapidCheck 中,所有生成的数据都来自 生成器。生成器用于为测试生成输入数据,是支持自定义类型的关键点。
内置支持的类型
RapidCheck 支持多种常见的数据类型和容器:
- 基本数据类型:如整型、浮动点数等
- 标准容器:如
std::array<T, N>
,std::vector<T>
,std::deque<T>
,std::list<T>
,std::set<T>
,std::map<K, V>
, 等等 - 其他类型:
std::chrono::time_point
std::chrono::duration
boost::optional<T>
std::pair<T1, T2>
std::tuple<Ts...>
std::basic_string<T>
等等。
常用生成器函数
RapidCheck 提供了一系列生成器,用于生成不同类型的数据。以下是一些常见的生成器:
gen::positive
:生成正整数gen::container
:生成容器(如std::vector
)gen::suchThat
:根据给定条件筛选生成的数据gen::map
:将一个生成器生成的数据应用一个函数gen::unique
:生成唯一的元素gen::oneOf
:从多个选项中随机选择一个gen::inRange
:生成指定范围内的数值gen::character
:生成字符gen::string
:生成字符串
示例:
- 生成正整数:
using namespace rc; const auto myGen = gen::positive<int>();
- 生成正整数的
std::vector
:const auto myGen = gen::container<std::vector<int>>(gen::positive<int>());
- 仅生成偶数长度的
std::vector<int>
:const auto myGen = gen::suchThat(gen::container<std::vector<int>>(gen::positive<int>()),[](const auto &v) { return (v.size() % 2) == 0; } );
- 将生成的整数数组连接为字符串:
const auto myStringGen = gen::map(myGen, [](const auto &v) { return joinElements(v, ", "); } );
状态测试(Stateful Testing)
- 有时候,代码并不是纯函数,而是有状态的,这时候输入数据成为一系列的操作。
- 状态测试的关键是通过模拟操作序列并验证与模型的符合程度。通过这种方式,可以测试具有副作用的函数。
测试实例:
以下是一些用 RapidCheck 进行的测试,展示了如何使用它来验证复杂的逻辑,像是 Spotify 的播放器系统:
- 失败的测试用例:
在一系列操作后,发现了预期值与实际值的不匹配,最终通过RC_ASSERT
输出了具体的错误:
这一错误显示,播放器在播放两个不同的音轨时,返回的音轨 ID 应该是不同的,但实际上它们相同。RC_ASSERT(track == expected_track);
在Spotify的学习与收获
- 高覆盖率,少量代码:通过使用属性测试,能够用少量的代码实现高覆盖率,快速发现潜在问题。
- 处理复杂情况:RapidCheck 使得测试非常复杂的功能变得可行。尤其是那些需要大量模拟和状态操作的场景,属性测试在这些领域非常有价值。
- 促使深入思考:它促使开发者更深入地思考程序应该如何工作,而不仅仅是它当前的行为。这有助于发现和修复潜在的设计问题。
- 意外的 bug:在已有的代码中,使用属性测试发现了很多令人惊讶的 bug,这些 bug 可能在传统单元测试中没有被注意到。
总结:
RapidCheck 是一个强大的 C++ 库,可以帮助开发者进行属性测试,自动生成各种类型的数据,进行高效的错误检查。通过状态测试、属性测试和高覆盖率的方式,能够大大提高代码的质量和健壮性。它不仅可以处理简单的功能,还能处理复杂的操作序列,帮助开发者发现意想不到的 bug。