Skip to content

Commit ed02e82

Browse files
committed
单元测试教程
1 parent b46a798 commit ed02e82

File tree

1 file changed

+123
-0
lines changed

1 file changed

+123
-0
lines changed
+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# 附录 - 项目测试
2+
3+
项目的规模越大,越难以通过直接观察发现隐藏的代码缺陷。在实验中,各位同学需要实现的是一个万行级别的编译器项目。在这一情景下若不对自己的开发过程进行规范,及时进行项目测试,必然会严重影响代码质量,进而影响各位同学的实验进展。
4+
5+
因此在本附录中,我们将结合 tolangc 项目,提供一个较为简单,但具体可行的项目测试示例,以供同学们参考。
6+
7+
## 一、单元测试
8+
9+
### (1)单元测试的结构
10+
11+
单元测试样例用于对程序中的最小功能模块进行测试,能够验证功能模块内部的正确性。一般来说,一个单元测试对应一个类或一个函数。对于编译器这一架构得到了充分研究的程序类型,一般来说我们可以选择为各个编译阶段设计对应的单元测试。例如在 tolangc 中,我们为 `Lexer``Parser``Visitor` 等等分别编写了单元测试。
12+
13+
一个单元测试样例是对某一功能进行的一次测试。不同的样例之间应当**尽可能相互独立、互不影响**。这也意味着单元测试的执行顺序不应当影响测试的结果。
14+
15+
需要注意的是,一些单元测试框架可能会提供设定样例执行顺序的功能,例如在某一样例成功之后执行等等。但是此处的执行顺序实际上反映的是功能模块之间的依赖关系,意为 “由于 A 模块依赖于 B 模块,所以如果 B 模块的测试出错,那么 A 模块的测试便没有意义”,而并不意味着单元测试之间可以相互关联。
16+
17+
由于上述特点,单元测试实际上应当看作**一一个不同的可执行程序**。但和整个项目所组成的程序不同,单个功能模块往往无法单独运行,且不像程序那样有着明确的输出。这就需要我们为单元测试构建测试所需的环境,并对模块的运行结果进行检验。这组成了单元测试所需要遵循的三个阶段:**构造****操作****检验**
18+
19+
> CMake 中提供的 “测试” 实际上就是这样的可执行程序。
20+
21+
构造指设定模块的运行环境,例如参数、依赖的对象、数据库中数据等等。构造在每个单元测试中都要进行,绝不能依赖其他单元测试的运行结果。运行环境当中需要重点关注的是可以被所有单元测试访问的单例和全局变量,以及可以实现数据持久化的文件和数据库。如果存在单元测试之间相互影响的风险,则需要在单元测试结束后进行重置操作。
22+
23+
操作指功能模块的运行。当构造完成运行环境之后,我们希望功能模块能够按照预期方式正确运行。
24+
25+
最后,检验指将功能模块的运行结果和预期结果进行比对。一般来说,测试框架会提供一系列用于比较结果的接口。具体的接口则视框架而定。这一部分的关键实际在于如何获取功能模块的运行结果。如果运行结果相对简单,如某个数值、单个数组等等,那么检验过程便相对简单。但如果运行结果的数据结构较为复杂,或运行结果位置分散,那么检验起来便较为困难。这时可以选择定义相应的信息提取函数,例如定义 `to_string` 函数将复杂数据结构转换为字符串。在 tolangc 中,对于语法树我们便采用了这样的处理方式。
26+
27+
### (2)Mock
28+
29+
如果我们发现难以编写某个类或函数的单元测试代码,那可能意味着我们的类或函数需要进行进一步的拆分。难以测试可能是**代码中的副作用****具体类之间的耦合**导致的。对于类之间的耦合,我们需要遵循依赖倒置原则,用抽象的接口替代具体的实现。
30+
31+
例如,在实现 `Lexer` 时,我们没有将 `ifstream` 作为参数,而是将其父类 `istream` 作为参数。尽管对于 tolangc 来说,源代码总是从文件中读入的。
32+
33+
```cpp
34+
class Lexer {
35+
Lexer(std::istream &in) : _input(in) {}
36+
// ...
37+
}
38+
```
39+
40+
这样做将使 Lexer 类更加可测试。若对 Lexer 类进行测试,我们不必创建真正的源文件。因为 `istream` 存在子类 `istringstream`。该类创建时需传入一字符串,而当从流中读取时,读到的便是传入的字符串的内容。
41+
42+
由于被测试的类所依赖的是抽象的接口,所以在满足接口行为的前提下,我们可以仿造模块的输入,或让模型输出到模拟的接口当中。
43+
44+
对于程序中的各个模块,我们均可以采用此种方法。例如对 `Paser` 类的测试。当然,由于 tolangc 的规模较小,所以我们并没有对 `Lexer` 类进行模拟。但我们可以将其一个例子。
45+
46+
为了对 `Parser` 进行测试,我们需要将原本的 `Lexer` 转变为抽象的接口。为了方便,我们这里保持 `Lexer` 的名称不变,但将其定义为抽象类。
47+
48+
```cpp
49+
class Lexer {
50+
public:
51+
virtual void next(Token &token) = 0;
52+
}
53+
```
54+
55+
此时,我们将原本的 `Lexer` 实现搬移到另一个类 `LexerImpl` 中。该类是 `Lexer` 的子类。
56+
57+
```cpp
58+
class LexerImpl : public Lexer {
59+
public:
60+
void next(Token &token) override;
61+
// ...
62+
}
63+
```
64+
65+
最后,我们将原本创建 `Lexer` 对象的语句修改为创建 `LexerImpl`。经过重构之后,`Lexer` 的接口和实现完成了分离。
66+
67+
为了测试,我们再定义模拟类 `MockLexer`。该类的行为应当和 `LexerImpl` 相同,当调用 `next` 函数时,返回新的 `Token`。当读到 `Token` 序列的末尾时,此后对 `next` 的调用返回的类型为 `TK_EOF`。
68+
69+
唯一的不同是,`MockLexer` 返回的序列将由我们给定。这意味着构造 `MockLexer` 时不应再传入 `istream`,而是一个单词序列 `std::vector<Token>`。
70+
71+
```cpp
72+
class MockLexer : public Lexer {
73+
public:
74+
void next(Token &token) override;
75+
76+
MockLexer(std::vector<Token>& tokens) : _tokens(tokens) {}
77+
// ...
78+
}
79+
```
80+
81+
`MockLexer``next` 函数的实现较为简单,这里就不再详细介绍。
82+
83+
定义 `MockLexer` 之后,我们便可以修改 tolangc 中 `Parser` 的测试代码。
84+
85+
```cpp
86+
// test_parser.cpp
87+
88+
// ...
89+
90+
TEST_CASE("testing lexer") {
91+
std::vector<Token> input = {
92+
Token(Token::TK_FN, "fn", 1), Token(Token::TK_IDENT, "nonParam", 1),
93+
Token(Token::TK_LPARENT, "(", 1), Token(Token::TK_RPARENT, ")", 1),
94+
// ...
95+
};
96+
MockLexer lexer(input);
97+
Parser parser(lexer);
98+
99+
auto ast = parser.parse();
100+
101+
std::ostringstream oss;
102+
root->print(oss);
103+
CHECK(oss.str() == EXPECTED);
104+
}
105+
```
106+
107+
## 二、集成测试
108+
109+
尽管单元测试保证了功能模块内部的正确性,但模块之间不合理的配合也会导致功能的错误。这时集成测试就十分必要。对于编译器这样有着明确输入输出的程序,编写源代码作为测试样例是十分有效的方法。
110+
111+
> “将源代码作为测试样例” 也适用于广义的编译器,如排版系统,文本渲染器等。
112+
113+
当然,如果每次测试都需要手动进行编译,那就太不方便了。合理的做法是为集成测试编写自动化脚本。接下来我们介绍一下 tolangc 中的集成测试脚本。
114+
115+
### (1)测试样例规定
116+
117+
118+
119+
### (2)
120+
121+
122+
123+
## 三、流水线

0 commit comments

Comments
 (0)