本篇文章为极客时间茹炳晟老师“《软件测试52讲》专栏课程03 | 什么是单元测试?如何做好单元测试?”的学习笔记与操作实践的相关内容。原文课程链接:https://time.geekbang.org/column/article/10275
单元测试在软件开发中扮演着至关重要的角色,类似于工厂对每个电子元器件进行测试以确保最终产品质量。它是对最小可测试单元进行隔离验证的工作,能以最小成本保证代码质量,并以自动化方式执行,适用于大量回归测试的场景。
文章深入探讨了如何做好单元测试,包括对代码基本特征与产生错误的原因的理解、单元测试用例的设计、以及驱动代码、桩代码和mock代码的应用。
作者详细解释了单元测试用例的“输入数据”和“预计输出”的复杂性,以及驱动代码、桩代码和Mock代码的逻辑关系。此外,还强调了编写桩代码的原则和Mock代码与桩代码的本质区别。
一、什么是单元测试?1.怎样理解单元测试?单元测试是指,对软件中的最小可测试单元在与程序其他部分相隔离的情况下进行检查和验证的工作,这里的最小可测试单元通常是指函数或者类。
如果把电视机的生产、测试和软件的开发、测试进行类比,会发现:
- 电子元器件就像是软件中的单元,通常是函数或者类,对单个元器件的测试就像是软件测试中的单元测试;
- 组装完成的功能电路板就像是软件中的模块,对电路板的测试就像是软件中的集成测试;
- 电视机全部组装完成就像是软件完成了预发布版本,电视机全部组装完成后的开机测试就像是软件中的系统测试。
单元测试通常由开发工程师完成,一般会伴随开发代码一起递交至代码库。单元测试属于最严格的软件测试手段,是最接近代码底层实现的验证手段,可以在软件开发的早期以最小的成本保证局部代码的质量。
3.单元测试执行方式是什么?单元测试都是以自动化的方式执行,所以在大量回归测试的场景下更能带来高收益。
单元测试的实施过程还可以帮助开发工程师改善代码的设计与实现,并能在单元测试代码里提供函数的使用示例,因为单元测试的具体表现形式就是对函数以各种不同输入参数组合进行调用,这些调用方法构成了函数的使用说明。
二、如何做好单元测试要做好单元测试,你首先必须弄清楚单元测试的对象是代码,以及代码的基本特征和产生错误的原因,然后你必须掌握单元测试的基本方法和主要技术手段,比如什么是驱动代码、桩代码和 Mock 代码等。
1.代码的基本特征与产生错误的原因开发语言多种多样,程序实现的功能更是千变万化,但无论是开发语言还是脚本语言,都会有条件分支、循环处理和函数调用等最基本的逻辑控制。
如果抛开代码需要实现的具体业务逻辑,仅看代码结构的话,你会发现所有的代码都是在对数据进行分类处理,每一次条件判定都是一次分类处理,嵌套的条件判定或者循环执行,也是在做分类处理。
如果有任何一个分类遗漏,都会产生缺陷;如果有任何一个分类错误,也会产生缺陷;如果分类正确也没有遗漏,但是分类时的处理逻辑错误,也同样会产生缺陷。
所以,要做到代码功能逻辑正确,必须做到分类正确并且完备无遗漏,同时每个分类的处理逻辑必须正确。
在具体的工程实践中,开发工程师为了设计并实现逻辑功能正确的代码,通常会有如下的考虑过程:
- 如果要实现正确的功能逻辑,会有哪几种正常的输入(等价类划分);
- 是否有需要特殊处理的多种边界输入(边界值分析);
- 各种潜在非法输入的可能性以及如何处理(错误推测)。
通常来讲,单元测试的用例是一个“输入数据”和“预计输出”的集合。 你需要针对确定的输入,根据逻辑功能推算出预期正确的输出,并且以执行被测试代码的方式进行验证,用一句话概括就是“在明确了代码需要实现的逻辑功能的基础上,什么输入,应该产生什么输出”。
1)单元测试用例“输入数据”有哪些种类如果你想当然的认为只有被测试函数的输入参数是“输入数据”的话,那就大错特错了,完整的单元测试“输入数据”:
- 被测试函数的输入参数;
- 被测试函数内部需要读取的全局静态变量;
- 被测试函数内部需要读取的成员变量;
- 函数内部调用子函数获得的数据;
- 函数内部调用子函数改写的数据;
- 嵌入式系统中,在中断调用时改写的数据;
- …
如果没有明确的预计输出,那么测试本身就失去了意义。同样地,“预计输出” 绝对不是只有函数返回值这么简单,还应该包括函数执行完成后所改写的所有数据。 具体来看有以下几大类:
- 被测试函数的返回值;
- 被测试函数的输出参数;
- 被测试函数所改写的成员变量;
- 被测试函数所改写的全局变量;
- 被测试函数中进行的文件更新;
- 被测试函数中进行的数据库更新;
- 被测试函数中进行的消息队列更新;
- …
对于预计输出值,你必须严格根据代码的功能逻辑来设定,而不能通过阅读代码来推算预期输出。
如果某些等价类或者边界值,开发工程师在开发的时候都没有考虑到,测试的时候就更不会去设计对应的测试用例了,这样也就会造成测试盲区。
3.驱动代码、桩代码和 Mock 代码驱动代码,桩代码和 Mock 代码,是单元测试中最常出现的三个名词。驱动代码是用来调用被测函数的,而桩代码和 Mock 代码是用来代替被测函数调用的真实代码的。
驱动代码(Driver)指调用被测函数的代码,在单元测试过程中,驱动模块通常包括调用被测函数前的数据准备、调用被测函数以及验证相关结果三个步骤。驱动代码的结构,通常由单元测试的框架决定。
2)桩代码桩代码(Stub)是用来代替真实代码的临时代码。 比如,某个函数 A 的内部实现中调用了一个尚未实现的函数 B,为了对函数 A 的逻辑进行测试,那么就需要模拟一个函数 B,这个模拟的函数 B 的实现就是所谓的桩代码。桩代码案例,被测函数 A 内部调用了函数 B:
在单元测试阶段,由于函数 B 尚未实现,但是为了不影响对函数 A 自身实现逻辑的测试,你可以用一个假的函数 B 来代替真实的函数 B,那么这个假的函数 B 就是桩函数。
为了实现函数 A 的全路径覆盖,你需要控制不同的测试用例中函数 B 的返回值,那么桩函数 B 的伪代码就应该是这个样子的:
当执行第一个测试用例的时候,桩函数 B 应该返回 true,而当执行第二个测试用例的时候,桩函数 B 应该返回 false。
这样就覆盖了被测试函数 A 的 if-else 的两个分支。
桩代码的应用首先起到了隔离和补齐的作用,使被测代码能够独立编译、链接,并独立运行。同时,桩代码还具有控制被测函数执行路径的作用。
编写桩代码通常需要遵守以下三个原则:
- 桩函数要具有与原函数完全相同的原形,仅仅是内部实现不同,这样测试代码才能正确链接到桩函数;
- 用于实现隔离和补齐的桩函数比较简单,只需保持原函数的声明,加一个空的实现,目的是通过编译链接;
- 实现控制功能的桩函数是应用最广泛的,要根据测试用例的需要,输出合适的数据作为被测函数的内部输入。
Mock 代码和桩代码的本质区别是:测试期待结果的验证(Assert and Expectiation)
- 对于 Mock 代码来说,我们的关注点是 Mock 方法有没有被调用,以什么样的参数被调用,被调用的次数,以及多个 Mock 函数的先后调用顺序。所以,在使用 Mock 代码的测试中,对于结果的验证(也就是 assert),通常出现在 Mock 函数中。
- 对于桩代码来说,我们的关注点是利用 Stub 来控制被测函数的执行路径,不会去关注 Stub 是否被调用以及怎么样被调用。所以,你在使用 Stub 的测试中,对于结果的验证(也就是 assert),通常出现在驱动代码中。
- 并不是所有的代码都要进行单元测试,通常只有底层模块或者核心模块的测试中才会采用单元测试。
- 你需要确定单元测试框架的选型,这和开发语言直接相关。比如,Java 最常用的单元测试框架是 Junit 和 TestNG;C/C 最常用的单元测试框架是 CppTest 和 Parasoft C/C test;框架选型完成后,你还需要对桩代码框架和 Mock 代码框架选型,选型的主要依据是开发所采用的具体技术栈。
通常,单元测试框架、桩代码 /Mock 代码的选型工作由开发架构师和测试架构师共同决定。 - 为了能够衡量单元测试的代码覆盖率,通常你还需要引入计算代码覆盖率的工具。不同的语言会有不同的代码覆盖率统计工具,比如 Java 的 JaCoCo,JavaScript 的 Istanbul。
- 最后你需要把单元测试执行、代码覆盖率统计和持续集成流水线做集成,以确保每次代码递交,都会自动触发单元测试,并在单元测试执行过程中自动统计代码覆盖率,最后以“单元测试通过率”和“代码覆盖率”为标准来决定本次代码递交是否能够被接受。
真正在项目中全面推行单元测试时,你会发现还有一些困难需要克服:
- 紧密耦合的代码难以隔离;
- 隔离后编译链接运行困难;
- 代码本身的可测试性较差,通常代码的可测试性和代码规模成正比;
- 无法通过桩代码直接模拟系统底层函数的调用;
- 代码覆盖率越往后越难提高。
- 代码要做到功能逻辑正确,必须做到分类正确并且完备无遗漏,同时每个分类的处理逻辑必须正确;
- 单元测试是对软件中的最小可测试单元在与软件其他部分相隔离的情况下进行的代码级测试;
- 桩代码起到了隔离和补齐的作用,使被测代码能够独立编译、链接,并运行。
八股文要记住,说不定有一天就用上了。
单元测试的最佳时机是在项目初期,结合TDD模式。但现实往往事与愿违,基本都是先实现再补上单元测试。我们有个项目就是,业务代码都写完了,最后为了应付客户要求,不得不补上单元测试。
什么是TDD(测试驱动开发)?不是测试人员驱动开发人员,而是测试代码驱动开发代码。简而言之就是先编写测试代码,再编写功能代码并利用测试代码进行验证。感兴趣的可以阅读这篇博文:https://blog.51cto.com/u_2681882/2120480
看了评论区,大多数公司和测试人员,都没有开展单元测试。究其原因,我觉得有以下诸多方面:
- 环境影响:国内的互联网环境竞争如此激烈,业务的快速发展导致需求的快速上线是一个常态,也就导致了业务为王,质量其次;
- 公司规模:通常大公司可能会重视一些单元测试,小公司基本不会开展;
- 项目类型:如果是一些快速交付的项目,或是公司内部使用的管理系统,对质量要求不是那么高,通常很少做单元测试;
- 成本考量:在项目初期阶段,单元测试需要投入很大的时间成本,时间就是金钱,尤其是很多时间紧、任务重的项目,就更没有时间搞这个了;
- 质量文化:做不做单元测试、做到什么程度,和这个公司以及带头人对质量的重视程度也有很大的关联;
- 人员能力:人都是比较懒得,很多时候开发即使做了也是为了应付KPI,如果团队内有测试人员有能力写单元测试代码,那么开展单元测试的概率就会大一些,测试的程度也会细致一些。
- 大众对互联网的容忍度:小问题大多数网民都不会在意,或者说在意了也没什么用,中大型问题紧急发布就够了,你只听说过大众、丰田这类的车召回,什么时候听说过国产车召回过?
来源:https://www.testwo.com/blog/8820
- 尽可能简短
- 切忌自我重复
- 选择组合而非继承
- 使其速度更快
- 使其具有确定性
- 不要为测试标注“可忽略”
- 测试你的测试
- 合理命名测试
- 每个测试仅包含一个逻辑断言
- 设计你的测试
类型 | 单元测试 | 白盒测试 |
测试对象的范围 | 主要关注软件中的最小可测试单元,通常是一个函数、一个方法或者一个类。 | 测试对象可以是一个模块、一个子系统甚至是整个系统。不仅关注单独的功能单元,还包括单元之间的接口、控制流和数据流等。 |
测试依据 | 依据详细设计说明书和代码本身。开发人员根据代码的功能实现细节,编写测试用例来验证代码是否符合预期的功能要求。 | 主要依据程序的内部逻辑结构,包括程序流程图、控制流图等。通过分析程序的逻辑路径,确定测试用例来覆盖各种可能的执行路径。 |
测试目的 | 侧重于验证单个单元的功能正确性。其目的是隔离每个单元进行测试,以发现单元内部的逻辑错误、语法错误等,确保每个单元能够独立地正确运行。 | 主要目的是检查程序的内部逻辑结构是否正确,包括检查是否有不可达的代码、是否存在逻辑错误导致程序执行异常等。 |
测试方法 | 通常采用黑盒测试方法和部分简单的白盒测试方法。 | 主要运用白盒测试技术,如语句覆盖、分支覆盖、条件覆盖、路径覆盖等。 |
测试人员 | 通常由开发人员自己完成。因为开发人员最了解代码的实现细节,能够方便地针对自己编写的代码进行单元测试。 | 可以由开发人员进行,也可以由专门的测试人员进行。 |
总结:
- 单元测试可以作为白盒测试的基础。在进行白盒测试之前,通过单元测试确保每个单元的功能正确,可以为白盒测试提供更可靠的组件。
- 白盒测试可以发现单元测试可能遗漏的问题。单元测试主要关注单元的功能,而白盒测试关注程序的内部逻辑结构,白盒测试能够发现一些由于单元之间的交互或者程序内部复杂逻辑导致的问题,这些问题可能在单元测试阶段没有被发现。
- 单元测试和白盒测试的最终目标都是为了提高软件质量。它们都是软件测试过程中的重要环节,通过不同的角度和方法来发现软件中的缺陷,从而保证软件的可靠性、稳定性和正确性。无论是确保单个单元的功能正确(单元测试),还是检查程序的内部逻辑结构(白盒测试),都是为了让软件产品能够更好地满足用户的需求。