目录

How SQLite Is Tested (SQLite 是怎么测试的)

目录

本文为摘录(或转载),侵删,原文为: -https://sqlite.org/testing.html#defcode -https://sqlite.org/testing.html

1 Introduction 简介

SQLite 的可靠性和稳健性部分得益于彻底而细致的测试。

截至版本 3.42.0(2023 年 5 月 16 日),SQLite 库大约包含 155.8 千行的 C 代码。(千行源代码即"KSLOC",指的是不包括空行和注释的代码行数。) 相比之下,该项目的测试代码和测试脚本的数量达到了其的 590 倍,约为 92053.1 千行。

1.1 Executive Summary 执行摘要

  • 四个独立开发的测试工具
  • 在实际部署配置下的 100%分支测试覆盖率
  • 数百万个测试用例
  • 内存溢出测试
  • I/O 错误测试
  • 崩溃和断电测试
  • 模糊测试
  • 边界值测试
  • 禁用优化的测试
  • 回归测试
  • 格式错误的数据库测试
  • 广泛使用 assert()和运行时检查
  • Valgrind 分析
  • 未定义行为检查
  • 检查清单

2 Test Harnesses 测试工具套件

用于测试核心 SQLite 库的有四个独立的测试工具。每个测试工具都是独立设计、维护和管理的,与其他工具相互分开。

  1. TCL 测试
    TCL 测试是 SQLite 的原始测试。它们与 SQLite 核心位于同一源代码树中,并且像 SQLite 核心一样,属于公共领域。TCL 测试是开发过程中使用的主要测试。这些测试使用 TCL 脚本语言编写。TCL 测试工具本身由 27.2 千行的 C 代码构成,用于创建 TCL 接口。测试脚本包含在 1390 个文件中,总大小为 23.2MB。共有 51445 个不同的测试用例,但许多测试用例都是参数化的,并且会多次运行(使用不同的参数),因此在完整的测试运行中会执行数百万个独立的测试。

  2. TH3 测试
    TH3 测试工具是一组专有测试,采用 C 语言编写,为核心 SQLite 库提供 100%的分支测试覆盖率(以及 100%的 MC/DC 测试覆盖率)。 TH3 测试旨在运行在嵌入式和专业平台上,这些平台不易支持 TCL 或其他工作站服务。TH3 测试仅使用已发布的 SQLite 接口。 TH3 包含约 76.9MB 或 1055.4 千行的 C 代码,实现了 50362 个独立的测试用例。不过,TH3 测试 heavily 参数化,因此完整覆盖的测试运行约需要 240 万个不同的测试实例。提供 100%分支测试覆盖率的测试案例构成了总 TH3 测试套件的一个子集。在发布之前的浸泡测试约进行 2.485 亿次测试。关于 TH3 的更多信息另有提供

  3. The SQL Logic Test or SLT 测试
    SQL 逻辑测试用于对 SQLite 以及其他几种 SQL 数据库引擎执行大量 SQL 语句,并验证它们是否返回相同的结果。SLT 目前将 SQLite 与 PostgreSQL、MySQL、Microsoft SQL Server 和 Oracle 10g 进行比较。SLT 执行 720 万个查询,测试数据总大小为 1.12GB。

  4. dbsqlfuzz 引擎
    dbsqlfuzz 引擎是一款专有的模糊测试工具。其他针对 SQLite 的模糊测试器要么对 SQL 输入进行变异,要么对数据库文件进行变异。而 dbsqlfuzz 同时对 SQL 和数据库文件进行变异,从而能够触发新的错误状态。 dbsqlfuzz 是基于 LLVM 的 libFuzzer 框架构建的,并配备了自定义变异器。该工具有 336 个种子文件。 dbsqlfuzz 模糊测试器每天进行约十亿次测试变异。 dbsqlfuzz 有助于确保 SQLite 能够抵御通过恶意 SQL 或数据库输入发起的攻击。

除了四个主要测试工具外,还有许多其他小程序实现了专门的测试。以下是一些示例:

  1. “speedtest1.c” 程序用于估算 SQLite 在典型工作负载下的性能。
  2. “mptester.c” 程序是一个压力测试,用于评估多个进程同时读取和写入单一数据库的能力。
  3. “threadtest3.c” 程序是一个压力测试,用于评估多个线程同时使用 SQLite 的情况。
  4. “fuzzershell.c” 程序用于运行一些模糊测试。
  5. “jfuzz” 程序是一个基于 libfuzzer 的模糊测试工具,用于测试 JSON SQL 函数的 JSONB 输入。

在每次 SQLite 发布之前,所有上述测试必须在多个平台和多种编译配置下成功运行。

在每次提交到 SQLite 源代码树之前,开发人员通常运行一组子集(称为“veryquick”)的 Tcl 测试,大约包含 304.7 千个测试用例。veryquick 测试包括大部分除异常、模糊和浸泡测试之外的测试。veryquick 测试的理念是,它们足够覆盖大多数错误,并且只需几分钟而非几个小时即可完成运行。

3 Anomaly Testing 异常测试

异常测试是用于验证 SQLite 在出现问题时是否表现正确的测试。在一个功能齐全的计算机上,构建一个对格式正确的输入能正确响应的 SQL 数据库引擎是相对容易的。然而,构建一个能够对无效输入做出合理响应并在系统故障后继续正常工作的系统就要困难得多。异常测试的目的就是验证后者的行为。

3.1 Out-Of-Memory Testing 内存溢出测试

SQLite 与所有 SQL 数据库引擎一样,广泛使用 malloc() 。在服务器和工作站上, malloc() 在实际使用中几乎不会失败,因此对于内存不足(OOM)错误的正确处理并不是特别重要。但是在嵌入式设备上,OOM 错误异常常见,考虑到 SQLite 经常在嵌入式设备上使用,因此能够优雅地处理 OOM 错误对 SQLite 来说是非常重要的。

OOM 测试是通过模拟 OOM 错误来实现的。SQLite 允许应用程序使用 sqlite3_config 接口替换为替代的 malloc() 实现。 TCL 和 TH3 测试工具都能够插入一个修改版的 malloc() ,该版本能够在经过一定数量的分配后故意失败。这些被改造过的 malloc 可以设置为仅在第一次分配时失败,然后恢复正常工作,也可以设置为在首次失败后继续失败。OOM 测试在循环中进行。在循环的第一次迭代中,改造的 malloc 被设置为在第一次分配时失败。接着执行某个 SQLite 操作,并进行检查,以确保 SQLite 正确处理了 OOM 错误。然后,改造的 malloc 的失败计数器增加一,并重复测试。循环持续进行,直到整个操作在没有遇到模拟 OOM 失败的情况下成功完成。

这类测试将进行两次,一次是将改造的 malloc 设置为仅在第一次分配时失败,另一次是将改造的 malloc 设置为在首次失败后持续失败。

TODO 3.2 I/O Error Testing 输入/输出错误测试

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 错误模拟失败机制后,使用 PRAGMA integrity_check 检查数据库,以确保 I/O 错误没有导致数据库损坏。

3.3 Crash Testing 崩溃测试

崩溃测试旨在证明,如果应用程序或操作系统崩溃,或者在数据库更新过程中发生电力故障,SQLite 数据库不会损坏。关于 SQLite 在崩溃后防止数据库损坏的防御措施,有一篇名为《SQLite 中的原子提交》的单独白皮书进行说明。崩溃测试的目标是验证这些防御措施是否有效。

当然,使用真实的电力故障进行崩溃测试是不切实际的,因此崩溃测试是通过模拟来完成的。会插入一个替代的虚拟文件系统,允许测试工具模拟崩溃后数据库文件的状态。

  1. TCL 测试工具中
    在 TCL 测试工具中,崩溃模拟是在一个单独的进程中进行的。主测试进程会生成一个子进程,该子进程运行某个 SQLite 操作并在写操作的某个位置随机崩溃。一个特殊的虚拟文件系统(VFS)会随机重新排序并损坏未同步的写操作,以模拟缓冲文件系统的影响。在子进程终止后,原始测试进程打开并读取测试数据库,验证子进程尝试的更改要么成功完成,要么完全回滚。使用 PRAGMA integrity_check 确保没有数据库损坏发生。
  1. TH3 测试工具
    TH3 测试工具需要在不一定能够生成子进程的嵌入式系统上运行,因此它使用内存中的虚拟文件系统(VFS)来模拟崩溃。内存中的 VFS 可以设置为在一定数量的 I/O 操作后对整个文件系统进行快照。崩溃测试在一个循环中进行。在每次循环迭代中,快照的生成点会向前推进,直到被测试的 SQLite 操作在没有遇到快照的情况下完成。在循环内部,在被测试的 SQLite 操作完成后,文件系统会恢复到快照状态,并引入随机文件损坏,以模拟电力故障后常见的损坏类型。然后,打开数据库并检查数据库的结构是否正常,以及事务是否完全执行或被完全回滚。循环内部的这些步骤会针对每个快照重复多次,每次引入不同的随机损坏。

3.4 Compound failure tests 复合故障测试

SQLite 的测试套件还探讨了多重故障叠加的结果。例如,进行测试以确保在试图从先前的崩溃中恢复时,如果发生 I/O 错误或内存不足(OOM)故障,系统仍能正确响应。

4 Fuzz Testing 模糊测试

模糊测试旨在验证 SQLite 对无效、超出范围或格式错误的输入能否做出正确响应。

4.1 SQL Fuzz

SQL 模糊测试包括创建语法上正确但含义非常荒谬的 SQL 语句,并将其输入到 SQLite 中,以观察 SQLite 的反应。通常会返回某种错误(例如“没有此表”)。有时,出于偶然,SQL 语句也恰好是语义上的正确。在这种情况下,会执行生成的预编译语句,以确保它返回合理的结果。

4.1.1 使用 American Fuzzy Lop 模糊测试器进行 SQL 模糊测试

模糊测试的概念已存在数十年,但直到 2014 年 Michal Zalewski 发明了第一款实用的基于配置文件的模糊测试器 —— American Fuzzy Lop(简称“AFL”)之前,模糊测试并不是有效的漏洞发现方式。与之前盲目生成随机输入的模糊测试器不同, AFL 对被测试程序进行插装(通过修改 C 编译器生成的汇编语言输出),并利用这一插装检测输入是否导致程序出现不同的行为 —— 例如,遵循新的控制路径或循环的次数不同。能够激发新行为的输入会被保留并进一步变异。通过这种方式,AFL 能够“发现”被测试程序的新行为,包括设计者未曾设想的行为。

AFL 在发现 SQLite 中的隐蔽漏洞方面表现出色。大多数发现都是 assert()语句,在一些不明显的情况下条件为假。但 AFL 也发现了相当数量的崩溃漏洞,甚至还有几个实例是 SQLite 计算了错误的结果。

由于其过去的成功,AFL 从版本 3.8.10(2015 年 5 月 7 日)开始成为 SQLite 测试策略的标准部分,直到在版本 3.29.0(2019 年 7 月 10 日)被更优秀的模糊测试器所取代。

4.1.2 Google OSS Fuzz

从 2016 年开始,谷歌的一组工程师启动了 OSS Fuzz 项目。OSS Fuzz 使用基于 AFL 风格的引导模糊测试器,运行在谷歌的基础设施上。该模糊测试器会自动下载参与项目的最新提交,对其进行模糊测试,并通过电子邮件向开发者报告任何问题。当修复提交后,模糊测试器会自动检测到这一点并向开发者发送确认邮件。

SQLite 是 OSS Fuzz 测试的众多开源项目之一。在 SQLite 源码库中,test/ossfuzz.c 文件是 SQLite 与 OSS Fuzz 的接口。

OSS Fuzz 不再发现 SQLite 中的历史漏洞。但它仍在持续运行,并偶尔会在新的开发提交中发现问题。例如:[1] [2] [3]。

4.1.3 The dbsqlfuzz and jfuzz fuzzers

自 2018 年底以来,SQLite 开始使用一种名为“dbsqlfuzz”的专有模糊测试器进行模糊测试。 dbsqlfuzz 是基于 LLVM 的 libFuzzer 框架构建的。

dbsqlfuzz 模糊测试器同时对 SQL 输入和数据库文件进行变异。dbsqlfuzz 使用自定义的结构感知变异器,针对一个专门的输入文件,该文件定义了输入数据库和要在该数据库上运行的 SQL 文本。由于它同时变异输入数据库和输入 SQL,dbsqlfuzz 能够发现一些以前仅对 SQL 输入或仅对数据库文件进行变异的模糊测试器所忽略的隐蔽故障。SQLite 开发者保持 dbsqlfuzz 在主干代码上持续运行,使用大约 16 核的处理器。每个 dbsqlfuzz 实例每秒能评估约 400 个测试用例,这意味着每天大约检查 5 亿个用例。

dbsqlfuzz 模糊测试器在增强 SQLite 代码库对恶意攻击的防御能力方面取得了显著成功。自从 dbsqlfuzz 被添加到 SQLite 内部测试套件以来,来自外部模糊测试器(如 OSS Fuzz)的漏洞报告几乎停止了。

请注意,dbsqlfuzz 并不是用于 SQLite 的基于 Protobuf 的结构感知模糊测试器,该模糊测试器由 Chromium 使用,并在《结构感知变异器》一文中进行了描述。这两个模糊测试器之间没有任何关联,除了它们都基于 libFuzzer 这一点。用于 SQLite 的 Protobuf 模糊测试器由谷歌的 Chromium 团队编写和维护,而 dbsqlfuzz 则由最初的 SQLite 开发者编写和维护。拥有多个独立开发的 SQLite 模糊测试器是有益的,因为这意味着不明显的问题更可能被发现。

在 2024 年 1 月底,另一种基于 libFuzzer 的工具“jfuzz”开始投入使用。Jfuzz 生成损坏的 JSONB 二进制块,并将其输入到 JSON SQL 函数中,以验证这些 JSON 函数能否安全高效地处理损坏的二进制输入。

4.1.4 Other third-party fuzzers 其他第三方模糊测试工具

SQLite 似乎是第三方模糊测试的热门目标。开发者们听说过许多尝试对 SQLite 进行模糊测试的案例,并且偶尔会收到独立模糊测试器发现的漏洞报告。所有这些报告都会迅速得到修复,从而提升产品质量,并使整个 SQLite 用户社区受益。这种拥有许多独立测试者的机制类似于 Linus 法则:“给足够多的眼睛,所有漏洞都将显而易见”。

其中一位特别值得注意的模糊测试研究人员是 Manuel Rigger。大多数模糊测试器仅关注断言错误、崩溃、未定义行为(UB)或其他容易检测的异常。而 Rigger 博士的模糊测试器能够发现 SQLite 计算错误结果的情况。Rigger 发现了许多这样的案例,其中大多数是涉及类型转换和亲和性变换的罕见边缘情况,并且相当多的发现是针对尚未发布的功能。尽管如此,这些发现依然重要,因为它们是真实的漏洞,SQLite 开发者对此非常感激,能够识别并修复潜在的问题。

4.1.5 The fuzzcheck test harness fuzzcheck 测试工具套件

来自 AFL、OSS Fuzz 和 dbsqlfuzz 的历史测试用例被收集在一组数据库文件中,并在每次运行“make test”时由“fuzzcheck”实用程序重新执行。fuzzcheck 仅运行数千个“有趣”的案例,而不是这些多年来各个模糊测试器所检查的数十亿个案例。“有趣”的案例是指那些表现出先前未见过的行为的案例。实际由模糊测试器发现的漏洞总是被纳入有趣的测试用例中,但 fuzzcheck 所运行的大多数案例从未实际存在过漏洞。

4.1.6 Tension Between Fuzz Testing And 100% MC/DC Testing 模糊测试与 100% MC/DC 测试之间的对抗

模糊测试和 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 测试的事实意味着,当模糊测试器发现问题时,这些问题能够迅速解决,且引入新错误的风险较小。

4.2 Malformed Database Files 格式错误的数据库文件

有许多测试用例验证 SQLite 是否能够处理格式错误的数据库文件。这些测试首先构建一个格式正确的数据库文件,然后通过非 SQLite 的方法更改文件中的一个或多个字节,以引入损坏。接着使用 SQLite 读取数据库。在某些情况下,更改的字节位于数据中间。这会导致数据库内容发生变化,但仍保持数据库的格式正确。在其他情况下,文件中未使用的字节被修改,这对数据库的完整性没有影响。有趣的情况是文件中的字节定义了数据库结构并被更改。格式错误的数据库测试验证了 SQLite 能够识别文件格式错误,并使用 SQLITE_CORRUPT 返回码报告这些错误,同时不溢出缓冲区、取消引用 NULL 指针或执行其他不当操作。

dbsqlfuzz 模糊测试器在验证 SQLite 如何合理应对格式错误的数据库文件方面也表现出色。

4.3 Boundary Value Tests 边界值测试

SQLite 对其操作定义了一些限制,例如表中最大列数、SQL 语句的最大长度或整数的最大值。TCL 和 TH3 测试套件都包含大量测试,旨在将 SQLite 推向其定义限制的边缘,并验证其在所有允许值下的正确表现。还有额外的测试超出了定义的限制,验证 SQLite 正确返回错误。源代码中包含测试用例宏,以确保每个边界的两侧都已进行了测试。

5 Regression Testing 回归测试

每当有关于 SQLite 的漏洞报告时,只有在 TCL 或 TH3 测试套件中新增了能够展示该漏洞的测试用例后,这个漏洞才被认为是修复完成。多年来,这导致了数以千计的新测试的增加。这些回归测试确保已修复的漏洞不会在未来的 SQLite 版本中重新出现。

6 Automatic Resource Leak Detection 自动资源泄漏检测

资源泄漏发生在系统资源被分配但从未释放的情况下。在许多应用程序中,最棘手的资源泄漏是内存泄漏——当通过 malloc() 分配的内存从未使用 free()释放时。此外,其他类型的资源也可能发生泄漏,例如文件描述符、线程、互斥锁等。

TCL 和 TH3 测试工具都能自动跟踪系统资源,并在每次测试运行时报告资源泄漏。无需特别的配置或设置。这些测试工具特别关注内存泄漏。如果某个更改导致内存泄漏,测试工具会迅速识别该问题。SQLite 设计上确保不会发生内存泄漏,即使在出现诸如 OOM 错误或磁盘 I/O 错误等异常后也是如此。测试工具对此执行得非常严格。

7 Test Coverage 测试覆盖率

SQLite 核心,包括 Unix 虚拟文件系统(VFS),在默认配置下的 TH3 测试中实现了 100% 的分支测试覆盖率,依据 gcov 的测量结果。诸如 FTS3 和 RTree 等扩展不包括在此分析之中。

7.1 Statement versus branch coverage 语句覆盖率与分支覆盖率

测量测试覆盖率的方法有很多种。最常用的指标是“语句覆盖率”。当你听到某人说他们的程序具有“XX%的测试覆盖率”而没有进一步解释时,他们通常是指语句覆盖率。语句覆盖率衡量的是测试套件至少执行过一次的代码行所占的百分比。

分支覆盖率比语句覆盖率更严格。分支覆盖率衡量的是在两个方向上至少被评估过一次的机器代码分支指令的数量。

为了说明语句覆盖率和分支覆盖率之间的区别,考虑下面的这一假设的 C 语言代码行:

1
if( a>b && c!=25 ){ d++; }

这样一行 C 代码可能生成十多条独立的机器代码指令。如果这些指令中的任何一条曾被执行过,我们就认为这条语句已经被测试过。例如,可能存在这样的情况:条件表达式始终为假,而 d 变量从未递增。即便如此,语句覆盖率仍然将这行代码视为已被测试。

分支覆盖率则更加严格。在分支覆盖率中,每个测试和语句中的每个子块都是单独考虑的。为了在上述示例中实现 100% 的分支覆盖率,必须至少有三个测试用例:

  • \[a <= b\]
  • \[a > b && c == 25\]
  • \[a > b && c != 25\]

以上任一测试用例都能提供 100%的语句覆盖率,但实现 100%的分支覆盖率则需要所有三个测试用例。一般来说,100% 的分支覆盖率意味着 100%的语句覆盖率,但反之则不一定成立。重申一下,SQLite 的 TH3 测试工具提供了更强的测试覆盖率形式 —— 100%的分支测试覆盖率。

7.2 Coverage testing of defensive code 防御性代码的覆盖率测试

一个编写良好的 C 程序通常会包含一些防御性条件,在实际使用中始终为真或始终为假。这导致了一个编程困境:是否应该删除防御性代码以获得 100%的分支覆盖率?

在 SQLite 中,对上述问题的回答是“否”。为了测试目的,SQLite 源代码定义了名为 ALWAYS()NEVER() 的宏。 ALWAYS() 宏用于包围那些预计始终会被评估为真的条件,而 N EVER() 宏用于包围那些始终会被评估为假的条件。这些宏作为注释,指示这些条件是防御性代码。在发布生成版本中,这些宏是直接传递的:

1
2
#define ALWAYS(X)  (X)
#define NEVER(X)   (X)

然而,在大多数测试过程中,如果其参数未具有预期的真值,这些宏将抛出断言错误。这会迅速提醒开发者错误的设计假设。

1
2
#define ALWAYS(X)  ((X)?1:assert(0),0)
#define NEVER(X)   ((X)?assert(0),1:0)

在测量测试覆盖率时,这些宏被定义为常量真值,以便它们不生成汇编语言分支指令,因此在计算分支覆盖率时不会被考虑:

1
2
#define ALWAYS(X)  (1)
#define NEVER(X)   (0)

该测试套件被设计为运行三次,分别对应上述三种 ALWAYS()和 NEVER()的定义。所有三次测试的结果应该完全相同。可以使用 sqlite3_test_control(SQLITE_TESTCTRL_ALWAYS,...) 接口进行运行时测试,以验证这些宏在部署时正确设置为第一种形式(传递形式)。

7.3 Forcing coverage of boundary values and boolean vector tests 强制覆盖边界值和布尔向量测试

在测试覆盖率测量中使用的另一个宏是 testcase() 宏。其参数是一个条件,我们希望为其创建能够评估为真和假的测试用例。在不进行覆盖率测量的构建中(即发布构建), testcase() 宏是一个无操作(no-op):

1
#define testcase(X)

但在覆盖率测量构建中,testcase()宏生成评估其参数中的条件表达式的代码。然后在分析过程中,会检查是否存在将该条件评估为真和假的测试用例。testcase()宏的使用示例包括帮助验证边界值的测试。例如:

1
2
3
testcase( a==b );
testcase( a==b+1 );
if( a>b && c!=25 ){ d++; }

当两个或多个 case 语句指向同一代码块时,也会使用 testcase()宏,以确保代码为所有情况都被执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
switch( op ){
  case OP_Add:
  case OP_Subtract: {
    testcase( op==OP_Add );
    testcase( op==OP_Subtract );
    /* ... */
    break;
  }
  /* ... */
}

对于位掩码测试, testcase() 宏用于验证每一位掩码是否影响结果。例如,在以下代码块中,如果掩码包含指示要打开 MAIN_DBTEMP_DB 的两个位中的任意一个,则条件为真。 preceding if 语句的 testcase() 宏确保两个情况都得到了测试:

1
2
3
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() 宏的使用。

7.4 分支覆盖率与 MC/DC 覆盖率比较

上述描述了两种测量测试覆盖率的方法:“语句覆盖率”和“分支覆盖率”。除此之外,还有许多其他的测试覆盖率指标。另一个流行的指标是“修改条件/决策覆盖率”(Modified Condition/Decision Coverage),或简称为 MC/DC。维基百科对 MC/DC 的定义如下:

  • 每个决策都会尝试每种可能的结果。
  • 决策中的每个条件都会呈现每种可能的结果。
  • 每个入口和出口点都会被调用。
  • 决策中的每个条件被证明会独立影响决策的结果。

在 C 编程语言中, &&|| 是“短路”运算符,因此 MC/DC 和分支覆盖率几乎是相同的。主要区别在于布尔向量测试。测试位向量中的几个位可以获得 100%的分支测试覆盖率,即使 MC/DC 的第二个要素——每个决策中的条件都需呈现每种可能的结果——可能未被满足。

SQLite 使用前一小节中描述的 testcase() 宏,以确保位向量决策中的每个条件都能呈现每种可能的结果。通过这种方式,SQLite 不仅实现了 100%的分支覆盖率,还达成了 100%的 MC/DC 覆盖率。

7.5 测量分支覆盖率

SQLite 中的分支覆盖率目前使用 gcov 和 -b 选项来测量。首先使用 -g -fprofile-arcs -ftest-coverage 选项编译测试程序,然后运行该测试程序。接着运行 gcov -b 生成覆盖率报告。覆盖率报告内容繁多且不易阅读,因此 gcov 生成的报告会通过一些简单的脚本进行处理,以便转换为更人性化的格式。当然,这整个过程是自动化的。

需要注意的是,以 gcov 运行 SQLite 并不是对 SQLite 的测试——而是对测试套件的测试。 gcov 运行并不会测试 SQLite, 因为 -fprofile-arcs-ftest-coverage 选项会导致编译器生成不同的代码。 gcov 运行只是验证测试套件是否提供了 100% 的分支测试覆盖率。gcov 运行是对测试的测试——一个元测试。

在使用 gcov 运行验证 100%分支测试覆盖率后,接着会使用交付编译器选项(不带特殊的 -fprofile-arcs-ftest-coverage 选项)重新编译测试程序并重新运行该程序。这第二次运行才是真正的 SQLite 测试。

验证 gcov 测试运行和第二次真实测试运行的输出是否一致非常重要。任何输出的差异都表明 SQLite 代码中使用了未定义或不确定的行为(从而表明存在漏洞),或者是编译器中的错误。值得注意的是,在过去的十年中,SQLite 曾遇到过 GCC、Clang 和 MSVC 中的 bug。尽管编译器错误较少,但确实会发生,这也是在交付配置中测试代码如此重要的原因。

7.6 变异测试

使用 gcov(或类似工具)显示每个分支指令在两个方向上至少被执行一次是测试套件质量的良好衡量标准。但更好的方法是显示每个分支指令对输出产生了影响。换句话说,我们不仅要证明每个分支指令都进行了跳转和落到底,还要证明每个分支都在执行有用的工作,并且测试套件能够检测并验证该工作。当发现某个分支对输出没有影响时,这表明与该分支相关的代码可以被移除(以减小库的大小,并可能提高运行速度),或测试套件未能充分测试该分支实现的功能。

SQLite 努力使用变异测试来验证每个分支指令对输出的重要性。首先,一个脚本将 SQLite 源代码编译成汇编语言(例如,使用 gcc 的 -S 选项)。然后,脚本遍历生成的汇编语言,逐个将每个分支指令更改为无条件跳转或无操作(no-op),编译结果,并验证测试套件是否捕获到变异。

不幸的是,SQLite 包含许多可以加速代码运行而不改变输出的分支指令。这些分支在变异测试中会产生误报。例如,考虑以下用于加速表名查找的哈希函数:

1
2
3
4
5
6
7
8
static unsigned int strHash(const char *z){
    unsigned int h = 0;
    unsigned char c;
    while( (c = (unsigned char)*z++)!=0 ){     /*OPTIMIZATION-IF-TRUE*/
        h = (h<<3) ^ h ^ sqlite3UpperToLower[c];
    }
    return h;
}

如果将第 58 行上实现 c!=0 测试的分支指令更改为无操作, while 循环将会无限循环,测试套件将因超时而失败。但如果将该分支更改为无条件跳转,则哈希函数将始终返回 0。问题在于 0 是一个有效的哈希值。一个始终返回 0 的哈希函数仍然可以工作,因为 SQLite 仍然能够得到正确的答案。表名哈希表会退化为链表,因此在解析 SQL 语句时进行的表名查找可能会稍微慢一些,但最终结果将是相同的。

为了解决这个问题,像 *OPTIMIZATION-IF-TRUE*//*OPTIMIZATION-IF-FALSE*/ 这样的注释被插入到 SQLite 源代码中,以告诉变异测试脚本忽略某些分支指令。

7.7 完整测试覆盖率的经验

SQLite 的开发者发现,全面覆盖测试是一种极其有效的方法,用于定位和防止 bug。由于 SQLite 核心代码中的每一个分支指令都被测试用例覆盖,开发者可以确信在代码的某个部分所做的更改不会对其他部分产生意外影响。近年来添加到 SQLite 的众多新功能和性能改进都得益于全面覆盖测试的可用性。

维持 100% MC/DC 测试是艰苦且耗时的。保持全面覆盖测试所需的努力,可能对于典型应用程序来说并不具成本效益。然而,我们认为对于像 SQLite 这样广泛部署的基础设施库,尤其是作为数据库库本质上需要“记住”过去错误的情况,全面覆盖测试是合理的。

8 动态分析 Dynamic Analysis

动态分析指的是在 SQLite 代码运行时进行的内部和外部检查。动态分析在维护 SQLite 的质量方面已经证明是极大的帮助。

8.1 Assert 断言

SQLite 核心包含 6754 个 assert() 语句,用于验证函数的前置条件、后置条件和循环不变式。 assert() 是 ANSI-C 的标准宏,其参数是一个被假定为始终为真的布尔值。如果该断言为假,程序将打印错误消息并终止运行。

通过定义 NDEBUG 宏来编译时禁用 assert() 。在大多数系统中,断言默认是启用的。但在 SQLite 中,断言数量众多,并且位于性能关键的位置,因此当启用断言时,数据库引擎的运行速度大约会减慢三倍。因此,SQLite 的默认(生产) 构建禁用了断言。只有在使用定义了 SQLITE_DEBUG 预处理器宏的情况下, assert 语句才会被启用。

有关 SQLite 如何使用 assert() 的更多信息,请参阅《在 SQLite 中使用 assert》文档。

8.2 Valgrind

Valgrind 可能是世界上最惊人和最有用的开发者工具。Valgrind 是一个模拟器——它模拟一个运行 Linux 二进制文件的 x86 架构。(除了 Linux 之外,Valgrind 的其他平台移植正在开发中,但截至目前,Valgrind 只在 Linux 上可靠运行,在 SQLite 开发者看来,这意味着 Linux 应当是所有软件开发的首选平台。)当 Valgrind 运行 Linux 二进制文件时,它会查找各种有趣的错误,例如数组溢出、读取未初始化内存、栈溢出、内存泄漏等。Valgrind 发现的问题往往是其他针对 SQLite 运行的所有测试中容易遗漏的。更重要的是,当 Valgrind 发现错误时,它可以将开发者直接带入符号调试器,从而准确定位错误发生的点,帮助快速修复问题。

由于 Valgrind 是一个模拟器,因此在 Valgrind 中运行二进制文件的速度比在本地硬件上运行慢。(粗略估算,一个在工作站上运行的 Valgrind 应用程序的性能大约与其在智能手机上本地运行时相同。)因此,通过 Valgrind 运行完整的 SQLite 测试套件是不切实际的。然而,在每次发布之前,veryquick 测试和 TH3 测试的覆盖率都会通过 Valgrind 进行运行。

8.3 Memsys2

SQLite 包含一个可插拔的内存分配子系统。默认实现使用系统的 malloc()free() 。然而,如果 SQLite 是编译时定义了 SQLITE_MEMDEBUG ,则会插入一个替代的内存分配包装器(memsys2),用于在运行时查找内存分配错误。memsys2 包装器自然会检查内存泄漏,同时还会检测缓冲区溢出、未初始化内存的使用以及在内存被释放后尝试使用该内存等情况。这些相同的检查也由 Valgrind 执行(实际上,Valgrind 执行得更好),但 memsys2 有更快的优势,这意味着可以更频繁地进行检查,并且可以用于更长时间的测试。

8.4 Mutex Asserts

SQLite 包含一个可插拔的互斥锁子系统。根据编译时选项,默认的互斥锁系统包含接口 sqlite3_mutex_held()=和 =sqlite3_mutex_notheld() ,用于检测调用线程是否持有特定的互斥锁。这两个接口在 SQLite 的 assert() 语句中被广泛使用,以验证互斥锁在所有合适的时刻被正确地持有和释放,从而双重检查 SQLite 在多线程应用程序中的正确性。

8.5 Journal Tests

SQLite 为确保在系统崩溃和电力故障情况下事务的原子性,采取的措施之一是在更改数据库之前将所有更改写入回滚日志文件。 TCL 测试工具包含一个替代的操作系统后端实现,以帮助验证这一过程是否正确进行。“journal-test VFS”监控数据库文件与回滚日志之间的所有磁盘 I/O 流量,检查确保在数据库文件中写入的内容之前都已写入并同步到回滚日志。如果发现任何不一致之处,将会引发断言错误。

回滚日志测试是对崩溃测试的额外一次双重检查,以确保 SQLite 事务在系统崩溃和电力故障中保持原子性。

8.6 Undefined Behavior Checks

在 C 编程语言中,编写具有“未定义”或“实现定义”行为的代码是非常容易的。这意味着代码在开发期间可能运行良好,但在不同系统上或使用不同编译器选项重新编译时可能会给出不同的结果。ANSI C 中的未定义和实现定义行为的示例包括:

  • 有符号整数溢出。(有符号整数溢出不一定会按大多数人预期的方式循环回绕。)
  • 将 N 位整数右移超过 N 位。
  • 以负数进行位移。
  • 移动负数。
  • 在重叠缓冲区上使用 memcpy()函数。
  • 函数参数的求值顺序。
  • “char”变量是否为有符号或无符号。
  • 其他类似情况。

由于未定义和实现定义行为是不可移植的,并且很容易导致错误的结果,SQLite 非常努力地避免这些情况。例如,当作为 SQL 语句的一部分将两个整数列的值相加时,SQLite 并不仅仅使用 C 语言的“+”运算符进行加法。而是首先检查加法是否会溢出,如果会,则改用浮点数进行加法。

为了帮助确保 SQLite 不利用未定义或实现定义行为,测试套件在使用带有检测未定义行为的工具构建时重新运行。例如,使用 GCC 的 -ftrapv 选项运行测试套件。然后再使用 Clang 的 -fsanitize=undefined 选项运行一次。再使用 MSVC 的 /RTC1 选项运行测试。接着,测试套件再次运行,使用 -funsigned-char-fsigned-char 选项,以确保实现差异不影响结果。测试还在 32 位和 64 位系统上,以及大端和小端系统上,使用多种 CPU 架构进行重复。此外,测试套件还增加了许多故意设计以激发未定义行为的测试用例。例如:=SELECT -1/ (-9223372036854775808);= 。

9 禁用优化测试 Disabled Optimization Tests

sqlite3_test_control(SQLITE_TESTCTRL_OPTIMIZATIONS, ...) 接口允许在运行时禁用某些 SQL 语句的优化。SQLite 在启用优化和禁用优化的情况下应始终生成完全相同的结果;启用优化的情况下,结果返回会更快。因此,在生产环境中,始终应将优化设为开启(默认设置)。

在 SQLite 中使用的一种验证技术是将整个测试套件运行两次,一次保持优化开启,第二次关闭优化,并验证两次的输出是否相同。这表明优化不会引入错误。

并不是所有测试用例都能以这种方式处理。有些测试用例用来验证优化确实减少了计算量,例如通过计算查询过程中发生的磁盘访问次数、排序操作、全表扫描步骤或其他处理步骤。这些测试用例在禁用优化时会显得失败。但是,大多数测试用例只检查是否得到了正确答案,所有这些用例都可以在启用和禁用优化的情况下成功运行,以证明优化不会导致故障。

10 Checklists 检查清单

SQLite 的开发者使用在线清单来协调测试活动,并在每次 SQLite 发布之前验证所有测试是否通过。过去的清单被保留用于历史参考。(对于匿名互联网用户,清单是只读的,但开发者可以登录并在他们的网络浏览器中更新清单项。)在 SQLite 测试和其他开发活动中使用清单的做法受到《清单宣言》的启发。

最新的清单包含大约 200 个项目,这些项目在每次发布时都要逐一验证。有些清单项目的验证和标记只需几秒钟,而其他项目则涉及需要运行数小时的测试套件。

发布清单并不是自动化的:开发者手动运行清单中的每个项目。我们发现将人类纳入流程是很重要的。有时在运行某个清单项目时会发现问题,即使测试本身通过了。重要的是要有一个人以最高层次审查测试输出,并不断问自己:“这真的是对的吗?”

发布清单是不断发展的。随着新问题或潜在问题的发现,新的清单项被添加,以确保这些问题不会出现在后续的版本中。发布清单被证明是一个宝贵的工具,有助于确保在发布过程中没有任何细节被忽视。

11 Static Analysis

静态分析是指在编译时分析源代码以检查其正确性。静态分析包括编译器警告信息以及更深入的分析引擎,例如 Clang 静态分析器。SQLite 在 Linux 和 Mac 上使用-Wall 和-Wextra 标志以及在 Windows 上的 MSVC 编译时,没有产生任何警告。Clang 静态分析工具“scan-build”也不会生成有效的警告(尽管最近版本的 Clang 似乎生成了许多误报)。然而,其他静态分析器可能会生成一些警告。鼓励用户不要过于担心这些警告,而是要对上述密集的 SQLite 测试感到安心。

静态分析在发现 SQLite 中的 bugs 方面并没有太大帮助。静态分析确实发现过一些 SQLite 中的 bugs,但这些是例外。在试图让 SQLite 在编译时不生成警告的过程中引入的 bugs 比静态分析发现的要多。

12 Summary

SQLite 是开源软件。这使得许多人认为它没有商业软件那样经过充分测试,因此可能不够可靠。但这种印象是错误的。SQLite 在实际应用中表现出非常高的可靠性和非常低的缺陷率,特别是考虑到它的快速发展。SQLite 的质量部分得益于精心的代码设计和实现。同时,广泛的测试也在维护和提升 SQLite 质量方面发挥着至关重要的作用。这份文档总结了每个 SQLite 版本发布时所经过的测试程序,旨在增强人们对 SQLite 适用于关键任务应用的信心。