This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,179 @@
<audio id="audio" title="25 | 不破不立:掌握代码级测试的基本理念与方法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/d5/b1fa413bf4ec340d9149fca848bbcdd5.mp3"></audio>
你好,我是茹炳晟,今天我和你分享的主题是“不破不立:掌握代码级测试的基本理念与方法”。
我在第三篇文章[《什么是单元测试?如何做好单元测试?》](https://time.geekbang.org/column/article/10275)中,为你介绍了单元测试的基本概念和方法,和你聊到了单元测试用例的“输入数据”和“预计输出”,也谈到了驱动代码和桩代码,其实这些概念和方法在代码级测试中也是最基本的。
通常情况下,代码级测试的工作都是由开发人员完成,但是测试框架选型、覆盖率统计工具选型、测试用例设计原则等都需要资深的测试工程师或者测试架构师参与。
所以,代码级测试这个系列,我会和你分享测试人员应该具备的代码级测试基础知识,为你呈现一幅包括代码级测试技术入门、方法论、用例设计,以及覆盖率衡量、典型难点、解决思路的全景技术视图。
为了能更好地协助开发人员做好代码级测试,所以我今天的这次分享是根据实际工程项目中的实践,总结了五种常见的代码错误,以及对应的四大类代码级测试方法。
掌握了这些错误类型、测试方法,相信你就可以搞定代码级测试了,即使自己不用去完成测试工作,也可以让开发人员对你另眼相看,可以更高效地互相配合完成整个项目。
这里需要注意的是,**代码级测试的测试方法一定是一套测试方法的集合,而不是一个测试方法。** 因为单靠一种测试方法不可能发现所有潜在的错误,一定是一种方法解决一部分或者一类问题,然后综合运用多种方法解决全部问题。
本着先发现问题,然后解决问题的思路,我在正式介绍代码级测试方法之前,先来概括一下常见的代码错误类型,然后我们再一起讨论代码级测试有哪些方法。这样,我们就可以清晰地看出,每一种代码级测试方法都能覆盖哪些类型的代码错误。
根据过往的经验来看,代码错误,可以分为“有特征”的错误和“无特征”的错误两大类。“有特征”的错误,可进一步分为语法特征错误、边界行为错误和经验特征错误;“无特征”的错误,主要包括算法错误和部分算法错误。
接下来,我将和你详细说说这五类代码错误的具体含义是什么。
## 常见代码错误类型
**第一,语法特征错误**
语法特征错误是指,从编程语法上就能发现的错误。比如,不符合编程语言语法的语句等。
如果你使用IDE环境进行代码开发那么IDE可以提示你大部分的这类错误而且只有解决了这类错误才能编译通过。但是还会有一些比较隐晦的语法特征错误IDE不能及时发现而且也不会影响编译只会在运行阶段出错。
```
void demoMethod(void)
{
int a[10];
a[10]=88;
...
}
```
比如这段C语言代码就存在数据越界的问题。
很显然你从语法上很容易就能发现这段代码初始化了一个长度为10的整型数组a但数组下标从0开始所以最大可用的数组空间应该是a[9]而这里却使用了a[10]造成数组越界访问了未被初始化的内存空间代码运行时Runtime就会造成意想不到的结果。
**第二,边界行为特征错误**
边界行为特征错误是指,代码在执行过程中发生异常,崩溃或者超时。之所以称为“边界”,是由于此类错误通常都是发生在一些边界条件上。
```
int Division(int a, int b)
{
return a/b;
}
```
这段C语言代码就存在具有边界行为特征的错误。当b取值为0时Division函数就会抛出运行时异常。
**第三,经验特征错误**
经验特征错误是指,根据过往经验发现代码错误。
```
void someMethod(void)
{
...
if(i=2)
{
// if the value of i equals to 2, call method &quot;operationA&quot;
operationA();
}
else
{
// if the value of i doesn't equal to 2, call method &quot;operationB&quot;
operationB();
}
}
```
这段C语言代码就是一个典型的具有经验特征错误的代码片段。代码想要表达的意思是如果变量i的值等于2就调用函数operationA否则调用函数operationB。
但是代码中将“if(i==2)”错误地写成了“if(i=2)”就会使原本的逻辑判断操作变成了变量赋值操作而且这个赋值操作的返回结果永远是true即这段代码永远只会调用operationA的分支。
显然“if(i=2)”在语法上没有错误,但是从过往经验来看,这就很可能是个错误了。也就是说,当你发现一个原本应该出现逻辑判断语句的地方,现在却出现了赋值语句,那就很有可能是代码写错了。
**第四,算法错误**
算法错误是指,代码完成的计算(或者功能)和之前预先设计的计算结果(或者功能)不一致。
这类错误直接关系到代码需要实现的业务逻辑,在整个代码级测试中所占比重最大,也是最重要的。但是,完全的算法错误并不常见,因为不能准确完成基本功能需求的代码,是一定不会被递交的。所以,在实际工程项目中,最常见的是部分算法错误。
**第五,部分算法错误**
部分算法错误是指,在一些特定的条件或者输入情况下,算法不能准确完成业务要求实现的功能。这类错误,是整个代码级测试过程中最常见的类型。
```
int add(int a, int b)
{
return a+b;
}
```
这段C语言代码完成了两个int类型整数的加法运算。在大多数情况下这段代码的功能逻辑都是正确的能够准确地返回两个整数的加法之和。但是在某些情况下可能存在两个很大的整数相加后和越界的情况也就是说两个很大的int数相加的结果超过了int的范围。这就是典型的部分算法错误。
## 代码级测试常用方法
介绍完了语法特征错误、边界行为特征错误、经验特征错误、算法错误、部分算法错误这五类代码错误后,我们再回过头来看看代码级测试的方法有哪些,这些测试方法又是如何揭露这五类代码错误的。
**在我看来,代码级测试方法主要分为两大类,分别是静态方法和动态方法。**
- 静态方法,顾名思义就是在不实际执行代码的基础上发现代码缺陷的方法,又可以进一步细分为人工静态方法和自动静态方法;
- 动态方法是指,通过实际执行代码发现代码中潜在缺陷的方法,同样可以进一步细分为人工动态方法和自动动态方法。
这里需要注意到的是,我在这篇文章中只会和你分享这四种方法具体是什么,各有何局限性和优势,分别可以覆盖哪些错误类型。而对于,具体如何用这四种方法完成代码级测试,测试用例如何设计、常用的测试工具如何使用,我会在后面两篇文章(《深入浅出之静态测试方法》和《深入浅出之动态测试方法》)中详细展开。
**第一,人工静态方法**
人工静态方法是指,通过人工阅读代码查找代码中潜在错误的方法,通常采用的手段包括,开发人员代码走查、结对编程、同行评审等。
**理论上,人工静态方法可以发现上述五类代码错误,但实际效果却并不理想。** 这个方法的局限性,主要体现在以下三个方面:
<li>
过度依赖于代码评审者的个人能力,同样的评审流程,发现的问题却相差悬殊;
</li>
<li>
如果开发人员自行走查自己的代码,往往会存在“思维惯性”,开发过程中没有能考虑的输入和边界值,代码走查时也一样会被遗漏;
</li>
<li>
由于完全依赖人工,效率普遍较低。
</li>
**第二,自动静态方法**
自动静态方法是指,在不运行代码的方式下,通过词法分析、语法分析、控制流分析等技术,并结合各种预定义和自定义的代码规则,对程序代码进行静态扫描发现语法错误、潜在语义错误,以及部分动态错误的一种代码分析技术。
**自动静态方法可以发现语法特征错误、边界行为特征错误和经验特征错误这三类“有特征”的错误**,但对于算法错误和部分算法错误这两种“无特征”的错误却无能为力。根本原因在于,自动静态方法并不清楚代码的具体业务逻辑。
目前,自动静态方法无论是在传统软件企业,还是在互联网软件企业都已经被广泛采用,往往会结合企业或项目的编码规范一起使用,并与持续集成过程紧密绑定。
你需要根据不同的开发语言选择不同的工具。目前有很多工具都可以支持多种语言比如Sonar、Coverity等你可以根据实际需求来选择。
**第三,人工动态方法**
人工动态方法是指,设计代码的输入和预期的正确输出的集合,然后执行代码,判断实际输出是否符合预期。我在之前的第三篇文章[《什么是单元测试?如何做好单元测试?》](https://time.geekbang.org/column/article/10275)中介绍的单元测试,采用的测试方法本质上就是人工动态方法。
在代码级测试中,**人工动态方法是最主要的测试手段,可以真正检测代码的逻辑功能,其关注点是“什么样的输入,执行了什么代码,产生了什么样的输出”,所以最善于发现算法错误和部分算法错误。**
目前不同的编程语言对应有不同的单元测试框架比如对Java语言最典型的是Junit和TestNG对于C语言比较常用的是Google Test等。
**第四,自动动态方法**
自动动态方法,又称自动边界测试方法,指的是基于代码自动生成边界测试用例并执行,以捕捉潜在的异常、崩溃和超时的方法。
**自动动态方法,可以覆盖边界行为特征错误,** 通常能够发现“忘记处理某些输入”引起的错误(因为容易忘记处理的输入,往往是“边界”输入)。但是它对于发现算法错误无能为力,毕竟工具不可能了解代码所要实现的功能逻辑。
## 总结
作为代码级测试系列的第一篇文章,我今天主要和你分享了代码级测试中的常见代码错误类型,以及常用测试方法。
代码错误,可以划分为“有特征”的错误和“无特征”的错误两大类。其中,“有特征”的错误,又可以进一步细分为语法特征错误、边界行为特征错误和经验特征错误;而“无特征”的错误,主要包括算法错误和部分算法错误两类。
针对这五种代码错误,我将代码级测试的方法分成了静态方法和动态方法两大类。顾名思义,静态方法不需要执行实际代码,而动态方法需要通过执行具体的代码去发现代码错误。而每一类方法又可以根据执行方式,进一步细分。也因此,每种测试方法,所能覆盖的错误类型也不同,所以进行代码级测试时,你需要综合运用这些方法,并结合所在公司或者项目的编码规范一起使用。
这四类测试方法的特点,以及可以覆盖的错误类型,可以概括如下:
- 人工静态方法,本质上通过开发人员代码走查、结对编程、同行评审来完成的,理论上可以发现所有的代码错误,但也因为其对“测试人员”的过渡依赖,局限性非常大;
- 自动静态方法,主要的手段是代码静态扫描,可以发现语法特征错误、边界行为特征错误和经验特征错误这三类“有特征”的错误;
- 人工动态方法,就是传统意义上的单元测试,是发现算法错误和部分算法错误的最佳方式;
- 自动动态方法,其实就是自动化的边界测试,主要覆盖边界行为特征错误。
## 思考题
你所在的公司,还采用过哪些代码级测试的方法,你们又是如何具体开展的呢?
欢迎你给我留言。

View File

@@ -0,0 +1,186 @@
<audio id="audio" title="26 | 深入浅出之静态测试方法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fb/2a/fb5535e63e6e40d047d271c34e8b622a.mp3"></audio>
你好,我是茹炳晟,今天我和你分享的主题是:深入浅出之静态测试方法。
我在分享[《不破不立:掌握代码级测试的基本理念与方法》](https://time.geekbang.org/column/article/14008)这个主题时,系统地介绍了代码级测试常见的五种错误类型(包括语法特征错误、边界行为特征错误、经验特征错误、算法错误,以及部分算法错误),以及对应的四大类测试方法(包括人工静态方法、自动静态方法、人工动态方法,以及自动动态方法)。
今天,我将和你详细讨论人工静态测试方法和自动静态测试方法,来**帮你理解研发流程上是如何保证代码质量的,以及如何搭建自己的自动静态代码扫描方案,并且应用到项目的日常开发工作中去。**
人工静态方法本质上属于流程上的实践,实际能够发现问题的数量很大程度依赖于个人的能力,所以从技术上来讲这部分内容可以讨论的点并不多。但是,这种方法已经在目前的企业级测试项目中被广泛地应用了,所以我们还是需要理解这其中的流程,才能更好地参与到人工静态测试中。
而自动静态方法可以通过自动化的手段以很低的成本发现并报告各种潜在的代码质量问题目前已经被很多企业和项目广泛采用并且已经集成到CI/CD流水线了。作为测试工程师我们需要完成代码静态扫描环境的搭建。接下来我会重点和你分享这一部分内容。
## 人工静态方法
通过我上一次的分析,我们知道了人工静态方法检查代码错误,主要有代码走查、结对编程,以及同行评审这三种手段。那么我们接下来就看一下这三种方法是如何执行的。
- **代码走查Code Review**,是由开发人员检查自己的代码,尽可能多地发现各类潜在错误。但是,由于个人能力的差异,以及开发人员的“思维惯性”,很多错误并不能在这个阶段被及时发现。
<li>**结对编程Pair Programming**,是一种敏捷软件开发的方法,一般是由两个开发人员结成对子在一台计算机上共同完成开发任务。其中,一个开发人员实现代码,通过被称为“驾驶员”;另一个开发人员审查输入的每一行代码,通常被称为“观察员”。<br />
当“观察员”对代码有任何疑问时,会立即要求“驾驶员”给出解释。解释过程中,“驾驶员”会意识到问题所在,进而修正代码设计和实现。<br />
实际执行过程中,这两个开发人员的角色会定期更换。</li>
<li>**同行评审Peer Review**是指把代码递交到代码仓库或者合并代码分支Branch到主干Master需要和你同技术级别或者更高技术级别的一个或多个同事对你的代码进行评审只有通过所有评审后你的代码才会被真正递交。<br />
如果你所在的项目使用GitHub管理代码并采用GitFlow的分支管理策略那么在递交代码或者分支合并时需要先递交Pull RequestPR只有这个PR经过了所有评审者的审核才能被合并。这也是同行评审的具体实践。目前只要你采用GitFlow的分支管理策略基本都会采用这个方式。</li>
对于以上三种方式,**使用最普遍的是同行评审**。因为同行评审既能较好地保证代码质量,又不需要过多的人工成本投入,而且递交的代码出现问题后责任明确,另外代码的可追溯性也很好。
结对编程的实际效果虽然不错,但是对人员的利用率比较低,通常被用于一些非常关键和底层算法的代码实现。
## 自动静态方法
自动静态方法,主要有以下三个特点:
- 相比于编译器,可以做到对代码更加严格、个性化的检查;
- 不真正检测代码的逻辑功能,只是站在代码本身的视角,基于规则,尽可能多地去发现代码错误;
- 由于静态分析算法并不实际执行代码,完全是基于代码的词法分析、语法分析、控制流分析等技术,由于分析技术的局限性以及代码写法的多样性,所以会存在一定的误报率。
基于这些特点,自动静态方法通常能够以极低的成本发现以下问题:
- 使用未初始化的变量;
- 变量在使用前未定义;
- 变量声明了但未使用;
- 变量类型不匹配;
- 部分的内存泄漏问题;
- 空指针引用;
- 缓冲区溢出;
- 数组越界;
- 不可达的僵尸代码;
- 过高的代码复杂度;
- 死循环;
- 大量的重复代码块;
-
正是由于自动静态方法具有自动化程度高,检查发现问题的成本低以及能够发现的代码问题广等特点,所以该方法被很多企业和项目广泛应用于前期代码质量控制和代码质量度量。
**在实际工程实践中企业往往会结合自己的编码规范定制规程库并与本地IDE开发环境和持续集成的流水线进行高度整合。**
代码本地开发阶段IDE环境就可以自动对代码实现自动静态检查当代码递交到代码仓库后CI/CD流水线也会自动触发代码静态检查如果检测到潜在错误就会自动邮件通知代码递交者。
接下来,我们一起来看两个自动静态方法发现错误的实际案例,希望可以加深你对自动静态方法的认识。
## 自动静态方法的实际例子
**第一个例子,自动静态方法检查语法特征错误。**
如图1左侧所示的C语言代码存在数组越界的问题一种典型的语法特征错误。
图1右侧就是通过C语言的自动静态扫描工具splint发现的这个问题并给出的分析结果。
<img src="https://static001.geekbang.org/resource/image/25/85/250302b2a51793aa80663857d6862885.png" alt="" />
**第二个例子,自动静态方法检查内存空间被释放后继续被赋值的错误。**
如图2左侧所示的C语言代码我们用malloc函数申请了一个内存空间并用指针a指向了这个空间然后新建了一个指针b也指向这个空间也就是指针a和指针b实际上指向了同一个内存空间。之后我们把指针a指向的空间释放掉了意味着指针b指向的空间也被释放了。但是此时代码却试图去对指针b指向的空间赋值显然这会导致不可预料的后果。
幸运的是C语言的自动静态扫描工具splint发现了这个问题并给出了详细解释。
<img src="https://static001.geekbang.org/resource/image/67/19/67979997b21504f5ca9ce99e39bb8d19.png" alt="" />
## 实际案例Sonar实战
现在我们已经了解了自动静态代码扫描的基本概念那怎么把这些知识落地到你的实际项目中呢我们就从目前主流的自动静态工具Sonar的使用开始吧。
考虑到你可能以前并没有接触过Sonar所以我会按照step by step的节奏展开。如果你已经用过Sonar了你可以跳过在Mac 电脑上建立Sonar的步骤从完成你的Maven项目的自动静态分析开始。
通过这个Sonar实例你可以掌握
- 搭建自己的SonarQube服务器
- 扫描Maven项目并将结果报告递交到SonarQube服务器
- 在IntelliJ IDE中集成SonarLint插件在IDE中实现实时的自动静态分析
首先,在[Sonar官网](https://www.sonarqube.org/downloads/)下载LTSLong-term Support版本的SonarQube 6.7.5。这里需要注意的是我不推荐在实际工程项目中使用最新版的SonarQube而是建议使用LTS版本以保证稳定性和兼容性。
解压后运行其中的bin/macosx-universal-64目录下的sonar.sh这里需要注意运行sonar.sh时要带上“console”参数。如果执行完成的界面如下图3所示那么说明你的SonarQube服务已经成功启动。
<img src="https://static001.geekbang.org/resource/image/a4/74/a4b522acf78853ee57aa8e2cbf0c7574.png" alt="" />
此时你可以尝试访问localhost:9000并用默认账号用户名和密码都是“admin”登录。
为了简化建立SonarQube的步骤所有的内容我都使用了默认值。比如我直接使用了SonarQube内建的数据库端口也采用了默认的9000。但是在实际工程项目中为了Sonar数据的长期可维护和升级我们通常会使用自己的数据库需要执行下面这些步骤
<li>
安装SonarQube之前先安装数据库
</li>
<li>
建立一个空数据库并赋予CRUD权限
</li>
<li>
修改SonarQube的conf/sonar.properties中的JDBC配置使其指向我们新建的数据库。我们也可以采用同样的方法来修改默认的端口。
</li>
因为要在Maven项目中执行代码静态扫描为此我们需要先找到$MAVEN_HOME/conf下的settings.xml文件在文件中加入Sonar相关的全局配置具体需要加入的内容如下所示
```
&lt;settings&gt;
&lt;pluginGroups&gt;
&lt;pluginGroup&gt;org.sonarsource.scanner.maven&lt;/pluginGroup&gt;
&lt;/pluginGroups&gt;
&lt;profiles&gt;
&lt;profile&gt;
&lt;id&gt;sonar&lt;/id&gt;
&lt;activation&gt;
&lt;activeByDefault&gt;true&lt;/activeByDefault&gt;
&lt;/activation&gt;
&lt;properties&gt;
&lt;sonar.host.url&gt;
http://myserver:9000
&lt;/sonar.host.url&gt;
&lt;/properties&gt;
&lt;/profile&gt;
&lt;/profiles&gt;
&lt;/settings&gt;
```
最后我们就可以在Maven项目中执行“mvn clean verify sonar:sonar”命令完成静态代码扫描。
如果你是第一次使用这个命令那么mvn会自动下载依赖maven-sonar-plugin完成后发起代码的静态扫描并会自动把扫描结果显示到SonarQube中。
图4所示的结果就是我对[《从0到1你的第一个GUI自动化测试》](https://time.geekbang.org/column/article/11913)一文中的GUI测试项目代码的扫描结果。
<img src="https://static001.geekbang.org/resource/image/67/6f/67e23e2c8a7d44903182d1b1ac38406f.png" alt="" />
扫描结果是Passd但同时也发现了三个Code Smell问题或者说是改进建议如图5所示。
<li>
Class建议放在package中
</li>
<li>
导入了java.io.BufferedInputStream但没有在实际代码中使用建议删除
</li>
<li>
建议变量名字不要包含下划线。
</li>
<img src="https://static001.geekbang.org/resource/image/57/89/57e09bf99b6cd9ef1933b54d2e958689.png" alt="" />
至此你已经使用Sonar完成了一次代码的静态扫描是不是还挺方便的
但是在日常工作中你可能还想要实时看到Sonar分析的结果这样可以大幅提高修改代码的效率。为此我们可以在IDE中引入SonarLint插件。你可以通过IDE的plugin插件管理界面安装SonarLint。
安装完成后重启IDE你就可以在IDE环境中实时看到Sonar的静态分析结果了如图6所示。
<img src="https://static001.geekbang.org/resource/image/f5/88/f50c3d4af11f340793afd1cc52078b88.png" alt="" />
另外在IDE中绑定SonarQube就可以把SonarLint和SonarQube集成在一起了如图7所示。集成完成后IDE本地的代码扫描就能使用SonarQube端的静态代码规则库了在企业级的项目中一般要求所有开发人员都使用统一的静态代码规则库所以一般都会要求本地IDE的SonarLint与SonarQube集成。
<img src="https://static001.geekbang.org/resource/image/70/7f/70d61ce1cc7ec324133d0937309c367f.png" alt="" />
目前自动静态扫描通常都会和持续集成的流水线做绑定最常见的应用场景是当你递交代码后持续集成流水线就会自动触发自动静态扫描这一功能是通过Jenkins以及Jenkins上的SonarQube插件来完成的当你在Jenkins中安装了SonarQube Plugin并且将SonarQube服务器相关的配置信息加入Plugin之后你就可以在Jenkins Job的配置中增加Sonar静态扫描步骤了。
## 总结
人工静态方法,主要有代码走查、结对编程和同行评审三种常用方法。在工程实践中,同行评审因为可以保证代码质量、效率高、责任明确等特点,已经被广泛采用。
自动静态方法,因为自动化程度高、成本低、发现的代码问题广等特点,是常用的代码级测试方法。
在这里测试工程师需要完成代码静态扫描环境的搭建考虑到你以前可能没有接触过Sonar我按照step by step的思路带你一起搭建了一套代码静态扫描环境并分享了一个Maven项目代码静态扫描的实例。
这就是我今天分享的主要内容了,希望可以帮助你解决实际工作中遇到的问题。
## 思考题
除了Sonar你还用过哪些静态代码扫描工具使用过程中遇到过哪些问题
欢迎你给我留言。

View File

@@ -0,0 +1,279 @@
<audio id="audio" title="27 | 深入浅出之动态测试方法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4b/61/4b2d0955681c68d5c01f74afa826ec61.mp3"></audio>
你好,我是茹炳晟,今天我和你分享的主题是:深入浅出之动态测试方法。
相较于,静态测试方法是不需要实际执行代码去发现潜在代码错误的方法,我今天要和你讨论的动态测试方法,则是要通过实际执行代码去发现潜在代码错误的测试方法。
正如我在分享[《不破不立:掌握代码级测试的基本理念与方法》](https://time.geekbang.org/column/article/14008)这个主题时,将动态测试方法进一步划分为人工动态方法和自动动态方法,今天这次关于动态测试方法的分享,我也会从这两个方面展开。
由于自动动态方法并不能理解代码逻辑,所以仅仅被用于发现异常、崩溃和超时这类“有特征”的错误,而对于代码逻辑功能的测试,主要还是要依靠人工动态方法。
## 人工动态方法
人工动态方法,可以真正检测代码的业务逻辑功能,其关注点是“什么样的输入,执行了什么代码,产生了什么样的输出”,主要用于发现算法错误和部分算法错误,是最主要的代码级测试手段。
从人工动态方法的定义中,你可以很清楚地看出:代码级测试的人工动态测试方法,其实就是单元测试所采用的方法。所以,下面的分享,我会从单元测试方法的角度展开。
如果有一些代码基础,那么你在学习单元测试框架或者工具时,会感觉单元测试很简单啊,一点都不难:**无非就是用驱动代码去调用被测函数,并根据代码的功能逻辑选择必要的输入数据的组合,然后验证执行被测函数后得到的结果是否符合预期。** 但是,一旦要在实际项目中开展单元测试时,你会发现有很多实际的问题需要解决。
我在专栏第4篇文章[《什么是单元测试?如何做好单元测试?》](https://time.geekbang.org/column/article/10275)中,已经分享过单元测试中的主要概念了,所以今天的分享我不会重复前面的内容,只和你分享前面没有涉及到的部分。如果你有哪些概念已经记不太清楚了,建议你先回顾一下那篇文章的内容。
接下来,我将和你分享单元测试中三个最主要的难点:
<li>
单元测试用例“输入参数”的复杂性;
</li>
<li>
单元测试用例“预期输出”的复杂性;
</li>
<li>
关联依赖的代码不可用。
</li>
## 单元测试用例“输入参数”的复杂性
提到“输入参数”的复杂性,你应该已经记起了,我在前面的分享中提到过:如果你认为单元测试的输入参数只有被测函数的输入参数的话,那你就把事情想得过于简单了。
其实,这也是源于我们在学习单元测试框架时,单元测试用例的输入数据一般都是被测函数的输入参数,所以我们的第一印象会觉得单元测试其实很简单。
但是到了实际项目时,你会发现单元测试太复杂了,因为测试用例设计时需要考虑的“输入参数”已经完全超乎想象了。
我在[《什么是单元测试?如何做好单元测试?》](https://time.geekbang.org/column/article/10275)一文中已经总结了多种常见的单元测试输入数据,但是并没有详细解释每种输入数据的具体含义,你可能也对此感到困惑,那么今天我就结合一些代码示例和你详细聊聊这些输入参数吧。
**第一,被测试函数的输入参数**
这是最典型也是最好理解的单元测试输入数据类型。假如你的被测函数是下面这段代码中的形式那么函数输入参数a和b的不同取值以及取值的组合就构成了单元测试的输入数据。
```
int someFunc(int a, int b)
{
}
```
**第二,被测试函数内部需要读取的全局静态变量**
如果被测函数内部使用了该函数作用域以外的变量,那么这个变量也是被测函数的输入参数。
下面这段代码中被测函数Func_SUT的内部实现中使用了全局变量someGlobalVariable并且会根据someGlobalVariable的取值去执行FuncA()和FuncB()这不同的代码分支。
在做单元测试时为了能够覆盖这两个分支你就必须构造someGlobalVariable的不同取值那么自然而然这个someGlobalVariable就成为了被测函数的输入参数。
所以在这段代码中单元测试的输入参数不仅包括Func_SUT函数的输入参数a还包括全局变量someGlobalVariable。
```
bool someGlobalVariable = true
void Func_SUT(int a)
{
...
if(someGlobalVariable == true)
{
FuncA();
}
else
{
FuncB();
}
...
}
```
**第三,被测试函数内部需要读取的类成员变量**
如果你能理解“被测函数内部需要读取的全局静态变量”是单元测试的输入参数,那么“被测试函数内部需要读取的类成员变量”也是单元测试的输入参数就不难理解了。因为,类成员变量对被测试函数来讲,也可以看做是全局变量。
我们一起看一段代码。这段代码中变量someClassVariable是类someClass的成员变量类的成员函数Func_SUT是被测函数。Func_SUT函数根据someClassVariable的取值不同会执行两个不同的代码分支。
同样地单元测试想要覆盖这两个分支就必须提供someClassVariable的不同取值所以someClassVariable对于被测函数Func_SUT来说也是输入参数。
```
class someClass{
...
bool someClassVariable = true
...
void Func_SUT(int a)
{
...
if(someClassVariable == true)
{
FuncA();
}
else
{
FuncB();
}
...
}
...
}
```
**第四,函数内部调用子函数获得的数据**
“函数内部调用子函数获得的数据”也是单元测试的输入数据,从字面上可能不太好理解,那我就通过一段代码,和你详细说说这是怎么回事吧。
```
void Func_SUT(int a)
{
bool toggle = FuncX(a);
if(toggle == true)
{
FuncA();
}
else
{
FuncB();
}
}
```
函数Func_SUT是被测函数它的内部调用了函数FuncX函数FuncX的返回值是bool类型并且赋值给了内部变量toggle之后的代码会根据变量toggle的取值来决定执行哪个代码分支。
那么从输入数据的角度来看函数FuncX的调用为被测函数Func_SUT提供了数据也就是这里的变量toggle后续代码逻辑会根据变量toggle的取值执行不同的分支。所以从这个角度来看被测函数内部调用子函数获得的数据也是单元测试的输入参数。
这里还有一个小细节被测函数Func_SUT的输入参数a在内部实现上只是传递给了内部调用的函数FuncX而并没有在其他地方被使用我们把这类用于传递给子函数的输入参数称为“间接输入参数”。
这里需要注意的是,**有些情况下“间接输入参数”反而不是输入参数。**
就以这段代码为例如果我们发现通过变量a的取值很难控制FuncX的返回值也就是说当通过间接输入参数的取值去控制内部调用函数的取值以达到控制代码内部执行路径比较困难我们会直接对FuncX(a)打桩用桩代码来控制函数FuncX返回的是true还是false。
这样一来原本的变量a其实就没有任何作用了。那么此时变量a虽然是被测函数的输入参数但却并不是单元测试的输入参数。
**第五,函数内部调用子函数改写的数据**
理解了前面几种单元测试的输入参数类型后,“函数内部调用子函数改写的数据”也是单元测试中被测函数的输入参数就好解释了。
比如,当被测函数内部调用的子函数改写了全局变量或者类的成员变量,而这个被改写的全局变量或者类的成员变量又会在被测函数内部被使用,那么“函数内部调用子函数改写的数据”也就成为了被测函数的输入参数了。
**第六,嵌入式系统中,在中断调用中改写的数据**
嵌入式系统中,在中断调用中改写的数据有时候也会成为被测函数的输入参数,这和“函数内部调用子函数改写的数据也是单元测试中的输入参数”类似,在某些中断事件发生并执行中断函数时,中断函数很可能会改写某个寄存器的值,但是被测函数的后续代码还要基于这个寄存器的值进行分支判断,那么这个被中断调用改写的数据也就成了被测函数的输入参数。
其实在实际工程项目中,除了这六种输入参数,还有很多输入参数。在这里,我详细分析这六种输入参数的目的,一来是帮你理解到底什么样的数据是单元测试的输入数据,二来也是希望你可以从本质上认识单元测试的输入参数,那么在以后遇到相关问题时,你也可以做到触类旁通,不会再踌躇无措。
理解了“输入参数”的复杂性,接下来我们再一起看看“预期输出”的复杂性表现在哪些方面。
## 单元测试用例“预期输出”的复杂性
同样地,单元测试用例的“预期输出”,也绝对不仅仅是函数返回值这么简单。通常来讲,“预期输出”应该包括被测函数执行完成后所改写的所有数据,主要包括:被测函数的返回值,被测函数的输出参数,被测函数所改写的成员变量和全局变量,被测函数中进行的文件更新、数据库更新、消息队列更新等。
**第一,被测函数的返回值**
这是最直观的预期输出。比如加法函数int add(int a, int a)的返回值就是预期输出。
**第二,被测函数的输出参数**
要理解“被测函数的输出参数”是预期输出最关键的是要理解什么是函数的输出参数。如果你有C语言背景那么你很容易就可以理解这个概念了。
我们一起来看一段代码。被测函数add包含三个参数其中a和b是输入参数而sum是个指针指向了一个地址空间。
如果被测函数的代码对sum指向的空间进行了赋值操作那么在被测函数外你可以通过访问sum指向的空间来获得被测函数内所赋的值相当于你把函数内部的值输出到了函数外所以sum对于函数add来讲其实是用于输出加法结果的那么显然这个sum就是我们的“预期输出”。
如果你还没有理解的话可以在百度上搜索一下“C语言的参数传递机制”。
```
void add(int a, int bint *sum)
{
*sum = a + b;
}
void main()
{
int a, bsum;
a = 10;
b = 8;
add(a, b, &amp;sum);
printf(&quot;sum = %d \n&quot;, sum);
}
```
**第三,被测函数所改写的成员变量和全局变量**
理解了单元测试用例“输入参数”的复杂性“被测函数所改写的成员变量和全局变量”也是被测函数的“预期输出”就很好理解了此时如果你的单元测试用例需要写断言来验证结果那么这些被改写的成员变量和全局变量就是assert的对象。
**第四,被测函数中进行的文件更新、数据库更新、消息队列更新等**
这应该不难理解。
但在实际的单元测试实践中因为测试解耦的需要所以一般不会真正去做这些操作而是借助对Mock对象的断言来验证是否发起了相关的操作。
## 关联依赖的代码不可用
什么是关联依赖的代码呢?
**假设被测函数中调用了其他的函数,那么这些被调用的其他函数就是被测函数的关联依赖代码。**
大型的软件项目通常是并行开发的,所以经常会出现被测函数关联依赖的代码未完成或者未测试的情况,也就是出现关联依赖的代码不可用的情况。那么,为了不影响被测函数的测试,我们往往会采用桩代码来模拟不可用的代码,并通过打桩补齐未定义部分。
具体来讲假定函数A调用了函数B而函数B由其他开发团队编写且未实现那么我们就可以用桩函数来代替函数B使函数A能够编译链接并运行测试。
桩函数要具有与原函数完全相同的原形,仅仅是内部实现不同,这样测试代码才能正确链接到桩函数。**一般来讲桩函数主要有两个作用,一个是隔离和补齐,另一个是实现被测函数的逻辑控制。**
用于实现隔离和补齐的桩函数实现比较简单,只需拷贝原函数的声明,加一个空的实现,可以通过编译链接就可以了。
用于实现控制功能的桩函数是最常用的,实现起来也比较复杂,需要根据测试用例的需要,输出合适的数据作为被测函数的内部输入。
## 自动动态方法
我们先来回顾一下,什么是自动动态方法。自动动态方法是,基于代码自动生成边界测试用例并执行来捕捉潜在的异常、崩溃和超时的测试方法。
自动动态方法的重点是:如何实现边界测试用例的自动生成。
**解决这个问题最简单直接的方法是,根据被测函数的输入参数生成可能的边界值。**
具体来讲,任何数据类型都有自己的典型值和边界值,我们可以预先为它们设定好典型值和边界值,然后组合就可以生成了。
比如函数int func(int a, char *s),就可以按下面的三步来生成测试用例集。
<li>
**定义各种数据类型的典型值和边界值。** 比如int类型可以定义一些值如int的最小值、int的最大值、0、1、-1等char*类型也可以定义一些值比如“”、“abcde”、“非英文字符串”等。
</li>
<li>
**根据被测函数的原形,生成测试用例代码模板**,比如下面这段伪代码:
</li>
```
try{
int a= @a@;
char *s = @s@;
int ret = func(a, s);
}
catch{
throw exception();
}
```
1. **将参数@a@和@s@的各种取值循环组合,分别替换模板中的相应内容,即可生成用例集。**
由于该方法不可能自动了解代码所要实现的功能逻辑所以不会验证“预期输出”而是通过try…catch来观察是否会引发代码的异常、崩溃和超时等具有边界特征的错误。
## 总结
代码级测试的动态测试方法,可以分为人工动态测试方法和自动动态测试方法。其中人工动态测试方式,是最常用的代码级测试方法,也是我们在进行单元测试时采用的方法。
人工动态方法,也就是单元测试方法,通常看似简单,但在实际的工程实践中会遇到很多困难,总结来看这些困难可以概括为三大方面:
<li>
单元测试用例“输入参数”的复杂性,表现在“输入参数”不是简单的函数输入参数。本质上讲,任何能够影响代码执行路径的参数,都是被测函数的输入参数。
</li>
<li>
单元测试用例“预期输出”的复杂性,主要表现在“预期输出”应该包括被测函数执行完成后所改写的所有数据。
</li>
<li>
关联依赖的代码不可用,需要我们采用桩代码来模拟不可用的代码,并通过打桩补齐未定义部分。
</li>
而自动动态方法,需要重点讨论的是:如何实现边界测试用例的自动生成。解决这个问题最简单直接的方法是,根据被测函数的输入参数生成可能的边界值。
## 思考题
除了我们一起讨论的这些单元测试的难点,还有复杂数据初始化、函数内部不可控子函数的调用、间接输入参数的估算等难点。你在单元测试中是否遇到过这些问题呢,又是如何解决的?
感谢你的收听,欢迎给我留言一起讨论。