SQLite 的可靠性和健壮性部分得益于彻底而细致的测试。
截至 3.42.0 版(2023-05-16),SQLite 库由大约 155.8 KSLOC 的 C 代码组成。(KSLOC 表示“代码源行数”的千位数,换句话说,就是不包括空行和注释的代码行数。)相比之下,该项目拥有 590 倍的测试代码和测试脚本 - 92053.1 KSLOC。
用于测试 SQLite 核心库的四个独立测试框架。每个测试框架的设计、维护和管理都独立于其他框架。
TCL 测试是 SQLite 的原始测试。它们与 SQLite 核心包含在同一个源代码树中,并且像 SQLite 核心一样属于公有领域。TCL 测试是开发过程中使用的主要测试。TCL 测试使用 TCL 脚本语言编写。TCL 测试框架本身由 27.2 KSLOC 的 C 代码组成,用于创建 TCL 接口。测试脚本包含在 1390 个文件中,总大小为 23.2MB。共有 51445 个不同的测试用例,但许多测试用例是参数化的,并且运行多次(使用不同的参数),因此在完整测试运行中会执行数百万次单独测试。
TH3 测试框架是一组专有的测试,用 C 语言编写,为 SQLite 核心库提供 100% 的分支测试覆盖率(以及 100% 的 MC/DC 测试覆盖率)。TH3 测试旨在在嵌入式和专用平台上运行,这些平台不容易支持 TCL 或其他工作站服务。TH3 测试仅使用已发布的 SQLite 接口。TH3 由大约 76.9 MB 或 1055.4 KSLOC 的 C 代码组成,实现了 50362 个不同的测试用例。但是,TH3 测试是高度参数化的,因此完整覆盖测试运行大约 240 万个不同的测试实例。提供 100% 分支测试覆盖率的用例构成 TH3 测试套件的子集。发布前的浸泡测试大约运行 2.485 亿次测试。有关 TH3 的更多信息,请单独查看。
SQL 逻辑测试 或 SLT 测试框架用于对 SQLite 和其他几个 SQL 数据库引擎运行大量 SQL 语句,并验证它们是否都得到相同的答案。SLT 目前将 SQLite 与 PostgreSQL、MySQL、Microsoft SQL Server 和 Oracle 10g 进行比较。SLT 运行 720 万个查询,包含 1.12GB 的测试数据。
dbsqlfuzz 引擎是一个专有的模糊测试器。其他 用于 SQLite 的模糊测试器 会修改 SQL 输入或数据库文件。Dbsqlfuzz 同时修改 SQL 和数据库文件,因此能够达到新的错误状态。Dbsqlfuzz 使用 LLVM 的 libFuzzer 框架和自定义变异器构建。有 336 个种子文件。dbsqlfuzz 模糊测试器每天运行大约 10 亿次测试变异。Dbsqlfuzz 有助于确保 SQLite 对通过恶意 SQL 或数据库输入进行的攻击具有鲁棒性。
除了四个主要测试框架外,还有许多其他小型程序实现专门的测试。以下是一些示例
在每次发布 SQLite 之前,上述所有测试都必须在多个平台和多个编译时配置下成功运行。
在每次签入 SQLite 源代码树之前,开发人员通常会运行 Tcl 测试的一个子集(称为“veryquick”),该子集包含大约 304.7 千个测试用例。“veryquick”测试包括大多数测试,但不包括异常、模糊和浸泡测试。 “veryquick”测试背后的理念是,它们足以捕获大多数错误,而且只需几分钟而不是几个小时即可运行。
异常测试旨在验证 SQLite 在出现问题时是否表现正确。构建一个 SQL 数据库引擎,使其在功能齐全的计算机上对格式良好的输入表现正确,这(相对)比较容易。构建一个能够对无效输入做出合理响应并在系统故障后继续运行的系统则更加困难。异常测试旨在验证后一种行为。
SQLite 与所有 SQL 数据库引擎一样,广泛使用 malloc()(有关详细信息,请参阅有关 SQLite 中动态内存分配 的单独报告。)在服务器和工作站上,malloc() 在实践中从未失败,因此正确处理内存不足 (OOM) 错误并不特别重要。但在嵌入式设备上,OOM 错误非常常见,并且由于 SQLite 经常用于嵌入式设备,因此 SQLite 能够优雅地处理 OOM 错误非常重要。
OOM 测试是通过模拟 OOM 错误来完成的。SQLite 允许应用程序使用 sqlite3_config(SQLITE_CONFIG_MALLOC,...) 接口替换替代的 malloc() 实现。TCL 和 TH3 测试框架都能够插入修改后的 malloc() 版本,该版本可以被设置为在一定数量的分配后失败。这些经过修改的 malloc 可以设置为仅失败一次然后重新开始工作,或者在第一次失败后继续失败。OOM 测试在一个循环中进行。在循环的第一次迭代中,经过修改的 malloc 被设置为在第一次分配时失败。然后执行一些 SQLite 操作并进行检查,以确保 SQLite 正确处理了 OOM 错误。然后,经过修改的 malloc 的失效时间计数器增加 1,并重复测试。循环继续,直到整个操作完成运行而从未遇到模拟的 OOM 故障。此类测试运行两次,一次将经过修改的 malloc 设置为仅失败一次,另一次将经过修改的 malloc 设置为在第一次失败后持续失败。
I/O 错误测试旨在验证 SQLite 是否对失败的 I/O 操作做出合理响应。I/O 错误可能是由磁盘驱动器已满、磁盘硬件故障、使用网络文件系统时的网络中断、SQL 操作过程中发生的系统配置或权限更改或其他硬件或操作系统故障引起的。无论原因是什么,SQLite 能够正确响应这些错误都很重要,而 I/O 错误测试旨在验证这一点。
I/O 错误测试在概念上类似于 OOM 测试;模拟 I/O 错误并进行检查以验证 SQLite 是否正确响应模拟的错误。在 TCL 和 TH3 测试框架中,通过插入一个新的 虚拟文件系统对象 来模拟 I/O 错误,该对象专门设计用于在一定数量的 I/O 操作后模拟 I/O 错误。与 OOM 错误测试一样,I/O 错误模拟器可以设置为仅失败一次,或者在第一次失败后持续失败。测试在一个循环中运行,逐渐增加故障点,直到测试用例在没有错误的情况下完成运行。循环运行两次,一次将 I/O 错误模拟器设置为仅模拟单个故障,另一次将 I/O 错误模拟器设置为在第一次故障后使所有 I/O 操作都失败。
在 I/O 错误测试中,在禁用 I/O 错误模拟故障机制后,使用 PRAGMA integrity_check 检查数据库,以确保 I/O 错误没有导致数据库损坏。
崩溃测试旨在证明如果应用程序或操作系统崩溃或数据库更新过程中发生断电,SQLite 数据库不会损坏。一篇名为 SQLite 中的原子提交 的单独白皮书描述了 SQLite 为防止数据库在崩溃后损坏而采取的防御措施。崩溃测试力求验证这些防御措施是否正常工作。
当然,使用实际断电进行崩溃测试是不切实际的,因此崩溃测试是在模拟中进行的。插入一个替代的 虚拟文件系统,允许测试框架模拟崩溃后数据库文件的状态。
在 TCL 测试框架中,崩溃模拟是在一个单独的进程中完成的。主测试进程会生成一个子进程,该子进程运行一些 SQLite 操作,并在写入操作的中间随机崩溃。一个特殊的 VFS 会随机重新排序和破坏未同步的写入操作,以模拟缓冲文件系统的影响。子进程终止后,原始测试进程会打开并读取测试数据库,并验证子进程尝试的更改是否已成功完成或完全回滚。使用 integrity_check PRAGMA 确保不会发生数据库损坏。
TH3 测试框架需要在不一定能够生成子进程的嵌入式系统上运行,因此它使用内存中的 VFS 来模拟崩溃。内存中的 VFS 可以被设置为在执行一定数量的 I/O 操作后创建整个文件系统的快照。崩溃测试在循环中运行。在每次循环迭代中,创建快照的点都会向前推进,直到正在测试的 SQLite 操作完成运行而不会遇到任何快照。在循环内部,在被测试的 SQLite 操作完成后,文件系统将回滚到快照,并引入随机文件损坏,这些损坏类似于断电后预期看到的损坏类型。然后打开数据库并进行检查,以确保它格式良好,并且事务已完成运行或已完全回滚。循环内部会针对每个快照重复多次,每次使用不同的随机损坏。
SQLite 的测试套件还探讨了堆叠多个故障的结果。例如,运行测试以确保在尝试从先前崩溃中恢复时发生 I/O 错误或内存不足故障时的正确行为。
模糊测试 旨在确定 SQLite 是否对无效、超出范围或格式错误的输入做出正确的响应。
SQL 模糊测试包括创建语法上正确但毫无意义的 SQL 语句,并将它们提供给 SQLite 以查看它将如何处理它们。通常会返回某种类型的错误(例如“没有这样的表”)。有时,纯粹出于偶然,SQL 语句也恰好具有语义上的正确性。在这种情况下,将运行生成的预处理语句以确保它给出合理的结果。
模糊测试的概念已经存在了几十年,但直到 2014 年 Michal Zalewski 发明了第一个实用的基于配置文件的模糊测试器 American Fuzzy Lop 或“AFL”之前,模糊测试一直都不是一种有效的查找错误的方法。与以前盲目生成随机输入的模糊测试器不同,AFL 会对被测试的程序进行检测(通过修改来自 C 编译器的汇编语言输出),并使用该检测来检测输入何时导致程序执行不同的操作 - 遵循新的控制路径或循环不同的次数。引发新行为的输入将被保留并进一步变异。通过这种方式,AFL 能够“发现”被测程序的新行为,包括设计人员从未设想过的行为。
AFL 被证明擅长查找 SQLite 中的晦涩错误。大多数发现都是断言语句,其中条件在模糊的情况下为假。但 AFL 也在 SQLite 中发现了一些崩溃错误,甚至还发现了一些 SQLite 计算结果不正确的情况。
由于其过去的成功,AFL 从 3.8.10 版本(2015-05-07)开始成为 SQLite 测试策略的标准部分,直到在 3.29.0 版本(2019-07-10)被更好的模糊测试器取代。
从 2016 年开始,Google 的一个工程师团队启动了 OSS Fuzz 项目。OSS Fuzz 使用在 Google 基础设施上运行的 AFL 样式的引导模糊测试器。模糊测试器会自动下载参与项目的最新签入,对其进行模糊测试,并向开发人员发送电子邮件报告任何问题。当修复程序签入时,模糊测试器会自动检测到这一点并向开发人员发送确认电子邮件。
SQLite 是 OSS Fuzz 测试的众多开源项目之一。SQLite 存储库中的 test/ossfuzz.c 源文件是 SQLite 与 OSS Fuzz 的接口。
OSS Fuzz 现在不再在 SQLite 中发现历史错误。但它仍在运行,并且偶尔会在新的开发签入中发现问题。例如:[1] [2] [3]。
从 2018 年底开始,SQLite 一直使用一个名为“dbsqlfuzz”的专有模糊测试器进行模糊测试。Dbsqlfuzz 是使用 LLVM 的 libFuzzer 框架构建的。
dbsqlfuzz 模糊测试器同时变异 SQL 输入和数据库文件。Dbsqlfuzz 在一个专门的输入文件上使用自定义 结构感知变异器,该文件同时定义了输入数据库和要针对该数据库运行的 SQL 文本。因为它同时变异输入数据库和输入 SQL,所以 dbsqlfuzz 能够发现 SQLite 中的一些模糊错误,这些错误以前仅变异 SQL 输入或仅变异数据库文件的模糊测试器错过了。SQLite 开发人员始终在约 16 个核心上针对主干运行 dbsqlfuzz。每个 dbsqlfuzz 程序实例每秒能够评估大约 400 个测试用例,这意味着每天大约检查 5 亿个用例。
dbsqlfuzz 模糊测试器在增强 SQLite 代码库以抵御恶意攻击方面非常成功。自从 dbsqlfuzz 被添加到 SQLite 内部测试套件以来,来自外部模糊测试器(如 OSSFuzz)的错误报告几乎全部停止了。
请注意,dbsqlfuzz **不是** Chromium 使用并在 结构感知变异器文章 中描述的基于 Protobuf 的 SQLite 结构感知模糊测试器。这两个模糊测试器之间没有联系,除了它们都基于 libFuzzer 之外。SQLite 的 Protobuf 模糊测试器由 Google 的 Chromium 团队编写和维护,而 dbsqlfuzz 由原始的 SQLite 开发人员编写和维护。SQLite 拥有多个独立开发的模糊测试器是一件好事,因为它意味着更可能发现模糊问题。
2024 年 1 月底,第二个基于 libFuzzer 的工具“jfuzz”投入使用。Jfuzz 生成损坏的 JSONB Blob 并将其馈送到 JSON SQL 函数 中,以验证 JSON 函数是否能够安全有效地处理损坏的二进制输入。
SQLite 似乎是第三方模糊测试的热门目标。开发人员了解了许多模糊测试 SQLite 的尝试,并且他们偶尔会收到独立模糊测试器发现的错误报告。所有此类报告都会得到及时修复,从而改进产品并使整个 SQLite 用户社区受益。这种拥有许多独立测试人员的机制类似于 Linus 定律:“如果有足够多的眼球,所有错误都将浅显易见”。
一位值得特别注意的模糊测试研究人员是 Manuel Rigger。大多数模糊测试器只查找断言错误、崩溃、未定义行为 (UB) 或其他易于检测的异常。另一方面,Rigger 博士的模糊测试器能够找到 SQLite 计算错误答案的情况。Rigger 已经 发现了许多此类案例。大多数这些发现都是涉及类型转换和亲和性转换的模糊极端情况,并且许多发现针对的是未发布的功能。尽管如此,他的发现仍然很重要,因为它们是真正的错误,SQLite 开发人员感谢能够识别并修复根本问题。
来自 AFL、OSS Fuzz 和 dbsqlfuzz 的历史测试用例收集在主 SQLite 源代码树中的一组数据库文件中,然后在每次运行“make test”时由“fuzzcheck”实用程序重新运行。Fuzzcheck 只运行几千个“有趣”的用例,而不是多年来各个模糊测试器检查过的数十亿个用例。“有趣”的用例是指表现出以前从未见过的行为的用例。模糊测试器发现的实际错误始终包含在有趣的测试用例中,但 fuzzcheck 运行的大多数用例从来都不是实际错误。
模糊测试和 100% MC/DC 测试 彼此之间存在张力。也就是说,经过 100% MC/DC 测试的代码往往更容易受到模糊测试发现的问题的影响,而在模糊测试期间表现良好的代码往往会具有(远低于)100% MC/DC。这是因为 MC/DC 测试不鼓励使用带有不可到达分支的 防御性代码,但如果没有防御性代码,模糊测试器更有可能找到导致问题的路径。MC/DC 测试似乎适用于构建在正常使用期间具有鲁棒性的代码,而模糊测试则适用于构建能够抵御恶意攻击的代码。
当然,用户更希望代码在正常使用时既健壮又能抵抗恶意攻击。SQLite 开发人员致力于提供这一点。本节的目的仅仅是指出同时做到这两点很困难。
在 SQLite 的大部分历史中,它一直专注于 100% MC/DC 测试。直到 2014 年引入 AFL,抵御模糊测试攻击才成为关注点。有一段时间,模糊测试器在 SQLite 中发现了许多问题。近年来,SQLite 的测试策略已经发展为更加重视模糊测试。我们仍然保持核心 SQLite 代码的 100% MC/DC,但大多数测试 CPU 周期现在都用于模糊测试。
虽然模糊测试和 100% MC/DC 测试之间存在张力,但它们并非完全背道而驰。事实上,SQlite 测试套件确实进行了 100% MC/DC 测试,这意味着当模糊测试器确实发现问题时,这些问题可以快速修复,并且几乎不会引入新的错误。
有许多测试用例验证 SQLite 是否能够处理格式错误的数据库文件。这些测试首先构建一个格式良好的数据库文件,然后通过某种方式(而不是使用 SQLite)更改文件中的一个或多个字节来添加损坏。然后使用 SQLite 读取数据库。在某些情况下,字节更改位于数据中间。这会导致数据库内容发生更改,同时保持数据库格式良好。在其他情况下,文件的未用字节被修改,这对数据库的完整性没有影响。有趣的情况是定义数据库结构的文件字节发生更改。格式错误的数据库测试验证 SQLite 是否发现了文件格式错误并使用 SQLITE_CORRUPT 返回代码报告它们,而不会溢出缓冲区、取消引用空指针或执行其他有害操作。
dbsqlfuzz 模糊测试器在验证 SQLite 对格式错误的数据库文件做出合理的响应方面也做得非常出色。
SQLite 对其操作定义了一些限制,例如表中列的最大数量、SQL 语句的最大长度或整数的最大值。TCL 和 TH3 测试套件都包含大量测试,这些测试将 SQLite 推到其定义限制的边缘,并验证它在所有允许的值下都能正确执行。其他测试超出了定义的限制,并验证 SQLite 是否正确返回错误。源代码包含测试用例宏,用于验证每个边界的两侧都已进行测试。
每当报告针对 SQLite 的错误时,只有在将显示该错误的新测试用例添加到 TCL 或 TH3 测试套件后,才认为该错误已修复。多年来,这导致了成千上万的新测试。这些回归测试可确保过去已修复的错误不会重新引入到 SQLite 的未来版本中。
资源泄漏是指系统资源被分配但从未释放。在许多应用程序中,最麻烦的资源泄漏是内存泄漏——当使用 malloc() 分配内存但从未使用 free() 释放时。但其他类型的资源也可能泄漏:文件描述符、线程、互斥量等。
TCL 和 TH3 测试工具都会自动跟踪系统资源并在每次测试运行时报告资源泄漏。无需任何特殊的配置或设置。测试工具在内存泄漏方面尤其警惕。如果更改导致内存泄漏,测试工具将很快识别出来。SQLite 旨在永远不会泄漏内存,即使在异常情况下(例如 OOM 错误或磁盘 I/O 错误)也是如此。测试工具会积极执行此操作。
SQLite 核心(包括 unix VFS)在 TH3 的默认配置下具有 100% 的分支测试覆盖率,如 gcov 所测。FTS3 和 RTree 等扩展不包含在此分析中。
有很多方法可以衡量测试覆盖率。“语句覆盖率”是最流行的指标。当您听到有人说他们的程序具有“XX% 测试覆盖率”而没有进一步解释时,他们通常指的是语句覆盖率。语句覆盖率衡量测试套件至少执行一次的代码行百分比。
分支覆盖率比语句覆盖率更严格。分支覆盖率衡量至少在两个方向上都评估一次的机器代码分支指令的数量。
为了说明语句覆盖率和分支覆盖率之间的区别,请考虑以下假设的 C 代码行
if( a>b && c!=25 ){ d++; }
这样一行 C 代码可能会生成十几条单独的机器代码指令。如果任何一条指令都被评估过,那么我们就说该语句已被测试过。因此,例如,条件表达式可能始终为假,并且“d”变量永远不会递增。即使这样,语句覆盖率也将此代码行计为已测试。
分支覆盖率更严格。使用分支覆盖率,语句中的每个测试和每个子块都会被单独考虑。为了在上面的示例中实现 100% 的分支覆盖率,必须至少有三个测试用例
上述任何一个测试用例都可以提供 100% 的语句覆盖率,但所有三个都需要 100% 的分支覆盖率。一般来说,100% 的分支覆盖率意味着 100% 的语句覆盖率,但反之则不成立。需要再次强调的是,SQLite 的 TH3 测试工具提供了更强大的测试覆盖率形式——100% 的分支测试覆盖率。
一个编写良好的 C 程序通常会包含一些在实践中始终为真或始终为假的防御性条件。这导致了一个编程难题:是否为了获得 100% 的分支覆盖率而删除防御性代码?
在 SQLite 中,前面问题的答案是“否”。出于测试目的,SQLite 源代码定义了名为 ALWAYS() 和 NEVER() 的宏。ALWAYS() 宏围绕预期始终评估为真的条件,而 NEVER() 宏围绕始终评估为假的条件。这些宏用作注释,以指示这些条件是防御性代码。在发布版本中,这些宏是直通的
#define ALWAYS(X) (X) #define NEVER(X) (X)
但是,在大多数测试期间,如果这些宏的参数没有预期的真值,则它们将引发断言错误。这会快速提醒开发人员设计假设不正确。
#define ALWAYS(X) ((X)?1:assert(0),0) #define NEVER(X) ((X)?assert(0),1:0)
在衡量测试覆盖率时,这些宏被定义为常量真值,以便它们不会生成汇编语言分支指令,因此在计算分支覆盖率时不会发挥作用
#define ALWAYS(X) (1) #define NEVER(X) (0)
测试套件设计为运行三次,每次运行都针对上面显示的 ALWAYS() 和 NEVER() 定义中的一个。所有三个测试运行都应该产生完全相同的结果。有一个使用 sqlite3_test_control(SQLITE_TESTCTRL_ALWAYS, ...) 接口的运行时测试,可用于验证宏是否已正确设置为第一种形式(直通形式)以进行部署。
另一个与测试覆盖率测量一起使用的宏是testcase()宏。参数是一个条件,我们希望测试用例评估为真和假。在非覆盖构建中(也就是说,在发布构建中),testcase()宏是一个无操作
#define testcase(X)
但在覆盖测量构建中,testcase()宏生成评估其参数中条件表达式的代码。然后在分析期间,进行检查以确保存在将条件评估为真和假的测试。Testcase()宏用于,例如,帮助验证边界值是否已测试。例如
testcase( a==b ); testcase( a==b+1 ); if( a>b && c!=25 ){ d++; }
Testcase 宏也用于两个或多个 switch 语句的情况转到相同的代码块时,以确保已为所有情况访问该代码
switch( op ){ case OP_Add: case OP_Subtract: { testcase( op==OP_Add ); testcase( op==OP_Subtract ); /* ... */ break; } /* ... */ }
对于位掩码测试,testcase()宏用于验证位掩码的每一位是否都会影响结果。例如,在以下代码块中,如果掩码包含两个位中的任何一个(表示正在打开 MAIN_DB 或 TEMP_DB),则条件为真。if 语句之前的testcase()宏验证了这两种情况都已测试。
testcase( mask & SQLITE_OPEN_MAIN_DB ); testcase( mask & SQLITE_OPEN_TEMP_DB ); if( (mask & (SQLITE_OPEN_MAIN_DB|SQLITE_OPEN_TEMP_DB))!=0 ){ ... }
SQLite 源代码包含 1184 个testcase()宏的使用。
上面描述了两种测量测试覆盖率的方法:“语句”和“分支”覆盖率。除了这两种方法之外,还有许多其他测试覆盖率指标。另一个流行的指标是“修改条件/决策覆盖率”或 MC/DC。维基百科 将 MC/DC 定义如下
在 C 编程语言中,&& 和 || 是“短路”运算符,MC/DC 和分支覆盖率几乎相同。主要区别在于布尔向量测试。可以在位向量中测试任何几个位,并且仍然获得 100% 的分支测试覆盖率,即使 MC/DC 的第二个元素(决策中的每个条件都采用所有可能的输出的要求)可能未得到满足。
SQLite 使用testcase()宏(如上一小节中所述)以确保位向量决策中的每个条件都采用所有可能的输出。通过这种方式,SQLite 除了 100% 的分支覆盖率之外,还实现了 100% 的 MC/DC。
SQLite 中的分支覆盖率目前使用 gcov 以及“-b”选项进行测量。首先使用选项“-g -fprofile-arcs -ftest-coverage”编译测试程序,然后运行测试程序。然后运行“gcov -b”以生成覆盖率报告。覆盖率报告冗长且阅读不便,因此使用一些简单的脚本处理 gcov 生成的报告,使其采用更人性化的格式。当然,整个过程都是使用脚本自动化的。
请注意,使用 gcov 运行 SQLite 并不是对 SQLite 的测试——而是对测试套件的测试。gcov 运行不会测试 SQLite,因为“-fprofile-args”和“-ftest-coverage”选项会导致编译器生成不同的代码。gcov 运行仅验证测试套件是否提供了 100% 的分支测试覆盖率。gcov 运行是对测试的测试——元测试。
在运行 gcov 以验证 100% 的分支测试覆盖率后,使用交付编译器选项(不使用特殊的“-fprofile-arcs”和“-ftest-coverage”选项)重新编译测试程序,然后重新运行测试程序。第二次运行是对 SQLite 的实际测试。
验证 gcov 测试运行和第二次实际测试运行是否都给出相同的输出非常重要。输出中的任何差异都表明 SQLite 代码中使用了未定义或不确定的行为(因此是错误),或者编译器中存在错误。请注意,在过去十年中,SQLite 遇到了 GCC、Clang 和 MSVC 中的错误。编译器错误虽然罕见,但确实会发生,这就是为什么在交付配置中测试代码如此重要的原因。
使用 gcov(或类似工具)来证明每个分支指令都至少在两个方向上都被执行过,是对测试套件质量的良好衡量。但更好的是证明每个分支指令都会对输出产生影响。换句话说,我们不仅要证明每个分支指令都会跳转和贯通,还要证明每个分支都在执行有用的工作,并且测试套件能够检测和验证该工作。当发现一个分支对输出没有影响时,这表明可以删除与该分支关联的代码(减小库的大小,并可能使其运行得更快),或者测试套件没有充分测试该分支实现的功能。
SQLite 努力使用突变测试来验证每个分支指令都会产生影响。一个脚本首先将 SQLite 源代码编译成汇编语言(例如,使用 gcc 的“-S”选项)。然后,该脚本遍历生成的汇编语言,逐一将每个分支指令更改为无条件跳转或无操作,编译结果,并验证测试套件是否捕获了突变。
不幸的是,SQLite 包含许多有助于代码运行得更快但不会更改输出的分支指令。此类分支在突变测试期间会生成误报。例如,请考虑以下哈希函数,该函数用于加速表名查找
55 static unsigned int strHash(const char *z){ 56 unsigned int h = 0; 57 unsigned char c; 58 while( (c = (unsigned char)*z++)!=0 ){ /*OPTIMIZATION-IF-TRUE*/ 59 h = (h<<3) ^ h ^ sqlite3UpperToLower[c]; 60 } 61 return h; 62 }
如果第 58 行实现“c!=0”测试的分支指令更改为无操作指令,则 while 循环将永远循环,测试套件将因超时而失败。但如果该分支更改为无条件跳转,则哈希函数将始终返回 0。问题在于 0 是一个有效的哈希值。始终返回 0 的哈希函数仍然有效,因为 SQLite 仍然始终获得正确的答案。表名哈希表退化为链表,因此在解析 SQL 语句时发生的表名查找可能会稍微慢一些,但最终结果将相同。
为了解决此问题,在 SQLite 源代码中插入了“/*OPTIMIZATION-IF-TRUE*/
”和“/*OPTIMIZATION-IF-FALSE*/
”形式的注释,以告诉变异测试脚本忽略某些分支指令。
SQLite 的开发人员发现,全覆盖测试是查找和防止错误的极其有效的方法。因为 SQLite 核心代码中的每个分支指令都由测试用例覆盖,所以开发人员可以确信在代码的一部分中进行的更改不会对代码的其他部分产生意外后果。近年来添加到 SQLite 的许多新功能和性能改进,如果没有全覆盖测试的可用性,将是不可能的。
维护 100% MC/DC 既费力又耗时。维护全覆盖测试所需的努力对于典型的应用程序来说可能不具有成本效益。但是,我们认为对于像 SQLite 这样广泛部署的基础设施库,以及对于本质上“记住”过去错误的数据库库来说,全覆盖测试是合理的。
动态分析是指在 SQLite 代码运行时执行的代码内部和外部检查。动态分析已被证明对维护 SQLite 的质量有很大帮助。
SQLite 核心包含 6754 个assert()语句,用于验证函数的前置条件和后置条件以及循环不变式。Assert() 是一个宏,它是 ANSI-C 的标准部分。参数是一个布尔值,假定始终为真。如果断言为假,程序将打印错误消息并停止。
通过定义 NDEBUG 宏进行编译,可以禁用 Assert() 宏。在大多数系统中,断言默认情况下是启用的。但在 SQLite 中,断言的数量如此之多,并且位于性能关键位置,因此当启用断言时,数据库引擎的运行速度大约慢三倍。因此,SQLite 的默认(生产)版本禁用了断言。仅当 SQLite 使用定义的 SQLITE_DEBUG 预处理器宏进行编译时,才会启用断言语句。
有关 SQLite 如何使用 assert() 的更多信息,请参阅SQLite 中 assert 的使用文档。
Valgrind 也许是世界上最令人惊奇和最有用的开发人员工具。Valgrind 是一个模拟器——它模拟运行 Linux 二进制文件的 x86。(Valgrind 针对 Linux 以外平台的移植正在开发中,但截至本文撰写之时,Valgrind 仅在 Linux 上可靠地工作,在 SQLite 开发人员看来,这意味着 Linux 应该是所有软件开发的首选平台。)当 Valgrind 运行 Linux 二进制文件时,它会查找各种有趣的错误,例如数组越界、读取未初始化的内存、堆栈溢出、内存泄漏等等。Valgrind 会发现很容易在针对 SQLite 运行的所有其他测试中都可能出现的错误。而且,当 Valgrind 确实发现错误时,它可以将开发人员直接转储到错误发生的精确位置的符号调试器中,以便快速修复。
因为它是一个模拟器,所以在 Valgrind 中运行二进制文件比在原生硬件上运行要慢。(作为第一近似值,在工作站上的 Valgrind 中运行的应用程序将执行与其在智能手机上原生运行相同的性能。)因此,通过 Valgrind 运行完整的 SQLite 测试套件是不切实际的。但是,在每次发布之前,都会通过 Valgrind 运行非常快速的测试和 TH3 测试的覆盖范围。
SQLite 包含一个可插拔的内存分配子系统。默认实现使用系统 malloc() 和 free()。但是,如果 SQLite 使用SQLITE_MEMDEBUG进行编译,则会插入一个替代的内存分配包装器(memsys2),该包装器在运行时查找内存分配错误。memsys2 包装器当然会检查内存泄漏,还会查找缓冲区溢出、使用未初始化的内存以及尝试在内存释放后使用内存。Valgrind 也执行相同的检查(实际上,Valgrind 执行得更好),但 memsys2 的优势在于它比 Valgrind 快得多,这意味着可以更频繁地执行检查,并且可以进行更长时间的测试。
SQLite 包含一个可插拔的互斥锁子系统。根据编译时选项,默认互斥锁系统包含接口sqlite3_mutex_held()和sqlite3_mutex_notheld(),用于检测特定互斥锁是否由调用线程持有。这两个接口在 SQLite 中的 assert() 语句中被广泛使用,以验证互斥锁在所有正确的时间都被持有和释放,以仔细检查 SQLite 是否在多线程应用程序中正常工作。
SQLite 为确保事务在系统崩溃和电源故障中具有原子性所做的一件事是在更改数据库之前将所有更改写入回滚日志文件。TCL 测试工具包含一个替代的OS 后端实现,有助于验证此操作是否正确。“日志测试 VFS”监视数据库文件和回滚日志文件之间所有磁盘 I/O 流量,检查以确保写入数据库文件的所有内容都已首先写入并同步到回滚日志文件。如果发现任何差异,则会引发断言错误。
日志测试是对崩溃测试的额外双重检查,以确保 SQLite 事务在系统崩溃和电源故障中具有原子性。
在 C 编程语言中,很容易编写具有“未定义”或“实现定义”行为的代码。这意味着代码可能在开发过程中工作,但在不同的系统上或使用不同的编译器选项重新编译时给出不同的答案。ANSI C 中未定义和实现定义行为的示例包括
由于未定义和实现定义的行为不可移植,并且很容易导致错误的答案,因此 SQLite 非常努力地避免它。例如,在将两个整数列值加在一起作为 SQL 语句的一部分时,SQLite 不会简单地使用 C 语言“+”运算符将它们加在一起。相反,它首先检查以确保加法不会溢出,如果会溢出,则使用浮点数进行加法。
为了帮助确保 SQLite 不使用未定义或实现定义的行为,测试套件使用尝试检测未定义行为的检测版本重新运行。例如,测试套件使用 GCC 的“-ftrapv”选项运行。并且它们使用 Clang 上的“-fsanitize=undefined”选项再次运行。并在 MSVC 中再次使用“/RTC1”选项。然后使用“-funsigned-char”和“-fsigned-char”等选项重新运行测试套件,以确保实现差异也不重要。然后在 32 位和 64 位系统以及大端和小端系统上重复测试,使用各种 CPU 架构。此外,测试套件中添加了许多故意设计为引发未定义行为的测试用例。例如:“SELECT -1*(-9223372036854775808);”。
sqlite3_test_control(SQLITE_TESTCTRL_OPTIMIZATIONS, ...) 接口允许在运行时禁用选定的 SQL 语句优化。SQLite 始终应在启用优化和禁用优化的情况下生成完全相同的答案;答案只是在优化打开时更快地到达。因此,在生产环境中,始终保持优化打开(默认设置)。
SQLite 使用的一种验证技术是运行整个测试套件两次,一次启用优化,第二次禁用优化,并验证是否获得相同的输出。这表明优化不会引入错误。
并非所有测试用例都可以通过这种方式处理。某些测试用例检查以验证优化是否确实通过计算磁盘访问次数、排序操作、完全扫描步骤或查询期间发生的其它处理步骤来减少计算量。当禁用优化时,这些测试用例似乎会失败。但是,大多数测试用例只是检查是否获得了正确的答案,并且所有这些用例都可以在启用和禁用优化的情况下成功运行,以表明优化不会导致故障。
SQLite 开发人员使用在线清单来协调测试活动,并在每次 SQLite 发布之前验证所有测试都通过。过去的清单被保留以供历史参考。(对于匿名的互联网查看者来说,清单是只读的,但开发人员可以在他们的网络浏览器中登录并更新清单项目。)SQLite 测试和其他开发活动的清单使用受到 清单宣言 的启发。
最新的清单包含大约 200 个项目,这些项目在每次发布时都会单独进行验证。某些清单项目只需几秒钟即可验证和标记。其他项目涉及运行数小时的测试套件。
发布清单不是自动化的:开发人员手动运行清单中的每个项目。我们发现让一个人参与其中很重要。有时,即使测试本身通过,在运行清单项目时也会发现问题。让一个人在最高级别审查测试输出并不断询问“这真的正确吗?”非常重要。
发布清单在不断发展。随着发现新的问题或潜在问题,会添加新的清单项目以确保这些问题不会出现在后续版本中。发布清单已被证明是在发布过程中帮助确保没有遗漏任何内容的宝贵工具。
静态分析意味着在编译时分析源代码以检查正确性。静态分析包括编译器警告消息和更深入的分析引擎,例如Clang 静态分析器。SQLite 在 GCC 和 Clang 上使用 Linux 和 Mac 上的 -Wall 和 -Wextra 标志以及 Windows 上的 MSVC 编译时没有任何警告。Clang 静态分析器工具“scan-build”也不会生成任何有效的警告(尽管 clang 的最新版本似乎会生成许多误报)。尽管如此,其他静态分析器可能会生成一些警告。鼓励用户不要过分强调这些警告,而是要从上面描述的 SQLite 的密集测试中获得慰藉。
静态分析在查找 SQLite 中的 bug 方面并没有起到作用。静态分析确实在 SQLite 中找到了一些 bug,但这些只是例外情况。为了消除编译警告而引入 SQLite 的 bug 比静态分析找到的 bug 还要多。
SQLite 是开源的。这使得许多人认为它不像商业软件那样经过充分测试,并且可能不可靠。但这种印象是错误的。SQLite 在实际应用中表现出非常高的可靠性和非常低的缺陷率,尤其考虑到它快速发展的速度。SQLite 的质量部分归功于谨慎的代码设计和实现。但广泛的测试也在维护和提高 SQLite 的质量方面发挥着至关重要的作用。本文档总结了每个 SQLite 版本都经历的测试流程,希望能够增强人们对 SQLite 适用于关键任务应用程序的信心。
此页面最后修改于 2024-03-13 17:43:35 UTC