SQLite 文档的学习笔记(2)测试方法

翻译 2022年 8月 4日
标签: TESTING

系列: SQLite的测试方法

先打个广告:欢迎关注我的公众号,参与 文史大挑战 趣味题目。使用方法见 这篇文章

公众号:晚花行乐

正文开始:

这篇文章 里,提到了 SQLite 采用了航空级别的测试过程。可惜这些文档的中文版,看起来是使用机器翻译的。我在这里给出一份自己翻译的中文版。

学习材料: How SQLite Is Tested , 中文版:如何测试SQLite

1. 引言

SQLite 的可靠与健壮,有一部分来自深入彻底、悉心设计的测试。

截止 2022年6月25日发布的 3.39.0 版本时,SQLite 库的规模大约是 151.3 KSLOC KSLOC 是指 “千行源代码”,意味着包括了空行、注释等等在内的统计 (15.13 万行)C语言源码。

相比之下,测试代码和脚本规模是 92038.3 KSLOC(9203.83万行),是上述库文件的 608 倍之多。

测试内容提要

  • 4种独立开发的测试套件
  • 按实际部署配置下的 100% 分支覆盖率
  • 数量达几百万的测试用例规模
  • 内存不足测试
  • I/O 异常测试
  • 崩溃和掉电测试
  • 模糊测试
  • 边界值测试
  • 禁用优化测试
  • 回归测试
  • 数据格式错误测试
  • 大量使用断言和程序运行时检查
  • Valgrind 测试
  • 预设行为之外的测试
  • 检查清单

2. 测试套件

SQLite 核心库的测试,有4套相互独立开发的工具完成。每一套工具的设计、维护、管理过程,都和其他三种互不相干。这四种测试工具套件如下:

  • TCL 测试集
  • TH3 测试集
  • SQL 逻辑测试
  • dbsqlfuzz

TCL 测试集是 SQLite 最初的测试集合,和 SQLite 核心库的源代码在同一套代码库中,并且一起公开。在 SQLite 开发过程中,主要使用 TCL 测试集做测试。TCL 测试集使用 TCL 脚本语言编写。TCL 测试集中,为了创建 TCL 界面的 C 语言源代码有 27.7 千行。测试脚本共有 1343 个文件,共有 23.5 MB 大小。从测试集规模上说,有50240个独立的测试用例。而且,这其中有许多测试用例是高度参数化的,为了遍历参数集,一条测试用例会执行很多次。因此,一次完整的测试运行会包含数百万次的用例执行。

TH3 测试集,则是一套 SQLite 内部私有的测试集,用 C 语言编写,提供了针对 SQLite 核心库的 100% 的分支覆盖率、和 100% 的 MC/DC MC/DC 是 Modified Condition/Decision Coverage 的简称,航空工业软件会要求这种覆盖测试 测试覆盖率。设计编写 TH3 测试集的原由,是为那些不方便运行 TCL 的嵌入式系统或者专用系统平台上能够进行测试。TH3 测试仅使用已发布的 SQLite 接口。TH3 测试集文件的体积有 75.7 MB ,代码规模有 1038.0 KSLOC 的 C 代码,实现了 49116 个不同的测试用例。和上面的 TCL 测试集一样,TH3 测试是高度参数化的,因此全覆盖测试运行大约 230 万个不同的测试运行实例。TH3 测试套件中有一个用例子集,能提供 100% 的分支覆盖率。发布前的浸泡测试会进行数亿次测试。有关 TH3 的更多信息可通过单独途径获得。

SQL 逻辑测试 ,或者简称为 “SLT 测试” ,包含巨量的 SQL 语句,分别向 SQLite 和其他若干 SQL 数据库查询,并对比验证是否能得到相同的答案。目前, SLT 测试将 SQLite 与 PostgreSQL、MySQL、Microsoft SQL Server 和 Oracle 10g 进行比较。一次 SLT 测试运行 720 万次查询,包括 1.12GB 的测试数据。

dbsqlfuzz 引擎是一个 SQLite 内部私有的 模糊测试器。 fuzz tester SQLite 的一般模糊器会要么只改变 SQL 输入或者只改变数据库文件。Dbsqlfuzz 同时改变 SQL 和数据库文件,因此能够产生前所未有的程序错误。Dbsqlfuzz 使用 LLVM 的 libFuzzer 框架和自定义 mutator 创建。有 303 个种子文件。dbsqlfuzz fuzzer 每天运行大约 10 亿个测试突变。Dbsqlfuzz 有助于确保 SQLite 能够抵御来自恶意 SQL 或其他数据库输入的攻击。

除了上述四个主要的测试工具之外,还有其他若干小程序用来专项测试。

speedtest1.c 程序用来评估 SQLite 在正常负载下的性能表现。

mptester.c 程序用来模拟多个进程对同一个数据库并发读写的性能表现。

threadtest3.c 程序用来模拟多个线程同时使用 SQLite 的压力测试。

fuzzershell.c 程序用来进行一些模糊测试。

上述所有测试必须在所有平台、多种编译条件下全部通过才能正式发布 SQLite 版本。

在开发人员每次提交代码之前,一般会运行一套 Tcl 测试集 称为 veryquick 测试 ,其中包含大约 30.02 万个测试用例。veryquick 测试包括了除异常、模糊和浸泡测试之外的大多数测试。veryquick 测试的意图是它们能以较短的时间覆盖大多数错误,执行时间只有几分钟,而非几个小时。

3. 异常测试

异常测试的目的是验证在出现异常情况时,SQLite 能否做出正确的行为。建造一个在功能正常的计算机上能处理正确格式的 SQL 的数据库引擎是(相对)容易的,相比之下,构建一个对无效输入做出合理响应并在系统故障后继续运行的系统则更加困难。异常测试针对的是后一种情况。

内存不足测试

与其他 SQL 数据库引擎一样,SQLite 大量使用 malloc()(分配内存函数,详情请参阅关于 SQLite 动态内存分配的章节)。当 SQLite 运行在 服务器和工作站 相比手机等移动设备,这里的内存是海量的 时,malloc() 函数不会失败,所以这种情况下,能否正确处理内存不足 (OOM) 错误并不是特别重要。但是实际上,SQLite 更为常见的是在嵌入式设备上使用,嵌入式设备上的内存不足 (OOM) 现象非常普遍,因此 SQLite 能否优雅地处理 OOM 错误就非常重要。

OOM 异常测试是通过模拟产生 OOM 错误来实现的。SQLite 有一项配置,可以让程序不使用默认原始的 malloc() ,而是用一个受控的 malloc() 的函数替代。在上述的 TCL测试工具 和 TH3 测试工具中,都可以使用修改了的 malloc() 版本,目的是在分配内存达到一定次数后故意失败。这些受控的 malloc 行为可以设置为失败一下,然后恢复正常状态,或者在第一次失败后保持失败状态。OOM 测试是个循环过程。在第一次循环迭代中,受控的 malloc 被指定在第一次分配内存时即报告失败。接着执行一些 SQLite 操作并检查SQLite 的行为,以确保 SQLite 能正确处理 OOM 错误。然后这个受控的 malloc 函数内部的故障计数器递增,并再次开始测试。循环继续,直到不会遇到任何模拟的 OOM 故障为止。像这样的测试会运行两次,一次将 malloc 设置为仅失败一次后继续正常工作,第二次将 malloc 设置为在第一次失败后保持失败状态。

I/O 异常测试

3.2. I/O 异常测试,旨在验证 SQLite 对失败的 I/O 操作能否做出合理的响应。I/O 错误的原因可能有磁盘空间满、磁盘硬件故障、使用网络文件系统时网络中断、SQL 操作过程中发生的系统配置或权限更改或其他硬件或操作系统故障。不管具体原因是什么,I/O 异常测试的目的就是验证 SQLite 能够针对这些错误做出正确的反应,这些是至关重要的。

从概念上讲,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 完整性检查以确保 I/O 错误没有导致数据库损坏。

崩溃异常测试

崩溃测试的目的是验证 SQLite 数据库在遇到程序或者操作系统崩溃时,或在数据库写入过程中出现电源故障等场景时,数据库不会损坏。有一份名为 Atomic Commit in SQLite 的白皮书描述了 SQLite 为防止崩溃后数据库损坏而采取的防御措施。崩溃测试的目的是验证这些防御措施是否能正常工作。

当然,用真实的电源故障进行崩溃测试并不具备可操作性,所以需要用模拟的方法。加入了一个替代虚拟文件系统,使测试工具能得到一个崩溃后数据库文件。

在 TCL 测试工具中,模拟崩溃测试是在另一个进程中完成的。主测试进程产生一个子进程,这个子进程负责执行 SQLite 操作,同时在写操作中随机的制造程序崩溃。一个特殊的 VFS Virtual File System 用于将那些未及时同步入数据库的写操作重新排序并破坏,目的是模拟缓冲文件系统的在崩溃时的行为。在子进程结束运行后,原先的测试主进程打开测试数据库,并读取数据,检查被测试子进程影响的过程是否仍然顺利地完成或者完整的回滚。使用 PRAGMA 完整性检查数据库没有发生损坏。

对 TH3 测试工具而言,因为是运行在嵌入式系统中,这种系统未必能产生子进程,因此使用内存中的 VFS 来模拟崩溃。在一系列 I/O 操作之后,内存中的 VFS 用来制作整个文件系统的快照。崩溃测试是个循环过程,在每次循环中,制作快照的时间点都会比前一次循环推后一些,直到被测试的 SQLite 操作全部完成而没有命中快照。在一次循环中,每一次的 SQLite 操作结束后,将快照恢复到文件系统中,并随机地造成文件损坏,这是断电后常见的文件系统损坏。然后打开数据库并检查数据文件的格式是否仍然正确、数据库事务要么顺利地完成,要么完整地回滚。对于每一个快照,循环体会执行多次,而且每次都有不同位置的随机损坏。

组合异常测试

为了达到探索测试的目的,还会将几种异常的情况复合叠加。例如,可以在一次模拟程序崩溃的复原过程中,模拟产生 I/O 错误或 OOM 故障,并验证此时程序的行为是否能保持正常。

4. 模糊测试

模糊测试的目的是确保 SQLite 针对无效的、或是越界的、或者错误格式的输入,能有正确的响应输出。

SQL Fuzz 模糊测试

SQL 模糊测试

SQL 模糊测试使用语法虽然正确但含义荒谬的 SQL 语句,提供给 SQLite 以观察如何处理。正常情况下,会返回某种 错误。 例如 “no such table” 有的时候,SQL 语句偶然在语义上也是正确的。在这种情况下,一些预处理语句以确保它给出合理的结果。

使用 AFL 的 SQL Fuzz

模糊测试的概念尽管已经存在了几十年,但是直到 2014 年 Michal Zalewski 才首次发明了一个实际可用的、使用配置文件的模糊器:American Fuzzy Lop 或者叫 “AFL”。与先前盲目生成随机输入的模糊器不同,AFL 通过修改 C 编译器的汇编语言输出的方法,修改调整被测试的程序,并使用该调整手段来寻找导致程序改变输出的时机,以寻找一个不同的控制路径或恰当的循环次数。能够让程序改变输出的输入数据被保存下来,并在此基础上进一步异化。通过这种方式,AFL 能够“探索”被测程序的未知行为,甚至包括设计者从未设想到过的行为。

事实证明,AFL 擅长在 SQLite 中发现神秘的 bug。大多数 bug 都是在难以想象的条件下被 assert() 语句断定为 false 的情况下发生的。同时 AFL 还在 SQLite 中发现了相当多的引起崩溃的 bug ,甚至还发现了一些 SQLite 给出错误结果的 bug 。

由于 AFL 的成功使用,从版本 3.8.10 (2015-05-07) 开始成为 SQLite 测试策略的一部分,直到在版本 3.29.0 (2019-07-10) 中被更好的模糊器所取代。

使用 Google OSS Fuzz

从 2016 年开始,一个 Google 团队创立了 OSS Fuzz 项目,这个项目在 Google 的运行架构上使用 AFL 风格的引导式模糊器。这种 Fuzzer 会自动下载被测试项目的最新提交版本,并对其开展模糊测试,出现问题后即向开发人员发送邮件。提交了修复代码后,模糊器会自动检测到并再发一封确认邮件。

OSS Fuzz 并不只用来测试 SQLite 一种软件,许多其他的开源项目也在使用。源文件中的 test/ossfuzz.c 描述了 SQLite 对 OSS fuzz 的接口。OSS Fuzz 不再用于查找 SQLite 的早前 bug,但还是会定期测试 SQLite 的新提交的代码,寻找可能存在的缺陷。

使用 dbsqlfuzz fuzzer

2018 年底,SQLite 开始使用名为 “dbsqlfuzz” 的专用模糊器开展模糊测试。dbsqlfuzz 使用 LLVM 的 libFuzzer 框架构建。

dbsqlfuzz 模糊器同时变异 SQL 输入命令和数据库文件。dbsqlfuzz 使用一个对文件结构敏感的变异器,这个变异器以一个特别设计的文件为输入,文件包含两项:被测的数据库和 SQL 命令语句。由于 dbsqlfuzz 同时变异 SQL 输入命令和数据库文件,它可以发现早前的模糊器所不能发现的隐藏缺陷。SQLite 的开发人员在 trunk 代码上持续的使用16核执行 dbsqlfuzz,每个 dbsqlfuzz 程序实例每秒能执行 400 条测试用例,意味着每天能检查 5亿个用例。(16x400x24x60x60=552,960,000)

dbsqlfuzzfuzzer 在加固SQLite代码库、预防恶意攻击方面取得了极大的成功。由于 dbsqlfuzz 已加入到 SQLite 内部测试套件中,来自例如OSSFuzz 等外部模糊器的缺陷报告已几乎停止。

注意,dbsqlfuzz 不是基于 Protobuf 的文件结构敏感的变异器,这种变异器为 Chromium 使用。注意,dbsqlfuzz 和 基于 Protobuf 这两种模糊器之间没有任何联系,尽管它们都基于 libFuzzer。Protobuf 模糊器由 Google 的 Chromium 团队编写和维护,而dbsqlfuzz由 SQLite开发人员编写和维护。使用各自独立开发的模糊器对 SQLite 有好处,更容易发现隐藏的缺陷。

其他第三方模糊器

SQLite 似乎是第三方模糊器的热门目标。开发人员经常能听说其他独立开发的模糊器的正在尝试测试 SQLite,也确实收到过来自他们的缺陷报告。所有这样的报告都被及时修复,因此产品得到了改进,整个SQLite用户群体都从中受益。这种拥有许多独立测试人员的机制符合 Linus 定律: “只要盯着的眼睛够多,所有的 bug 都是显而易见的” Given enough eyeballs, all bugs are shallow.

值得一提是一位专门研究模糊测试的 Manuel Rigger,他 目前 撰写本文的 2019年12月21日 在苏黎世联邦理工学院工作。一般的模糊器只查找断言错误、崩溃、未定义行为(UB)或其他比较容易检测到的异常。然而,Rigger 博士的模糊器能够发现 SQLite 给出错误答案的情况。Rigger 发现了很多这样的错误场景。这些发现中的大多数都是难以触及的角落场景,例如数据类型转换、关联转换等等,而且有相当数量的缺陷也存在于尚未发布的功能里。他的发现很重要,因为它们是真正的bug,能帮助识别并修复 SQLite 潜在的问题,开发人员表示很感激。Rigger 尚未发布他的模糊器,若有一天发布时,它可能会像 Zalewski 发明的 AFL 和 配置文件引导的模糊器一样具有影响力。

fuzzcheck 测试工具

AFL、OSS Fuzz、dbsqlfuzz下的回归测试用例存放于 SQLite 源代码的一组数据文件中,当运行 make test 时,fuzzcheck 程序就运行测试。这些年来,各种模糊器已经了测试了数十亿个测试用例, fuzzcheck 只运行其中数千个 “感兴趣” 的案例。“感兴趣”表示在之前有不可见的行为。fuzzer 的真实 bug 总是被这些 “感兴趣” 的用例发现,而其他的大多数用例从来都没有发现实际的 bug。

模糊测试和 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 测试,同时更多的测试资源用于模糊测试。

虽然模糊测试和100%MC/DC测试存在紧张关系,但二者并不是毫不相干。事实上,SQlite 执行了 100% MC/DC 测试后再开展模糊测试,发现的 bug,都可以快速解决,并且引入新错误的风险很小。

格式错误的数据库文件

针对SQLite是否能够处理格式错误的数据库文件,有许多测试用例。这些测试用例首先构建一个正确格式的数据库文件,然后通过SQLite外部的其他方式更改文件中的一个或多个字节来使其损坏。再使用SQLite读取数据库。如果字节更改在数据的位置,只会导致数据的内容发生变化,而数据库文件继续保持的正确的格式。如果修改到文件中未使用的字节,丝毫不影响数据库文件的完整性。需要关注的场景是,修改的字节位于数据库结构的定义。需要验证SQLite是否能够发现文件格式错误,而且返回 SQLite_CORRUPT错误码,而不会产生缓冲区溢出错误、取消引用空指针或执行其他有害动作。

在验证 SQLite 是否能正确对待错误数据库文件问题上,dbsqlfuzz 模糊器做的很好。

边界值测试

SQLite对某些操作定义了边界限制,例如:表中的最大列数、SQL语句的最大长度、整型变量的最大值。TCL和TH3测试套件都包含大量针对此类型的测试,这些测试将 SQLite 处于极限的边缘处运行,并验证它对所有正常的值条件下,是否能正确运行。还有一些测试,则超出了定义的限制,目的是验证SQLite是否如期返回错误码。源代码包含测试用例宏,用于确保每个边界值的两侧都通过了测试。

5. 回归测试

每当发现 SQLite 的 bug 时,会针对这个 bug 设计新的测试用例,并添加到TCL或TH3测试套件中,只有这个新的测试用例通过后,才会认为这个 bug 已经修复。多年积累下来,以此产生了成千上万的新测试用例。这些回归测试确保了曾经发生的的 bug 在 SQLite 的新版本中不会再次出现。

6. 资源泄漏的自动检测

当分配出去的系统资源再也没有被释放回收时,就产生了资源泄漏。在大多数应用程序中,内存泄漏是最麻烦的一种资源泄漏,比如使用 malloc() 分配内存,但从未使用free()释放内存,就会产生内存泄漏。而其他类型的资源也可能泄漏:比如文件描述符、线程、互斥锁等。

TCL和TH3测试工具都会自动监测系统资源,并在每次测试运行时上报是否存在资源泄漏,而无需特殊配置或设置。测试工具尤其警惕内存是否泄漏。如果某次代码的更改导致内存泄漏,测试工具将很快识别出这一点。按照设计,SQLite应当决不会泄漏内存,即使在发生OOM错误或磁盘I/O错误等异常之后也是如此。测试工具热衷于强制执行这一点。

7. 测试覆盖率

SQLite核心,包括unix VFS,在默认配置 TH3下有100%的分支测试覆盖率,这是由 gcov 统计出的。并不包含额外的测试,例如 FTS3 和 RTree 之类的扩展。

7.1 语句覆盖率 vs. 分支覆盖率

有很多方法可以衡量测试覆盖率。最流行的衡量标准是 “语句覆盖率” statement coverage 。当你听到有人说他们的程序是 “测试覆盖率达到百分之多少” 而没有进一步解释时,他们通常指的是语句覆盖率。语句覆盖率的定义是,有多少比例的代码被测试代码执行了至少一次。

“分支覆盖率” branch coverage 统计方法则比语句覆盖率更加严格。分支覆盖率指机器码的分支指令的两条路径,至少都被执行一次的比例。

为了展示上述两种覆盖率的不同,我们观察下面的这行 C 代码:

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

这行C代码会产生几十条机器码指令集。如果其中有任何一条机器码指令被执行,我们说这条语句是被测试了的。即使每次测试中,if 的条件总是 false,导致变量 d 总是不能自增,然而因为这行语句确实被执行了,所以按语句覆盖率统计方法,这一行会被认为测试通过了的。

分支覆盖率统计方法则更加严格。还是上面的例子,为了使每个判定条件和其中的组成部分定都会被单独处理,将至少产生 3 个测试用例:

a<=b
a>b && c==25
a>b && c!=25

以上三条用例中,执行任何一条用例都能提供 100% 的语句覆盖率,然而为了达到 100% 的分支覆盖率,必须执行全部三条。一般来说,如果达到了 100% 的分支覆盖率,则一定满足 100% 的 语句覆盖率 分支覆盖率的条件更强 但是反过来未必成立。再次强调:前面提到的 TH3 test 提供了强类型的测试覆盖:100% 分支覆盖率。

7.2 防御性代码的测试覆盖

好的 C 程序通常会包含一些防御性代码,程序正常运行时,这些代码总是 truefalse 的。这就造成了一个两难境地:为了获得 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(),我们希望它按照传递的参数返回真值条件。在 不统计覆盖率的版本 即正式发布版 中,这个宏什么也不做。宏的具体形式如下:

#define testcase(X)

但是在需要统计覆盖率的版本里,testcase() 宏按给予的参数的真值来生成对应的代码。在测试过程中,会检查所有产生分支的条件为 truefalse 时的情况。这时 testcase() 就派上用场了,比如,检验是否测试到边界值。

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

在 多条件分支的 switch 语句中, 也会用到 testcase() 宏,多次进入同一个 {} 代码块,来确保走遍各种情况。

switch( op ){
  case OP_Add:
  case OP_Subtract: {
    testcase( op==OP_Add );
    testcase( op==OP_Subtract );
    /* ... */
    break;
  }
  /* ... */
}

位掩码 bitmask tests 测试中,testcase() 宏用于验证位掩码的每一比特都对结果产生影响。例如,在下面的代码中,如果掩码中包含 MAIN_DB 或 TEMP_DB 二者中任何一个标志位,testcase() 宏就能得到 true 的结果。 位于 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测试覆盖率

上面介绍了语句覆盖和分支覆盖这两种测试方法。除这二者之外,还有好多其他测试覆盖率的评判方法,比如还有一种流行的叫 “Modified Condition/Decision Coverage” 简称 MC/DC。维基百科中 MC/DC 的定义为:

  • 每一次判断都会尝试每一个可能的结果
  • 每一个判断条件指向到每一个可能的结果
  • 每个入口点和出口点都要走到
  • 确认每个判断条件都各自独立地影响结果

在C编程语言中,&&|| 是 “短路”运算符,MC/DC和分支覆盖率几乎是一样的覆盖率指标。二者的主要区别在于 布尔向量测试 boolean vector tests 。当你针对 bit-vector 中的任意比特做测试时,上面列出的 MC/DC 就不能满足,然而这时仍能得到 100% 的分支覆盖率。

SQLite 使用上文提到的 testcase() 宏弥补了 bit-vector 的各种可能结果,这样就实现了 100% 的分支覆盖率 基础上,同样达到了 100% MC/DC 覆盖率。

7.5 统计分支覆盖率

目前,SQLite 使用 gcov 加上 -b 参数统计 分支覆盖率

Branch coverage in SQLite is currently measured using gcov with the “-b” option. First the test program is compiled using options “-g -fprofile-arcs -ftest-coverage” and then the test program is run. Then “gcov -b” is run to generate a coverage report. The coverage report is verbose and inconvenient to read, so the gcov-generated report is processed using some simple scripts to put it into a more human-friendly format. This entire process is automated using scripts, of course.

Note that running SQLite with gcov is not a test of SQLite — it is a test of the test suite. The gcov run does not test SQLite because the -fprofile-args and -ftest-coverage options cause the compiler to generate different code. The gcov run merely verifies that the test suite provides 100% branch test coverage. The gcov run is a test of the test - a meta-test.

After gcov has been run to verify 100% branch test coverage, then the test program is recompiled using delivery compiler options (without the special -fprofile-arcs and -ftest-coverage options) and the test program is rerun. This second run is the actual test of SQLite.

It is important to verify that the gcov test run and the second real test run both give the same output. Any differences in output indicate either the use of undefined or indeterminate behavior in the SQLite code (and hence a bug), or a bug in the compiler. Note that SQLite has, over the previous decade, encountered bugs in each of GCC, Clang, and MSVC. Compiler bugs, while rare, do happen, which is why it is so important to test the code in an as-delivered configuration.

7.6. Mutation testing Using gcov (or similar) to show that every branch instruction is taken at least once in both directions is good measure of test suite quality. But even better is showing that every branch instruction makes a difference in the output. In other words, we want to show not only that every branch instruction both jumps and falls through but also that every branch is doing useful work and that the test suite is able to detect and verify that work. When a branch is found that does not make a difference in the output, that suggests that the code associated the branch can be removed (reducing the size of the library and perhaps making it run faster) or that the test suite is inadequately testing the feature that the branch implements.

SQLite strives to verify that every branch instruction makes a difference using mutation testing. A script first compiles the SQLite source code into assembly language (using, for example, the -S option to gcc). Then the script steps through the generated assembly language and, one by one, changes each branch instruction into either an unconditional jump or a no-op, compiles the result, and verifies that the test suite catches the mutation.

Unfortunately, SQLite contains many branch instructions that help the code run faster without changing the output. Such branches generate false-positives during mutation testing. As an example, consider the following hash function used to accelerate table-name lookup:

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 } If the branch instruction that implements the “c!=0” test on line 58 is changed into a no-op, then the while-loop will loop forever and the test suite will fail with a time-out. But if that branch is changed into an unconditional jump, then the hash function will always return 0. The problem is that 0 is a valid hash. A hash function that always returns 0 still works in the sense that SQLite still always gets the correct answer. The table-name hash table degenerates into a linked-list and so the table-name lookups that occur while parsing SQL statements might be a little slower, but the end result will be the same.

To work around this problem, comments of the form “/OPTIMIZATION-IF-TRUE/” and “/OPTIMIZATION-IF-FALSE/” are inserted into the SQLite source code to tell the mutation testing script to ignore some branch instructions.

7.7. Experience with full test coverage The developers of SQLite have found that full coverage testing is an extremely effective method for locating and preventing bugs. Because every single branch instruction in SQLite core code is covered by test cases, the developers can be confident that changes made in one part of the code do not have unintended consequences in other parts of the code. The many new features and performance improvements that have been added to SQLite in recent years would not have been possible without the availability of full-coverage testing.

Maintaining 100% MC/DC is laborious and time-consuming. The level of effort needed to maintain full-coverage testing is probably not cost effective for a typical application. However, we think that full-coverage testing is justified for a very widely deployed infrastructure library like SQLite, and especially for a database library which by its very nature “remembers” past mistakes.

  1. Dynamic Analysis Dynamic analysis refers to internal and external checks on the SQLite code which are performed while the code is live and running. Dynamic analysis has proven to be a great help in maintaining the quality of SQLite.

8.1. Assert The SQLite core contains 6548 assert() statements that verify function preconditions and postconditions and loop invariants. Assert() is a macro which is a standard part of ANSI-C. The argument is a boolean value that is assumed to always be true. If the assertion is false, the program prints an error message and halts.

Assert() macros are disabled by compiling with the NDEBUG macro defined. In most systems, asserts are enabled by default. But in SQLite, the asserts are so numerous and are in such performance critical places, that the database engine runs about three times slower when asserts are enabled. Hence, the default (production) build of SQLite disables asserts. Assert statements are only enabled when SQLite is compiled with the SQLITE_DEBUG preprocessor macro defined.

See the Use Of assert in SQLite document for additional information about how SQLite uses assert().

8.2. Valgrind Valgrind is perhaps the most amazing and useful developer tool in the world. Valgrind is a simulator - it simulates an x86 running a Linux binary. (Ports of Valgrind for platforms other than Linux are in development, but as of this writing, Valgrind only works reliably on Linux, which in the opinion of the SQLite developers means that Linux should be the preferred platform for all software development.) As Valgrind runs a Linux binary, it looks for all kinds of interesting errors such as array overruns, reading from uninitialized memory, stack overflows, memory leaks, and so forth. Valgrind finds problems that can easily slip through all of the other tests run against SQLite. And, when Valgrind does find an error, it can dump the developer directly into a symbolic debugger at the exact point where the error occur, to facilitate a quick fix.

Because it is a simulator, running a binary in Valgrind is slower than running it on native hardware. (To a first approximation, an application running in Valgrind on a workstation will perform about the same as it would running natively on a smartphone.) So it is impractical to run the full SQLite test suite through Valgrind. However, the veryquick tests and the coverage of the TH3 tests are run through Valgrind prior to every release.

8.3. Memsys2 SQLite contains a pluggable memory allocation subsystem. The default implementation uses system malloc() and free(). However, if SQLite is compiled with SQLITE_MEMDEBUG, an alternative memory allocation wrapper (memsys2) is inserted that looks for memory allocation errors at run-time. The memsys2 wrapper checks for memory leaks, of course, but also looks for buffer overruns, uses of uninitialized memory, and attempts to use memory after it has been freed. These same checks are also done by valgrind (and, indeed, Valgrind does them better) but memsys2 has the advantage of being much faster than Valgrind, which means the checks can be done more often and for longer tests.

8.4. Mutex Asserts SQLite contains a pluggable mutex subsystem. Depending on compile-time options, the default mutex system contains interfaces sqlite3_mutex_held() and sqlite3_mutex_notheld() that detect whether or not a particular mutex is held by the calling thread. These two interfaces are used extensively within assert() statements in SQLite to verify mutexes are held and released at all the right moments, in order to double-check that SQLite does work correctly in multi-threaded applications.

8.5. Journal Tests One of the things that SQLite does to ensure that transactions are atomic across system crashes and power failures is to write all changes into the rollback journal file prior to changing the database. The TCL test harness contains an alternative OS backend implementation that helps to verify this is occurring correctly. The “journal-test VFS” monitors all disk I/O traffic between the database file and rollback journal, checking to make sure that nothing is written into the database file which has not first been written and synced to the rollback journal. If any discrepancies are found, an assertion fault is raised.

The journal tests are an additional double-check over and above the crash tests to make sure that SQLite transactions will be atomic across system crashes and power failures.

8.6. Undefined Behavior Checks In the C programming language, it is very easy to write code that has “undefined” or “implementation defined” behavior. That means that the code might work during development, but then give a different answer on a different system, or when recompiled using different compiler options. Examples of undefined and implementation-defined behavior in ANSI C include:

Signed integer overflow. (Signed integer overflow does not necessarily wrap around, as most people expect.) Shifting an N-bit integer by more than N bits. Shifting by a negative amount. Shifting a negative number. Using the memcpy() function on overlapping buffers. The order of evaluation of function arguments. Whether or not “char” variables are signed or unsigned. And so forth…. Since undefined and implementation-defined behavior is non-portable and can easily lead to incorrect answers, SQLite works very hard to avoid it. For example, when adding two integer column values together as part of an SQL statement, SQLite does not simply add them together using the C-language “+” operator. Instead, it first checks to make sure the addition will not overflow, and if it will, it does the addition using floating point instead.

To help ensure that SQLite does not make use of undefined or implementation defined behavior, the test suites are rerun using instrumented builds that try to detect undefined behavior. For example, test suites are run using the “-ftrapv” option of GCC. And they are run again using the “-fsanitize=undefined” option on Clang. And again using the “/RTC1” option in MSVC. Then the test suites are rerun using options like “-funsigned-char” and “-fsigned-char” to make sure that implementation differences do not matter either. Tests are then repeated on 32-bit and 64-bit systems and on big-endian and little-endian systems, using a variety of CPU architectures. Furthermore, the test suites are augmented with many test cases that are deliberately designed to provoke undefined behavior. For example: “SELECT -1*(-9223372036854775808);”.

  1. Disabled Optimization Tests The sqlite3_test_control(SQLITE_TESTCTRL_OPTIMIZATIONS, …) interface allows selected SQL statement optimizations to be disabled at run-time. SQLite should always generate exactly the same answer with optimizations enabled and with optimizations disabled; the answer simply arrives quicker with the optimizations turned on. So in a production environment, one always leaves the optimizations turned on (the default setting).

One verification technique used on SQLite is to run an entire test suite twice, once with optimizations left on and a second time with optimizations turned off, and verify that the same output is obtained both times. This shows that the optimizations do not introduce errors.

Not all test cases can be handled this way. Some test cases check to verify that the optimizations really are reducing the amount of computation by counting the number of disk accesses, sort operations, full-scan steps, or other processing steps that occur during queries. Those test cases will appear to fail when optimizations are disabled. But the majority of test cases simply check that the correct answer was obtained, and all of those cases can be run successfully with and without the optimizations, in order to show that the optimizations do not cause malfunctions.

  1. Checklists The SQLite developers use an on-line checklist to coordinate testing activity and to verify that all tests pass prior each SQLite release. Past checklists are retained for historical reference. (The checklists are read-only for anonymous internet viewers, but developers can log in and update checklist items in their web browsers.) The use of checklists for SQLite testing and other development activities is inspired by The Checklist Manifesto .

The latest checklists contain approximately 200 items that are individually verified for each release. Some checklist items only take a few seconds to verify and mark off. Others involve test suites that run for many hours.

The release checklist is not automated: developers run each item on the checklist manually. We find that it is important to keep a human in the loop. Sometimes problems are found while running a checklist item even though the test itself passed. It is important to have a human reviewing the test output at the highest level, and constantly asking “Is this really right?”

The release checklist is continuously evolving. As new problems or potential problems are discovered, new checklist items are added to make sure those problems do not appear in subsequent releases. The release checklist has proven to be an invaluable tool in helping to ensure that nothing is overlooked during the release process.

  1. Static Analysis Static analysis means analyzing source code at compile-time to check for correctness. Static analysis includes compiler warning messages and more in-depth analysis engines such as the Clang Static Analyzer. SQLite compiles without warnings on GCC and Clang using the -Wall and -Wextra flags on Linux and Mac and on MSVC on Windows. No valid warnings are generated by the Clang Static Analyzer tool “scan-build” either (though recent versions of clang seem to generate many false-positives.) Nevertheless, some warnings might be generated by other static analyzers. Users are encouraged not to stress over these warnings and to instead take solace in the intense testing of SQLite described above.

Static analysis has not been helpful in finding bugs in SQLite. Static analysis has found a few bugs in SQLite, but those are the exceptions. More bugs have been introduced into SQLite while trying to get it to compile without warnings than have been found by static analysis.

  1. Summary SQLite is open source. This gives many people the idea that it is not well tested as commercial software and is perhaps unreliable. But that impression is false. SQLite has exhibited very high reliability in the field and a very low defect rate, especially considering how rapidly it is evolving. The quality of SQLite is achieved in part by careful code design and implementation. But extensive testing also plays a vital role in maintaining and improving the quality of SQLite. This document has summarized the testing procedures that every release of SQLite undergoes with the hope of inspiring confidence that SQLite is suitable for use in mission-critical applications.

如果您对本文有疑问或者寻求合作,欢迎 联系邮箱邮箱已到剪贴板

标签: TESTING

系列: SQLite的测试方法

给个免费的赞吧~

精彩评论

本站 是个人网站,采用 署名协议 CC-BY-NC 授权。
欢迎转载,请保留原文链接 https://www.lfhacks.com/tech/how-sqlite-is-tested/ ,且不得用于商业用途。