一、简介
SQLite 的可靠性和健壮性部分是通过彻底和仔细的测试实现的。
从版本 3.39.0 (2022-06-25) 开始,SQLite 库由大约 151.3 KSLOC 的 C 代码组成。(KSLOC 表示数千“源代码行”,换句话说,不包括空行和注释的代码行。)相比之下,该项目的测试代码和测试脚本数量是其 608 倍——92038.3 KSLOC。
1.1. 执行摘要
- 四款自主研发的测试线束
- 已部署配置中的 100% 分支测试覆盖率
- 数以百万计的测试用例
- 内存不足测试
- I/O 错误测试
- 碰撞和断电测试
- 模糊测试
- 边界值测试
- 禁用优化测试
- 回归测试
- 格式错误的数据库测试
- 大量使用 assert() 和运行时检查
- Valgrind分析
- 未定义的行为检查
- 清单
2.测试线束
有四个独立的测试工具用于测试核心 SQLite 库。每个测试工具的设计、维护和管理均与其他工具分开。
TCL 测试是 SQLite 的原始测试 。它们包含在与 SQLite 核心相同的源代码树中,并且与 SQLite 核心一样位于公共域中。TCL 测试是开发过程中使用的主要测试。TCL 测试是使用 TCL 脚本语言编写的。TCL 测试工具本身包含 27.7 KSLOC 的 C 代码,用于创建 TCL 接口。测试脚本包含在 1343 个文件中,总大小为 23.5MB。有 50240 个不同的测试用例,但许多测试用例被参数化并运行多次(使用不同的参数),因此在完整的测试运行中执行数百万个单独的测试。
TH3测试工具是一组专有测试,用 C 语言编写,可为核心 SQLite 库提供 100% 的分支测试覆盖率(和 100 % 的MC/DC 测试覆盖率)。TH3 测试旨在在不易支持 TCL 或其他工作站服务的嵌入式和专用平台上运行。TH3 测试仅使用已发布的 SQLite 接口。TH3 由大约 75.7 MB 或 1038.0 KSLOC 的 C 代码组成,实现了 49116 个不同的测试用例。不过,TH3 测试参数化程度很高,因此全覆盖测试会运行大约 230 万个不同的测试实例。提供 100% 分支测试覆盖率的案例构成了整个 TH3 测试套件的一个子集。发布前的浸泡测试会进行数亿次测试。关于 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框架和自定义修改器构建的。有303个种子文件。dbsqlfuzz 模糊器每天运行大约 10 亿个测试突变。Dbsqlfuzz 有助于确保 SQLite 能够抵御通过恶意 SQL 或数据库输入进行的攻击。
除了四个主要的测试工具之外,还有其他几个实现专门测试的小程序。
- “speedtest1.c”程序估计了 SQLite 在典型工作负载下的性能。
- “mptester.c”程序是对多个进程同时读写单个数据库的压力测试。
- “threadtest3.c”程序是同时使用 SQLite 的多个线程的压力测试。
- “fuzzershell.c”程序用于运行一些模糊测试。
在每次 SQLite 发布之前,上述所有测试都必须在多个平台和多个编译时配置下成功运行。
在每次签入 SQLite 源代码树之前,开发人员通常会运行 Tcl 测试的一个子集(称为“veryquick”),其中包含大约 300.2 千个测试用例。veryquick 测试包括除异常、模糊和浸泡测试之外的大多数测试。veryquick 测试背后的想法是它们足以捕获大多数错误,而且只需几分钟而不是几个小时即可运行。
3.异常测试
异常测试是设计用于在出现问题时验证 SQLite 的正确行为的测试。在一台功能齐全的计算机上构建一个在格式正确的输入上表现正确的 SQL 数据库引擎(相对)很容易。构建一个对无效输入做出理智响应并在系统故障后继续运行的系统更加困难。异常测试旨在验证后一种行为。
3.1. 内存不足测试
与所有 SQL 数据库引擎一样,SQLite 广泛使用 malloc()(有关更多详细信息,请参阅关于SQLite 中动态内存分配的单独报告 。)在服务器和工作站上,malloc() 在实践中永远不会失败,因此可以正确处理 out-内存不足 (OOM) 错误并不是特别重要。但是在嵌入式设备上,OOM 错误非常普遍,而且由于 SQLite 在嵌入式设备上经常使用,因此 SQLite 能够优雅地处理 OOM 错误非常重要。
OOM测试是通过模拟OOM错误来完成的。SQLite 允许应用程序使用sqlite3_config ( SQLITE_CONFIG_MALLOC,...) 界面。TCL 和 TH3 测试工具都能够插入 malloc() 的修改版本,可以在一定数量的分配后将其设置为失败。这些经过检测的 malloc 可以设置为仅失败一次然后再次开始工作,或者在第一次失败后继续失败。OOM 测试在一个循环中完成。在循环的第一次迭代中,已检测的 malloc 被设置为在第一次分配时失败。然后执行一些 SQLite 操作并进行检查以确保 SQLite 正确处理了 OOM 错误。然后,检测的 malloc 上的失败时间计数器增加 1,并重复测试。循环继续,直到整个操作运行完成,而没有遇到模拟的 OOM 故障。这样的测试运行两次,
3.2. I/O 错误测试
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. 碰撞测试
崩溃测试旨在证明如果应用程序或操作系统崩溃或数据库更新过程中出现电源故障,SQLite 数据库不会损坏。另一份名为 SQLite 中的原子提交的白皮书 描述了 SQLite 为防止崩溃后数据库损坏而采取的防御措施。碰撞测试努力验证这些防御措施是否正常工作。
当然,使用真正的电源故障进行碰撞测试是不切实际的,因此碰撞测试是在模拟中完成的。插入了一个替代的 虚拟文件系统,允许测试工具模拟崩溃后数据库文件的状态。
在 TCL 测试框架中,碰撞模拟是在一个单独的过程中完成的。主测试进程生成一个子进程,该子进程运行一些 SQLite 操作并在写入操作中间的某个地方随机崩溃。一个特殊的VFS随机重新排序并破坏非同步写入操作以模拟缓冲文件系统的效果。孩子死亡后,原始测试进程打开并读取测试数据库,并验证孩子尝试的更改是否成功完成或完全回滚。integrity_check PRAGMA用于确保没有发生数据库损坏。
TH3 测试工具需要在不一定具有生成子进程的能力的嵌入式系统上运行,因此它使用内存中的VFS来模拟崩溃。内存中的VFS可以设置为在一定数量的 I/O 操作后制作整个文件系统的快照。碰撞测试循环运行。在循环的每次迭代中,创建快照的时间点都会提前,直到被测试的 SQLite 操作运行完成而从未命中快照。在循环中,在测试中的 SQLite 操作完成后,文件系统恢复到快照并引入随机文件损坏,这是断电后预计会出现的损坏类型的特征。然后打开数据库并进行检查以确保其格式正确,并且事务运行完成或完全回滚。对于每次具有不同随机损坏的每个快照,循环内部重复多次。
3.4. 复合失效测试
SQLite 的测试套件还探索了叠加多个故障的结果。例如,运行测试以确保在尝试从先前的崩溃中恢复时发生 I/O 错误或 OOM 故障时的正确行为。
4.模糊测试
模糊测试 旨在确定 SQLite 是否正确响应无效、超出范围或格式错误的输入。
4.1. SQL模糊测试
SQL 模糊测试包括创建语法正确但毫无意义的 SQL 语句,并将它们提供给 SQLite 以查看它将如何处理它们。通常会返回某种错误(例如“没有这样的表”)。有时,纯属偶然,SQL 语句在语义上也恰好是正确的。在这种情况下,运行生成的准备好的语句以确保它给出合理的结果。
4.1.1. SQL Fuzz 使用美国 Fuzzy Lop Fuzzer
模糊测试的概念已经存在了几十年,但直到 2014 年 Michal Zalewski 发明了第一个实用的配置文件引导 模糊器American Fuzzy Lop或“AFL”时,模糊测试才成为发现错误的有效方法。与之前盲目生成随机输入的模糊器不同,AFL 检测被测试的程序(通过修改 C 编译器的汇编语言输出)并使用该检测来检测输入何时导致程序执行不同的操作 - 遵循新的控制路径或循环不同的次数。激发新行为的输入被保留并进一步变异。通过这种方式,AFL 能够“发现”被测程序的新行为,包括设计者从未设想过的行为。
AFL 被证明擅长在 SQLite 中发现神秘的错误。大多数发现都是 assert() 语句,其中条件在模糊的情况下为假。但是 AFL 也在 SQLite 中发现了相当多的崩溃错误,甚至在一些情况下 SQLite 计算出了错误的结果。
由于过去的成功,AFL 从3.8.10 版(2015-05-07)开始成为 SQLite 测试策略的标准部分,直到它在3.29.0 版(2019-07-10)中被更好的模糊器所取代。
4.1.2. 谷歌 OSS 模糊测试
从 2016 年开始,谷歌的一个工程师团队启动了 OSS Fuzz项目。OSS Fuzz 使用在 Google 的基础设施上运行的 AFL 风格的引导式模糊器。Fuzzer 会自动下载参与项目的最新签到,对其进行模糊测试,并向开发人员发送电子邮件报告任何问题。签入修复后,模糊器会自动检测到这一点,并通过电子邮件向开发人员发送确认邮件。
SQLite 是 OSS Fuzz 测试的众多开源项目之一。SQLite 存储库中的 test/ossfuzz.c源文件是 SQLite 与 OSS fuzz 的接口。
OSS Fuzz 不再发现 SQLite 中的历史错误。但它仍在运行,偶尔会在新的开发签入中发现问题。示例: [1] [2] [3]。
4.1.3. dbsqlfuzz 模糊器
从 2018 年底开始,SQLite 已使用名为“dbsqlfuzz”的专有模糊器进行模糊测试。Dbsqlfuzz 是使用 LLVM 的 libFuzzer框架构建的。
dbsqlfuzz 模糊器同时改变 SQL 输入和数据库文件。Dbsqlfuzz 在专门的输入文件上使用自定义 结构感知突变器 ,该文件定义了输入数据库和要针对该数据库运行的 SQL 文本。因为它同时改变了输入数据库和输入 SQL,所以 dbsqlfuzz 已经能够在 SQLite 中找到一些模糊的错误,这些错误是之前只改变 SQL 输入或只改变数据库文件的模糊器所遗漏的。SQLite 开发人员始终保持 dbsqlfuzz 在大约 16 个内核中针对主干运行。dbsqlfuzz 程序的每个实例每秒能够评估大约 400 个测试用例,这意味着每天检查大约 5 亿个用例。
dbsqlfuzz 模糊器在强化 SQLite 代码库以防止恶意攻击方面非常成功。由于 dbsqlfuzz 已添加到 SQLite 内部测试套件中,来自外部模糊器(如 OSSFuzz)的错误报告几乎停止了。
请注意,dbsqlfuzz不是Chromium 使用的 SQLite 的基于 Protobuf 的结构感知模糊器,它在 Structure-Aware Mutator 文章中有所描述。这两个模糊器之间没有任何联系,只是它们都基于libFuzzer。SQLite 的 Protobuf 模糊器由 Google 的 Chromium 团队编写和维护,而 dbsqlfuzz 由原始 SQLite 开发人员编写和维护。拥有多个独立开发的 SQLite 模糊器很好,因为这意味着更容易发现模糊问题。
4.1.4. 其他第三方模糊器
SQLite 似乎是第三方模糊测试的热门目标。开发人员听说过许多对 SQLite 进行模糊测试的尝试,他们偶尔会收到独立模糊器发现的错误报告。所有此类报告都得到及时修复,因此产品得到改进,整个 SQLite 用户社区都从中受益。这种拥有许多独立测试人员的机制类似于 Linus 法则:“给定足够的眼球,所有的错误都是浅显的”。
一位特别值得注意的模糊测试研究人员是 Manuel Rigger,目前(本段写于 2019 年 12 月 21 日)在苏黎世联邦理工学院。大多数模糊器只查找断言错误、崩溃、未定义行为 (UB) 或其他容易检测到的异常。另一方面,Dr. Rigger 的模糊器能够发现 SQLite 计算出错误答案的情况。Rigger发现 了很多这样的案例. 这些发现中的大多数都是涉及类型转换和亲和力转换的模糊角落案例,并且很多发现都是针对未发布的功能。尽管如此,他的发现仍然很重要,因为它们是真正的错误,SQLite 开发人员很高兴能够识别并修复潜在的问题。Rigger 的作品目前未发表。当它发布时,它可能与 Zalewski 发明的 AFL 和配置文件引导的模糊测试一样具有影响力。
4.1.5. fuzzcheck 测试工具
来自AFL、OSS Fuzz和dbsqlfuzz的历史测试用例被收集在主 SQLite 源代码树中的一组数据库文件中,然后在运行“make test”时由“fuzzcheck”实用程序重新运行。Fuzzcheck 仅在各种模糊器多年来检查的数十亿个案例中运行了几千个“有趣”的案例。“有趣”的案例是表现出以前未见过的行为的案例。fuzzer 发现的实际错误总是包含在有趣的测试用例中,但 fuzzcheck 运行的大多数用例从来都不是真正的错误。
4.1.6. 模糊测试和 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. 格式错误的数据库文件
有许多测试用例可以验证 SQLite 是否能够处理格式错误的数据库文件。这些测试首先构建一个格式良好的数据库文件,然后通过 SQLite 以外的某种方式更改文件中的一个或多个字节来添加损坏。然后使用SQLite读取数据库。在某些情况下,字节变化发生在数据中间。这会导致数据库内容发生变化,同时保持数据库格式正确。在其他情况下,文件未使用的字节被修改,这对数据库的完整性没有影响。有趣的情况是定义数据库结构的文件字节发生变化。格式错误的数据库测试验证 SQLite 发现文件格式错误并使用SQLITE_CORRUPT报告它们返回代码而不会溢出缓冲区、取消引用 NULL 指针或执行其他不健康的操作。
dbsqlfuzz模糊器在验证 SQLite 是否正确响应格式错误的数据库文件方面也做得非常出色。
4.3. 边界值测试
SQLite 对其操作定义了一定的限制,例如表中的最大列数、SQL 语句的最大长度或整数的最大值。TCL 和 TH3 测试套件都包含大量测试,将 SQLite 推到其定义限制的边缘,并验证它是否对所有允许值正确执行。其他测试超出定义的限制并验证 SQLite 是否正确返回错误。源代码包含测试用例宏以验证每个边界的两侧是否都已测试。
5.回归测试
每当针对 SQLite 报告错误时,该错误不会被视为已修复,直到将显示该错误的新测试用例添加到 TCL 或 TH3 测试套件中。多年来,这导致了成千上万的新测试。这些回归测试确保过去已修复的错误不会重新引入到 SQLite 的未来版本中。
6.自动资源泄漏检测
当系统资源被分配但从未释放时,就会发生资源泄漏。许多应用程序中最麻烦的资源泄漏是内存泄漏 - 当内存使用 malloc() 分配但从未使用 free() 释放时。但其他类型的资源也可能被泄露:文件描述符、线程、互斥量等。
TCL 和 TH3 测试工具都会自动跟踪系统资源并在每次测试运行时报告资源泄漏。无需特殊配置或设置。测试工具对内存泄漏特别警惕。如果更改导致内存泄漏,测试工具会很快识别出来。SQLite 被设计为永远不会泄漏内存,即使在出现 OOM 错误或磁盘 I/O 错误等异常之后也是如此。测试工具热衷于执行此操作。
7.测试覆盖率
SQLite 核心,包括 unix VFS ,在TH3的默认配置 下具有 100% 的分支测试覆盖率,由gcov测量。FTS3 和 RTree 等扩展被排除在该分析之外。
7.1. 语句与分支覆盖
有很多方法可以衡量测试覆盖率。最流行的指标是“语句覆盖率”。当您听到有人说他们的程序为“XX% 测试覆盖率”而没有进一步解释时,他们通常指的是语句覆盖率。语句覆盖率衡量测试套件至少执行一次代码行的百分比。
分支覆盖比语句覆盖更严格。分支覆盖率衡量在两个方向上至少评估一次的机器代码分支指令的数量。
为了说明语句覆盖率和分支覆盖率之间的区别,请考虑以下假设的 C 代码行:
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. 防御代码的覆盖率测试
一个写得很好的 C 程序通常会包含一些防御性条件,这些条件实际上总是为真或总是为假。这导致了一个编程困境:是否需要删除防御性代码以获得 100% 的分支覆盖率?
在 SQLite 中,前一个问题的答案是“否”。出于测试目的,SQLite 源代码定义了名为 ALWAYS() 和 NEVER() 的宏。ALWAYS() 宏包含预期始终评估为 true 的条件,而 NEVER() 包含始终评估为 false 的条件。这些宏作为注释来指示条件是防御性代码。在发布版本中,这些宏是传递的:
#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 , ...) 接口的运行时测试,可用于验证宏是否正确设置为用于部署的第一种形式(传递形式)。
7.3. 强制覆盖边界值和布尔向量测试
与测试覆盖率测量结合使用的另一个宏是testcase()宏。参数是我们想要评估为真和假的测试用例的条件。在非覆盖构建中(也就是说,在发布构建中), testcase()宏是一个空操作:
#define testcase(X)
但是在覆盖率测量构建中,testcase()宏生成的代码会评估其参数中的条件表达式。然后在分析期间,进行检查以确保存在将条件评估为真和假的测试。 例如,使用Testcase()宏来帮助验证是否测试了边界值。例如:
testcase( a==b ); testcase( a==b+1 ); if( a>b && c!=25 ){ d++; }
当 switch 语句的两个或多个 case 转到同一代码块时,也会使用测试用例宏,以确保所有 case 都到达代码:
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 源代码包含 1143 次使用testcase()宏。
7.4. 分支机构覆盖与 MC/DC
上面描述了两种测量测试覆盖率的方法:“语句”和“分支”覆盖率。除了这两个之外,还有许多其他测试覆盖率指标。另一个流行的指标是“修改后的条件/决策覆盖率”或 MC/DC。 维基百科 对 MC/DC 的定义如下:
- 每个决定都会尝试所有可能的结果。
- 决策中的每个条件都会产生每个可能的结果。
- 调用每个入口点和出口点。
- 决策中的每个条件都会独立影响决策的结果。
在 C 编程语言中,&&和|| 是“短路”运算符,MC/DC 和分支覆盖几乎是一回事。主要区别在于布尔向量测试。即使 MC/DC 的第二个元素 - 决策中的每个条件对每个可能的结果的要求 - 可能无法满足,也可以测试位向量中的任何几个位并仍然获得 100% 的分支测试覆盖率。
SQLite 使用上一小节中描述的testcase()宏来确保位向量决策中的每个条件都适用于每个可能的结果。这样,SQLite除了100%的分支覆盖率外,还实现了100%的MC/DC。
7.5. 衡量分支机构覆盖率
SQLite 中的分支覆盖率目前使用带有“-b”选项的gcov来测量。首先使用选项“-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 中都遇到了错误。编译器错误虽然很少见,但确实会发生,这就是为什么在交付配置中测试代码如此重要的原因。
7.6. 突变检测
使用 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*/
7.7. 全面测试覆盖的经验
SQLite的开发者发现全覆盖测试是一种非常有效的定位和预防bug的方法。因为 SQLite 核心代码中的每条分支指令都被测试用例覆盖,所以开发人员可以确信在代码的一部分中所做的更改不会对代码的其他部分产生意想不到的后果。如果没有全覆盖测试的可用性,近年来添加到 SQLite 的许多新功能和性能改进是不可能的。
保持 100% MC/DC 既费力又费时。维护全覆盖测试所需的工作量对于典型应用程序而言可能不符合成本效益。然而,我们认为全覆盖测试对于 像 SQLite 这样部署非常广泛的基础架构库是合理的,尤其是对于本质上“记住”过去错误的数据库库。
8.动态分析
动态分析是指在代码实时运行时对 SQLite 代码执行的内部和外部检查。动态分析已被证明对维护 SQLite 的质量有很大帮助。
8.1. 断言
SQLite 核心包含 6548 个assert() 语句,用于验证函数前置条件和后置条件以及循环不变量。Assert() 是一个宏,它是 ANSI-C 的标准部分。该参数是一个布尔值,假定始终为真。如果断言为假,程序将打印一条错误消息并停止。
通过使用定义的 NDEBUG 宏进行编译来禁用 Assert() 宏。在大多数系统中,默认情况下启用断言。但在 SQLite 中,断言数量如此之多,而且位于对性能至关重要的地方,以至于在启用断言时数据库引擎的运行速度大约慢了三倍。因此,SQLite 的默认(生产)构建禁用断言。断言语句仅在使用定义的 SQLITE_DEBUG 预处理器宏编译 SQLite 时启用。
有关SQLite如何使用 assert() 的更多信息,请参阅SQLite 文档中的断言的使用。
8.2. Valgrind
Valgrind可能是世界上最神奇、最有用的开发人员工具。Valgrind 是一个模拟器——它模拟运行 Linux 二进制文件的 x86。(用于 Linux 以外平台的 Valgrind 端口正在开发中,但在撰写本文时,Valgrind 只能在 Linux 上可靠地工作,在 SQLite 开发人员看来,这意味着 Linux 应该是所有软件开发的首选平台。)作为 Valgrind运行 Linux 二进制文件时,它会查找各种有趣的错误,例如数组溢出、未初始化内存读取、堆栈溢出、内存泄漏等。Valgrind 发现的问题很容易绕过针对 SQLite 运行的所有其他测试。而且,当 Valgrind 确实发现错误时,它可以在错误发生的确切位置将开发人员直接转储到符号调试器中,以促进快速修复。
因为它是一个模拟器,所以在 Valgrind 中运行二进制文件比在本机硬件上运行要慢。(对于第一个近似值,在工作站上的 Valgrind 中运行的应用程序将执行与在智能手机上本地运行的应用程序大致相同。)因此通过 Valgrind 运行完整的 SQLite 测试套件是不切实际的。然而,非常快速的测试和 TH3 测试的覆盖范围是在每次发布之前通过 Valgrind 运行的。
8.3. 内存系统2
SQLite 包含一个可插入的 内存分配子系统。默认实现使用系统 malloc() 和 free()。但是,如果使用SQLITE_MEMDEBUG编译 SQLite ,则会插入一个替代内存分配包装器 ( memsys2 ),用于在运行时查找内存分配错误。memsys2 包装器当然会检查内存泄漏,但也会查找缓冲区溢出、未初始化内存的使用以及在释放内存后尝试使用内存。这些相同的检查也由 valgrind 完成(事实上,Valgrind 做得更好)但是 memsys2 的优势是比 Valgrind 快得多,这意味着检查可以更频繁地完成,并且测试时间更长。
8.4. 互斥断言
SQLite 包含一个可插入的互斥子系统。根据编译时选项,默认的互斥锁系统包含接口 sqlite3_mutex_held()和sqlite3_mutex_notheld()来检测调用线程是否持有特定的互斥锁。这两个接口在 SQLite 的 assert() 语句中广泛使用,以验证在所有正确的时刻持有和释放互斥锁,以便仔细检查 SQLite 是否在多线程应用程序中正常工作。
8.5. 期刊测试
SQLite 为确保事务在系统崩溃和电源故障时是原子的而做的一件事是在更改数据库之前将所有更改写入回滚日志文件。TCL 测试工具包含一个替代的 操作系统后端实现,有助于验证这是否正确发生。“journal-test VFS”监视数据库文件和回滚日志之间的所有磁盘 I/O 流量,检查以确保没有首先写入并同步到回滚日志的数据库文件中没有任何内容。如果发现任何差异,则会引发断言错误。
日志测试是崩溃测试之上的额外双重检查,以确保 SQLite 事务在系统崩溃和电源故障时是原子的。
8.6. 未定义的行为检查
在 C 编程语言中,很容易编写具有“未定义”或“实现定义”行为的代码。这意味着代码可能在开发期间工作,但随后在不同的系统上或使用不同的编译器选项重新编译时给出不同的答案。ANSI C 中未定义和实现定义的行为示例包括:
- 有符号整数溢出。(有符号整数溢出不一定 像大多数人所期望的那样环绕。)
- 将 N 位整数移位 N 位以上。
- 移动负数。
- 移动一个负数。
- 在重叠缓冲区上使用 memcpy() 函数。
- 函数参数的评估顺序。
- “char”变量是有符号的还是无符号的。
- 等等……
由于未定义和实现定义的行为是不可移植的,并且很容易导致错误的答案,因此 SQLite 非常努力地避免它。例如,当作为 SQL 语句的一部分将两个整数列值相加时,SQLite 不会简单地使用 C 语言的“+”运算符将它们相加。相反,它首先检查以确保加法不会溢出,如果溢出,它会改用浮点数进行加法。
为了帮助确保 SQLite 不使用未定义或实现定义的行为,测试套件使用试图检测未定义行为的检测构建重新运行。例如,测试套件使用 GCC 的“-ftrapv”选项运行。然后使用 Clang 上的“-fsanitize=undefined”选项再次运行它们。并再次使用 MSVC 中的“/RTC1”选项。然后使用“-funsigned-char”和“-fsigned-char”等选项重新运行测试套件,以确保实现差异也无关紧要。然后使用各种 CPU 架构在 32 位和 64 位系统以及大端和小端系统上重复测试。此外,测试套件还增加了许多测试用例,这些测试用例是故意设计来引发未定义行为的。例如:选择 -1*(-9223372036854775808); ”。
9.禁用优化测试
sqlite3_test_control ( SQLITE_TESTCTRL_OPTIMIZATIONS , ...) 接口允许在运行时禁用选定的 SQL 语句优化。SQLite 应该始终在启用优化和禁用优化的情况下生成完全相同的答案;启用优化后,答案会更快地到达。因此在生产环境中,人们总是将优化保持打开状态(默认设置)。
SQLite 上使用的一种验证技术是运行整个测试套件两次,一次保持优化,第二次关闭优化,并验证两次获得相同的输出。这表明优化不会引入错误。
并不是所有的测试用例都可以这样处理。一些测试用例通过计算磁盘访问、排序操作、全扫描步骤或查询期间发生的其他处理步骤的次数来检查以验证优化是否确实减少了计算量。当禁用优化时,这些测试用例似乎会失败。但是大多数测试用例只是简单地检查是否获得了正确的答案,并且所有这些用例在有和没有优化的情况下都可以成功运行,以表明优化不会导致故障。
10.清单
SQLite 开发人员使用在线检查表来协调测试活动,并在每次 SQLite 发布之前验证所有测试是否通过。 保留过去的清单以供历史参考。(检查列表对于匿名互联网查看者是只读的,但开发人员可以在他们的 Web 浏览器中登录并更新检查列表项目。)使用检查列表进行 SQLite 测试和其他开发活动的灵感来自The Checklist Manifesto。
最新的清单包含大约 200 个项目,每个版本都经过单独验证。一些清单项目只需要几秒钟来验证和标记。其他涉及运行数小时的测试套件。
发布清单不是自动的:开发人员手动运行清单上的每个项目。我们发现让一个人参与其中很重要。有时,即使测试本身通过了,运行检查列表项时也会发现问题。重要的是要有人在最高级别审查测试输出,并不断地问“这真的正确吗?”
发布清单在不断发展。当发现新问题或潜在问题时,会添加新的清单项目以确保这些问题不会出现在后续版本中。发布核对清单已被证明是一种非常宝贵的工具,可帮助确保在发布过程中不会遗漏任何内容。
11.静态分析
静态分析意味着在编译时分析源代码以检查正确性。静态分析包括编译器警告消息和更深入的分析引擎,例如 Clang Static Analyzer。SQLite 在 GCC 和 Clang 上使用 -Wall 和 -Wextra 标志在 Linux 和 Mac 上以及在 Windows 上的 MSVC 上编译时没有警告。Clang 静态分析器工具“scan-build”也没有生成有效警告(尽管最近版本的 clang 似乎生成了许多误报。)不过,其他静态分析器可能会生成一些警告。鼓励用户不要对这些警告感到压力,而是在上述 SQLite 的紧张测试中寻求安慰。
静态分析对查找 SQLite 中的错误没有帮助。静态分析在 SQLite 中发现了一些错误,但这些都是例外。SQLite 在试图让它在没有警告的情况下进行编译时引入了比静态分析发现的更多的错误。
12.总结
SQLite 是开源的。这让很多人认为它作为商业软件没有经过很好的测试,可能不可靠。但这种印象是错误的。SQLite 在该领域表现出非常高的可靠性和非常低的缺陷率,特别是考虑到它的发展速度。SQLite 的质量部分是通过仔细的代码设计和实现实现的。但是广泛的测试对于维护和提高 SQLite 的质量也起着至关重要的作用。本文档总结了 SQLite 的每个版本都经过的测试过程,希望能激发人们对 SQLite 适用于关键任务应用程序的信心。