自动化测试是编写可靠软件的核心部分;您可以手动测试的数量有限,而且您无法像机器那样彻底或认真地测试东西。作为一个花费大量时间在自动化测试系统上工作的人,无论是工作项目还是开源项目,这篇文章涵盖了我对它们的看法。哪些区别是有意义的,哪些是没有意义的,哪些实践有意义,哪些没有意义,这就形成了一套连贯的原则,说明如何在任何软件项目中思考自动化测试的世界。
关于作者:Haoyi是一名软件工程师,也是许多开源Scala工具(如Ammonite REPL和Mill build tool)的作者。如果你喜欢这个博客上的内容,你可能也会喜欢作者的“Scala编程实践”一书。
我可能比大多数软件工程师更关心自动化测试。在之前的工作中,我鼓动并推出了Selenium集成测试,将其作为整个工程开发过程的一部分,开发了静态分析测试以阻止愚蠢的错误和代码质量问题,并领导项目与脆弱的测试祸害作斗争,帮助CI再次构建绿色。在我的开源工作中,例如在像Ammonite或FastParse这样的项目中,我的测试代码与应用程序代码的比率通常大约是1:1。
关于自动化测试的实践已经写了很多:关于单元测试、基于属性的测试、集成测试和其他主题。不足为奇的是,你可以在互联网上找到的许多信息都是不完整的,彼此之间存在分歧,或者只适用于某些类型的项目或场景。
这篇文章不是讨论个别的工具或技术,而是试图定义一种自动化测试的思维方式,无论您从事的是什么软件项目,这种方式都应该广泛适用。希望这将形成一个基础,当您最终不得不将目光从日常的软件开发工作中移开,开始思考项目或组织中更广泛的自动化测试策略时,该基础将会派上用场。
自动化测试的目的是尝试并验证您的软件是否做了您期望它做的事情,无论是现在还是将来。
这是一个非常宽泛的定义,反映了有很多不同的方法来尝试和验证您的软件是否符合您的期望:
设置临时网站并检查网页及其背后的所有系统是否可以正确执行简单操作
用大量随机输入模糊您的系统,看看它是否崩溃。
将程序的行为与已知良好的参考实现进行比较,并确保其行为相同。
请注意,声明的目标对单元测试或集成测试只字不提。这是因为这些几乎从来都不是最终目标:您需要通过任何必要的手段自动检查您的软件是否做了您想做的事情的测试。单元测试或集成测试只是实现自动化测试的许多不同方法中的一个区别,我们将在本文中介绍其中几种方法。
既然我们已经定义了高层次的目标,这篇文章的其余部分将更详细地讨论我们可以尝试实现它的不同方式所固有的错综复杂和权衡取舍。
有无数种区分单元测试和集成测试的真正方法,它们都是不同的。规则如下:
单元测试不允许执行多个文件中的代码:必须模拟所有导入。
不过,我觉得这类讨论往往缺乏远见。实际上,您绘制直线的确切位置是任意的。每段代码或系统都是一个集成较小单元的单元:
每段代码或系统都可以被认为是一个待测试的单元,而每段代码或系统都可以被认为是其他较小单元的集成。基本上,所有编写过的软件都是以这种方式分层分解的。
_|计算机||_|/\_/\_|进程||进程||_||_|//\/_/\_.|。|模块||模块||_||_|_|/\\_/\_\_|函数||_||_|。_|/\/.。
在前面,我们定义了自动化测试的目的是尝试并验证您的软件是否执行了您期望的操作,并且在任何软件中,您都将在此层次结构的每个级别上拥有代码。所有这些代码都由您负责测试和验证。
为了与现有术语保持一致,我将对层次结构中较低的代码(例如集成原语的函数)进行测试,并对层次结构中较高的代码(例如集成虚拟机的集群)进行单元测试和集成测试。但这些标签只是光谱上的方向,你不能在适用于每个项目的单元标签和集成标签之间划出一条亮线。
大多数测试位于单元<;-->;集成|功能模块进程计算机群集之间。
真正重要的是,您意识到您的软件分层分解的方式,并且自动化测试可以存在于代码层次结构中的任何级别和单元集成频谱中的任何点。
同属一类并不意味着单元测试和集成测试之间的区别毫无意义。虽然这两个极端之间没有亮线,但朝向光谱两端的测试确实有不同的属性:
单元测试往往更快,因为它们执行的代码更少,需要运行的代码更少。
单元测试往往更可靠,因为它们使用的代码较少,可能会出现不确定的故障
单元测试往往以相对特定的方式失败(";函数返回1,返回2";),几乎没有可能的原因,而集成测试往往失败,出现广泛的、无意义的错误(";无法访问网站";),有许多不同的可能原因。
算法库对单元测试和集成测试的定义可能与网站不同,而网站对单元和集成测试的定义可能与群集部署系统不同。
算法库可以将单元测试定义为对微小输入(例如,对零、一、二或三个数字的列表进行排序)运行一个函数的测试,而集成测试使用多个函数来构造通用算法。
网站可以将单元测试定义为任何不涉及HTTP API的测试,将集成测试定义为任何涉及HTTP API的测试。
或者,它可以将网站定义为不启动浏览器(包括API交互)的任何东西,并将集成测试为使用Selenium通过UI/Javascript与服务器进行交互的那些浏览器的集成测试(";unit&34;unit;test";unit&34;unit;test";unit&34;test";unit;test";unit&34;test)测试为不启动浏览器(包括API交互)和集成测试。
群集部署系统可以将单元测试定义为不以物理方式创建虚拟机的任何测试,包括使用HTTP API或数据库访问的测试,而集成测试是在临时环境中启动真实群集的测试。
虽然存在差异(例如,算法库的集成测试可能比群集部署系统的单元测试运行得更快),但最终所有这些系统的测试范围从更多的单元到更多的集成。
因此,这取决于项目所有者在它们之间划清界限,然后围绕这条界限建立实践。上面的项目符号应该会让您对在各种项目中可以在哪里划定界限有所了解,围绕这条界限的实践可能是这样的:
单元测试必须在提交之前运行,而在夜间构建期间每天只运行一次集成测试。
由于设置要求不同,集成测试与单元测试在不同的CI计算机/群集上运行。
将测试范围划分为更细粒度的分区可能是有价值的。同样,也是由项目所有者决定绘制多少条线,在哪里绘制它们,每组测试称为什么(例如,";单元";,";集成";,";端到端";,";功能性";?)。以及它们在您的项目中是如何对待的。
对于人们从事的大量软件项目而言,对单元测试和集成测试没有统一的分类,但这并不意味着区别就没有意义。它简单地说,这取决于每个单独的项目以一种有意义和有用的方式划清界限。
每个软件都是分层编写的,作为集成较小单元的单元。而且在每个级别上,程序员都有可能犯错误:理想情况下,这是我们的自动化测试可以捕捉到的错误。
因此,像只写单元测试而不写集成测试或只写集成测试而不写单元测试这样的规则限制太多了。
您不能只编写单元测试。如果每个单独的函数都经过了严格的测试,但是您的模块以错误的方式组合了您的函数,或者如果每个单独的模块都经过了严格的测试,但是应用程序进程没有正确地使用这些模块,这都无关紧要。虽然拥有一个运行速度非常快的测试套件是很棒的,但是如果它不能捕捉到程序层次结构上层引入的错误,那么它就毫无用处了。
您不应该只编写集成测试。从理论上讲,它是有效的,层次结构中上层的代码在其下面的层执行代码,但是您需要大量的集成测试才能充分执行低级代码中的各种情况。例如,如果您想用10组不同的原语参数检查单个函数的行为,使用集成测试来测试它可能意味着您最终需要设置和关闭应用程序进程10次:缓慢且浪费计算资源的使用。
相反,测试的结构应该大致反映软件的结构。您需要所有级别的测试,与该级别的代码量以及出错的可能性/严重性成比例。这可以防止在软件层次结构中的任何级别引入错误的可能性。
自动化测试有两个主要目的:确保您的代码没有损坏(也许在某种程度上,通过手动测试很难捕捉到这一点),以及确保工作代码不会在将来的某个时候损坏(回归)。前者可能是由不完整的实现造成的,而后者可能是由于代码库随着时间的推移而发生的错误造成的。
因此,对不太可能被破坏的代码、其破坏不重要的代码或在有人导致其崩溃之前可能完全消失的代码进行自动化测试是没有意义的。
决定一个系统或一段代码需要多少测试与其说是一门科学不如说是一门艺术,但是一些指导原则可能是:
重要的事情需要更多的测试!您的密码/身份验证系统绝对应该经过严格的测试,以确保错误的密码无论如何都不会让人登录,这比应用程序中的其他随机逻辑更重要。
不太重要的事情,可能需要更少的测试,或者根本不需要测试。也许在下一次部署之前,您的网络追加销售模式的东西在几天内不会出现并不重要,也许正确测试它的唯一方法是通过昂贵/缓慢/不稳定的Selenium集成测试。如果是这样的话,测试的成本可能会决定您根本不应该对其进行自动化测试。
活动开发中的代码需要更多的测试,而非开发中的代码需要更少的测试。如果一段可怕的代码已经多年没有被碰过,如果它以前没有被破坏,那么它就不太可能被破坏。现在,您可能想要测试以确保它没有损坏,但是您不需要测试来防止倒退和新的损坏。
不会消失的API应该比可能消失的API进行更多的测试。您应该将更多的精力放在测试应用程序中的稳定接口上,而不是测试可能在一周内完全消失的不稳定代码。结合上述准则,最值得测试的代码拥有稳定的API,但内部正在进行大量开发。
如果代码中的复杂性处于尴尬的位置(进程间、浏览器-服务器、带有数据库互操作的……)。你应该确保你测试了这个逻辑,不管它有多尴尬。不要只测试简单的东西:如果将单个函数捆绑在一起的粗糙/脆弱的代码最终崩溃,那么测试它们的效果如何并不重要。
这些要点中有许多是主观的,不能完全从代码本身来确定。尽管如此,这些都是您在为代码库编写自动化测试的工作集中精力时必须做出的判断。
测试是与其他代码一样的代码:您的测试套件是一款软件,用于检查您的软件是否以特定方式运行。因此,您的测试代码应该像对待任何其他合适的软件一样对待:
公共测试逻辑应该重构给帮助器。如果在一些常见的测试逻辑中存在错误,那么能够在一个地方修复它是很棒的,而不是在测试套件上通过复制粘贴样板来应用修复。
测试应该采用与普通代码相同的代码质量标准:正确的命名、格式、注释、内联文档、代码组织、编码样式和约定。
测试需要重构以保持代码质量。任何代码在增长过程中都会变得杂乱无章且难以处理,因此需要进行重构以保持整洁、干燥和组织良好。测试代码也没有什么不同,随着它的增长和变化以支持测试主应用程序的不断增长/变化的特性集,它需要定期重构以保持干爽并保持代码质量。
您的测试套件应该是敏捷和灵活的。如果要测试的API发生更改,更改测试应该又快又容易。如果删除了一段代码,您应该可以随意删除相应的测试,如果重写了代码,那么重写测试以匹配应该不难。适当的抽象/帮助器/装置有助于确保修改和重写测试套件的各部分不会造成负担。
并不是每个人都同意这些指导方针。我见过一些人争辩说测试不同于正常代码。这种复制-粘贴测试代码不仅是可接受的,而且比设置测试抽象和帮助器来保持干爽更可取。争论的焦点是,当没有抽象概念时,看看测试中是否有错误就更简单了。我不同意那个观点。
我的观点是,测试和其他任何代码一样,都应该被视为代码。
测试就是代码,代码应该是干燥的和分解的,这样才能看到必要的逻辑,并且不会有重复的样板。一个很好的例子是定义测试帮助器,使您可以轻松地将大量测试用例推送到您的测试套件中,并且能够一目了然地看到您的测试套件正在测试的输入。例如,给定以下测试代码:
//健全性检查当您在REPL中按Enter键时运行的逻辑,/-检测一组输入行是否./-是否完整,并且可以在不需要额外输入的情况下提交//-不完整,因此需要来自用户的额外输入行def test1={val res=ammon e.interp.Parsers.Split(";{}";)assert(res.isDefined)}def test2={val res=ammonite.interp.Parsers.split(";foo.bar";}def test2={val res=ammon e.interp.Parsers.Split(";{}";)assert(res.isDefined)}def test2={val res=ammonite.interp.Parsers.split(";foo.bar";)assert(res.isDefined)}def test3={val res=ammon e.interp.Parsers.Split(";foo.bar//行注释";)assert(res.isDefined)}def test4={val res=ammon e.interp.Parsers.plit(";foo.bar/*块注释*/";)assert(res.isDefined)}def test5={val res=。n%3==0||n%5==0).sum";)Assert(res.isDefined)}def test6={val res=ammonitor e.interp.Parsers.Split(";{";)assert(res.isEmpty)}def test7={val res=ammonitor e.interp.Parsers.Split(";foo.bar/*不完整块注释";)assert(res.isEmpty)}def test7={val res=ammonitor e.interp.Parsers.Split(";foo.bar/*不完整块注释";)assert(res.isEmpty。val r=(1到1000.view.filter(n=>;n%3==0||n%5==0)";)assert(res.isEmpty)}def test9={val res=ammon e.interp.Parsers.Split(";val r=(1 To 1000).view.filter(n=>;n%3=0||n%5=0";)assert(res.isEmt。
你可以看到它在一遍又一遍地做同样的事情。它确实应该写成:
//健全性检查当您在REPL中按Enter键时运行的逻辑,/-检测一组输入行是否./-是否完整,并且可以在不需要额外输入的情况下提交//-不完整,因此需要来自userdef checkDefined(s:string)={val res=ammonitor e.interp.Parsers.plit(S)assert(res.isDefined)}def checkEmpty(s:string)={val res=ammonitor(s:string)={val res=ammonitor。{}";)checkDefined(";foo.bar";)checkDefined(";foo.bar//行注释";)checkDefined(";foo.bar/*块注释*/";)checkDefined(";val r=(1 To 1000).view.filter(n=>;n%3=0|n%5=0).sum";)checkDefined(";foo.bar";)checkDefined(";foo.bar//行注";)checkDefined(";foo.bar//行注"。foo.bar/*不完整的块注释";)checkEmpty(";val r=(1至1000.view.filter(n=>;n%3==0||n%5==0)";)checkEmpty(";val r=(1至1000).view.filter(n=>;n%3==0||n%5==0";)}。
这只是一个正常的重构,您可以在任何编程语言的任何代码上执行该重构。尽管如此,它会立即将繁重的样板复制粘贴测试方法转变为优雅、干练的代码,从而使您正在测试的输入以及它们的预期输出一目了然。还有其他方法可以做到这一点,例如,您可以定义Array中的所有已定义案例、Array中的所有空案例,并使用断言对它们进行循环:
def finedCases=Seq(";{}";,";foo.bar";,";foo.bar//行注释";,";foo.bar/*块注释*/";,";val r=(1到1000).view.filter(n=>;n%3=0||n%5=0).sum";)(s&。-finedCases){val res=ammon e.interp.Parsers.plit(S)assert(res.isDefined)}def emptyCases=Seq(";{";,";foo.bar/*不完整的块注释";,";val r=(1至1000.view.filter(n=>;n%3=0||n%5==0)";,";Val r=(1至1000.view.filter(n=>;n%3=0||n%5==0)";,";V。n%3==0||n%5==0";(s<;-emptyCases){val res=ammon e.interp.Parsers.Split(S)assert(res.isEmpty)}。
这两种重构都实现了相同的目标,而且还有无数其他方法可以使此代码枯竭。你喜欢哪种款式由你决定。
围绕这个想法有很多花哨的工具/术语:表驱动测试、数据驱动测试等等。但从根本上说,您需要的是测试用例简洁,预期/断言行为一目了然。这是普通的代码重构技术能够帮助您在不使用任何花哨工具的情况下实现的功能。只有在您尝试手动完成此操作,并发现它在某些方面存在不足之后,才值得开始研究更专业的工具和技术。
确实有。
.