编译技术教程:附录 - 项目测试
系列导航: 《编译技术教程:前言》 《编译技术教程:词法分析》 《编译技术教程:抽象语法树》 《编译技术教程:语法分析》 《编译技术教程:符号表与类型系统》 《编译技术教程:语义分析》 《编译技术教程:附录 - 项目测试》 项目的规模越大,越难以通过直接观察发现隐藏的代码缺陷。在实验中,各位同学需要实现的是一个万行级别的编译器项目。在这一情景下若不对自己的开发过程进行规范,及时进行项目测试,必然会严重影响代码质量,进而影响各位同学的实验进展。 因此在本附录中,我们将结合 tolangc 项目,提供一个较为简单,但具体可行的项目测试示例,以供同学们参考。 一、单元测试 (1)单元测试的结构 单元测试样例用于对程序中的最小功能模块进行测试,能够验证功能模块内部的正确性。一般来说,一个单元测试对应一个类或一个函数。对于编译器这一架构得到了充分研究的程序类型,一般来说我们可以选择为各个编译阶段设计对应的单元测试。例如在 tolangc 中,我们为 Lexer、Parser、Visitor 等等分别编写了单元测试。 一个单元测试样例是对某一功能进行的一次测试。不同的样例之间应当尽可能相互独立、互不影响。这也意味着单元测试的执行顺序不应当影响测试的结果。 需要注意的是,一些单元测试框架可能会提供设定样例执行顺序的功能,例如在某一样例成功之后执行等等。但是此处的执行顺序实际上反映的是功能模块之间的依赖关系,意为 “由于 A 模块依赖于 B 模块,所以如果 B 模块的测试出错,那么 A 模块的测试便没有意义”,而并不意味着单元测试之间可以相互关联。 由于上述特点,单元测试实际上应当看作一一个不同的可执行程序。但和整个项目所组成的程序不同,单个功能模块往往无法单独运行,且不像程序那样有着明确的输出。这就需要我们为单元测试构建测试所需的环境,并对模块的运行结果进行检验。这组成了单元测试所需要遵循的三个阶段:构造、操作和检验。 CMake 中提供的 “测试” 实际上就是这样的可执行程序。 构造指设定模块的运行环境,例如参数、依赖的对象、数据库中数据等等。构造在每个单元测试中都要进行,绝不能依赖其他单元测试的运行结果。运行环境当中需要重点关注的是可以被所有单元测试访问的单例和全局变量,以及可以实现数据持久化的文件和数据库。如果存在单元测试之间相互影响的风险,则需要在单元测试结束后进行重置操作。 操作指功能模块的运行。当构造完成运行环境之后,我们希望功能模块能够按照预期方式正确运行。 最后,检验指将功能模块的运行结果和预期结果进行比对。一般来说,测试框架会提供一系列用于比较结果的接口。具体的接口则视框架而定。这一部分的关键实际在于如何获取功能模块的运行结果。如果运行结果相对简单,如某个数值、单个数组等等,那么检验过程便相对简单。但如果运行结果的数据结构较为复杂,或运行结果位置分散,那么检验起来便较为困难。这时可以选择定义相应的信息提取函数,例如定义 to_string 函数将复杂数据结构转换为字符串。在 tolangc 中,对于语法树我们便采用了这样的处理方式。 (2)Mock 如果我们发现难以编写某个类或函数的单元测试代码,那可能意味着我们的类或函数需要进行进一步的拆分。难以测试可能是代码中的副作用或具体类之间的耦合导致的。对于类之间的耦合,我们需要遵循依赖倒置原则,用抽象的接口替代具体的实现。 例如,在实现 Lexer 时,我们没有将 ifstream 作为参数,而是将其父类 istream 作为参数。尽管对于 tolangc 来说,源代码总是从文件中读入的。 class Lexer { Lexer(std::istream &in) : _input(in) {} // ... } 这样做将使 Lexer 类更加可测试。若对 Lexer 类进行测试,我们不必创建真正的源文件。因为 istream 存在子类 istringstream。该类创建时需传入一字符串,而当从流中读取时,读到的便是传入的字符串的内容。...