单元测试被高估了

2020-07-09 19:26:20

测试在现代软件开发中的重要性再怎么强调都不为过。交付一个成功的产品不是你做一次就忘记的事情,而是一个不断重复的过程。随着每一行代码的变化,软件必须保持在功能状态,这意味着需要严格的测试。

随着时间的推移,随着软件行业的发展,测试实践也日趋成熟。逐渐走向自动化,测试方法也影响了软件设计本身,催生了诸如测试驱动开发之类的口头禅,强调了诸如依赖反转之类的模式,并普及了围绕它构建的高级体系结构。

如今,自动化测试深深地植根于我们对软件开发的看法中,很难想象其中一个没有另一个。由于这最终使我们能够在不牺牲质量的情况下快速生产软件,因此很难说这不是一件好事。

然而,尽管有许多不同的方法,但现代“最佳实践”主要推动开发人员专门进行单元测试。考试的范围位于迈克·科恩金字塔的更高位置,它们要么作为更广泛的套件(通常是完全不同的人)的一部分编写,要么甚至完全被忽视。

这种方法的好处通常得到这样一种观点的支持,即单元测试在开发过程中提供了最大的价值,因为它们能够快速捕获错误,并帮助实施有助于模块化的设计模式。这一想法已经被广泛接受,以至于术语“单元测试”现在在某种程度上与一般的自动化测试混为一谈,失去了部分意义,并造成了混淆。

当我是一名经验较少的开发人员时,我相信应该严格遵循这些“最佳实践”,因为我认为这会使我的代码更好。我并不特别喜欢编写单元测试,因为所有的仪式都涉及到抽象和嘲笑,但这毕竟是推荐的方法,所以我是谁才能更好地了解呢?

直到后来,随着我进行了更多的实验并构建了更多的项目,我才开始意识到有更好的方法来处理测试,而且在大多数情况下,专注于单元测试完全是浪费时间。

大行其道的“最佳实践”通常有一种趋势,就是在他们周围表现出对货物的狂热崇拜,诱使开发人员应用设计模式或使用特定的方法,而不给他们非常需要的三思。在自动化测试的上下文中,当涉及到我们行业对单元测试的不健康痴迷时,我发现这种情况很普遍。

在本文中,我将分享我对此测试技术的观察,并详细说明为什么我认为它是低效的。我还将解释我目前正在使用哪些方法来测试我的代码,无论是在开放源码项目中还是在日常工作中都是如此。

注意:本文包含用C#编写的代码示例,但是语言本身对于我要阐述的观点并不(太)重要。

注2:我逐渐意识到,编程术语在传达含义方面完全无用,因为似乎每个人对它们的理解都不同。在本文中,我将依赖于“标准”定义,其中单元测试针对的是代码中最小的可分离部分,端到端测试针对的是软件最外面的入口点,而集成测试针对的是介于两者之间的所有内容。

注3:如果您不想阅读整篇文章,您可以跳到结尾处查看摘要。

单元测试,顾名思义,围绕着“单元”的概念,它表示一个较大系统的一个非常小的孤立部分。单元是什么或应该有多小没有正式的定义,但大多数人认为它对应于模块(或对象的方法)的单个功能。

通常,如果编写代码时没有考虑单元测试,则可能不可能完全隔离地测试某些函数,因为它们可能具有外部依赖关系。为了解决这个问题,我们可以应用依赖倒置原则,用抽象替换具体的依赖关系。然后,根据代码是正常执行还是作为测试的一部分,可以用真实或虚假的实现替换这些抽象。

除此之外,单元测试应该是纯粹的。例如,如果函数包含将数据写入文件系统的代码,则该部分也需要抽象出来,否则验证此类行为的测试将被视为集成测试,因为它的覆盖范围扩展到单元与文件系统的集成。

考虑到上述因素,我们可以推断单元测试只用于验证给定函数内的纯业务逻辑。它们的范围不扩展到测试副作用或其他集成,因为这属于集成测试领域。

为了说明这些细微差别如何影响设计,让我们来看一个我们想要测试的简单系统的示例。假设我们正在开发一个计算当地日出和日落时间的应用程序,它通过以下两个类的帮助来计算日出和日落时间:

公共类:{private readonly_httpClient=new();//通过查询获取位置公共异步任务<;location>;GetLocationAsync(String LocationQuery){/*.*/}//通过IP公共异步任务<;location>;GetLocationAsync(){/*.*/}public void Dispose()=>;_httpClient获取当前位置。Dispose();}公共类:{private readonly_locationProvider=new();//获取当前位置和指定日期的公共异步任务<;SolarTimes>;GetSolarTimesAsync(Date){/*.*/}public void Dispose()=>;_locationProvider。Dispose();}。

尽管上面的设计在面向对象方面是完全有效的,但是这两个类实际上都不是单元可测试的。因为LocationProvider依赖于它自己的HttpClient实例,而SolarCalculator又依赖于LocationProvider,所以不可能隔离这些类的方法中可能包含的业务逻辑。

公共接口{Task<;Location>;GetLocationAsync(String LocationQuery);Task<;Location>;GetLocationAsync();}公共类:{private readonly_httpClient;public LocationProvider(HttpClient)=>;_httpClient=httpClient;public Async Task<;location>;GetLocationAsync(String LocationQuery){/*。GetLocationAsync(){/*.*/}}公共接口{Task<;SolarTimes>;GetSolarTimesAsync(Date);}公共类:{private readonly_locationProvider;public SolarCalculator(LocationProvider)=>;_locationProvider=locationProvider;public Async Task<;SolarTimes>;GetSolarTimesAsync(Date){/*.*。

通过这样做,我们能够将LocationProvider与SolarCalculator解耦,但作为交换,我们的代码大小几乎翻了一番。还要注意,我们不得不从这两个类中删除IDisposable,因为它们不再拥有自己的依赖项,因此没有业务来负责它们的生命周期。

虽然对某些人来说,这些更改似乎是一种改进,但重要的是要指出,我们定义的接口除了使单元测试成为可能之外,没有任何实际用途。在我们的设计中不需要实际的多态性,因此,就我们的代码而言,这些抽象是自动的(即为了抽象而抽象)。

让我们尝试从所有这些工作中获益,并为SolarCalculator.GetSolarTimesAsync编写一个单元测试:

PUBLIC CLASS{[]PUBLIC Async GetSolarTimesAsync_ForKyiv_ReturnsCorrectSolarTimes(){//安排变量位置=NEW(50.4530.52);VAR DATE=NEW(2019,11,04,00,00,00,00,TimeSpan。FromHours(+2));var expectedSolarTimes=new(new(06,55,00),new(16,29,00));var locationProvider=Mock.。<;>;(lp=>;lp.。GetLocationAsync()==任务。FromResult(Location));var solarCalculator=new(LocationProvider);//Act var solarTimes=等待solarCalculator。GetSolarTimesAsync(Date);//断言solarTimes。应该()。BeEquivalentTo(Expect TedSolarTimes);}}

这里我们有一个基本的测试,它验证SolarCalculator对于已知位置是否正常工作。因为单元测试及其单元是紧密耦合的,所以我们遵循推荐的命名约定,其中测试类以被测试的类命名,测试方法的名称遵循method_predition_result模式。

为了在安排阶段模拟所需的前提条件,我们必须向单元的依赖项ILocationProvider注入相应的行为。在本例中,我们通过将GetLocationAsync()的返回值替换为提前知道正确太阳时间的位置来做到这一点。

请注意,尽管ILocationProvider公开了两个不同的方法,但从契约的角度来看,我们无法知道实际调用了哪一个方法。这意味着,通过选择模拟这些方法中的一个特定方法,我们是在假设我们正在测试的方法的底层实现(它被故意隐藏在前面的代码片段中)。

总而言之,测试确实正确地验证了GetSolarTimesAsync内部的业务逻辑是否按预期工作。然而,让我们扩展一下我们在这个过程中所做的一些观察。

重要的是要理解,任何单元测试的目的都非常简单:在独立的范围内验证业务逻辑。根据您打算测试哪些交互,单元测试可能是也可能不是适合这项工作的工具。

例如,使用冗长而复杂的数学算法对计算太阳时间的方法进行单元测试有意义吗?很有可能,是的。

对向rest API发送请求以获取地理坐标的方法进行单元测试有意义吗?最有可能的是,不会。

如果您将单元测试本身视为一个目标,您很快就会发现,尽管付出了很多努力,但大多数测试都不能为您提供所需的信心,原因很简单,因为它们测试的是错误的东西。在许多情况下,使用集成测试测试更广泛的交互比专门关注单元测试要有利得多。

有趣的是,一些开发人员最终确实在这样的场景中编写了集成测试,但仍然将它们称为单元测试,这主要是因为对概念的混淆。虽然有人可能会争辩说,单位大小可以任意选择,可以跨越多个组件,但这使得定义非常模糊,最终只会使这个术语的总体用法变得完全无用。

支持单元测试的最流行的论点之一是它迫使您以高度模块化的方式设计软件。这建立在这样一个假设之上,即当代码被拆分成许多较小的组件而不是几个较大的组件时,更容易对其进行推理。

然而,它通常会导致相反的问题,其中功能可能最终会变得不必要地支离破碎。这使得评估代码变得更加困难,因为开发人员需要扫描组成单个内聚元素的多个组件。

此外,实现组件隔离所需的大量使用抽象创建了许多不必要的间接。尽管抽象本身是一种令人难以置信的强大和有用的技术,但它不可避免地增加了认知复杂性,使得对代码进行推理变得更加困难。

通过这种间接方式,我们还最终丢失了原本可以保持的某种程度的封装。例如,管理单个依赖项的生存期的责任从包含它们的组件转移到其他一些不相关的服务(通常是依赖项容器)。

一些基础设施复杂性还可以委托给依赖注入框架,从而更容易配置、管理和激活依赖项。但是,这会降低可移植性,这在某些情况下可能是不希望的,例如在编写库时。

归根结底,虽然很明显单元测试确实会影响软件设计,但这是否真的是一件好事还存在很大争议。

从逻辑上讲,假设单元测试很小并且是孤立的,单元测试应该非常容易和快速地编写,这是有意义的。不幸的是,这只是另一个似乎相当流行的谬论,尤其是在管理者中。

尽管前面提到的模块化体系结构诱使我们认为可以将各个组件分开考虑,但单元测试实际上并没有从中受益。事实上,单元测试的复杂性只与单元的外部交互数量成正比增长,这是因为您必须做所有的工作来实现隔离,同时仍然执行所需的行为。

本文前面演示的示例非常简单,但在实际项目中,安排阶段跨越许多长行的情况并不少见,这只是为了为单个测试设置前提条件。在某些情况下,被嘲笑的行为可能非常复杂,几乎不可能解开它来弄清楚它应该做什么。

除此之外,单元测试在设计上与它们正在测试的代码紧密耦合,这意味着随着测试套件也需要更新,任何进行更改的工作实际上都会加倍。更糟糕的是,似乎很少有开发人员发现这样做是一项诱人的任务,通常只是将其转嫁给团队中更初级的成员。

基于模拟的单元测试的不幸之处在于,用这种方法编写的任何测试本质上都是可实现的。通过模拟特定的依赖项,您的测试将依赖于测试中的代码如何使用该依赖项,而该依赖项不受公共接口的控制。

这种额外的耦合通常会导致意想不到的问题,在这些问题中,看似不会中断的更改可能会导致测试开始失败,因为模拟已经过时。这可能非常令人沮丧,并最终阻碍开发人员尝试重构代码,因为永远不清楚测试中的错误是来自实际的回归还是由于依赖于某些实现细节。

单元测试有状态代码可能更加棘手,因为可能无法通过公开的接口观察到突变。要解决此问题,您通常会注入间谍,这是一种记录函数调用时间的模拟行为,可帮助您确保单元正确使用其依赖项。

当然,当您不仅依赖于被调用的特定函数,而且还依赖于它发生了多少次或传递了哪些参数时,测试就变得更加与实现相耦合了。以这种方式编写的测试只有在预期内部细节不会更改的情况下才有用,这是一种非常不合理的期望。

过于依赖实现细节也会使测试本身变得非常复杂,考虑到需要多少设置才能配置模拟以模拟特定行为,特别是当交互不是那么微不足道或存在大量依赖项时。当测试变得如此复杂,以至于他们自己的行为很难推理时,谁来编写测试来测试这些测试呢?

无论您正在开发哪种类型的软件,其目标都是为最终用户提供价值。事实上,我们编写自动化测试的主要原因首先是为了确保不存在会降低该价值的意外缺陷。

在大多数情况下,用户通过一些顶级界面(如UI、CLI或API)使用软件。虽然代码本身可能涉及多个抽象层,但对于用户来说,唯一重要的是他们能够实际看到并与之交互的抽象层。

即使在系统的某些部分有几层深度的bug也没有关系,只要它不会出现在用户面前,并且不会影响提供的功能。相反,如果用户界面中存在缺陷,使得我们的系统实际上毫无用处,那么我们可能会完全覆盖所有较低级别的部分,这没有什么不同。

当然,如果您想要确保某些功能正常工作,您必须检查该功能是否正确。在我们的例子中,获得对系统信心的最好方法是模拟真实用户如何与顶层界面交互,并查看它是否按照预期正常工作。

单元测试的问题是它们正好相反。因为我们总是处理用户不直接交互的小的孤立的代码片段,所以我们从不测试实际的用户行为。

进行基于模拟的测试将这类测试的价值置于一个更大的问题之下,因为我们的系统中原本会使用的部分被模拟所取代,从而进一步拉开了模拟环境与现实的距离。不可能通过测试一些与用户体验不同的东西来获得用户将拥有流畅体验的信心。

那么,考虑到软件的所有现有缺陷,作为一个行业,我们为什么要决定将单元测试作为测试软件的主要方法呢?在很大程度上,这是因为更高级别的测试一直被认为太难、太慢、太不可靠。

如果您参考传统的测试金字塔,您会发现它建议测试的最重要部分应该在单元级别执行。其想法是,由于假设粗粒度测试速度较慢且较为复杂,因此您需要将精力集中在集成范围的底部,以获得高效且可维护的测试套件:

金字塔提供的比喻模型旨在传达一个好的测试方法应该涉及许多不同的层,因为关注极端可能会导致测试太慢和笨拙,或者在提供任何信心方面都无济于事。这就是说,强调较低的级别是因为开发测试的投资回报被认为是最高的。

尽管顶层测试提供了最大的信心,但它们通常以缓慢、难以维护或范围太广而无法作为典型的快节奏开发流程的一部分而告终。这就是为什么在大多数情况下,这样的测试是由专门的QA专家单独维护的,因为编写它们通常不被认为是开发人员的工作。

集成测试是介于单元测试和完全端到端测试之间的抽象部分,通常被完全忽略。由于不清楚哪种集成级别更可取,如何组织和组织这样的测试,或者担心它们可能会失控,许多开发人员倾向于避免它们,而倾向于更明确的极端情况,即单元测试。

由于这些原因,在开发过程中完成的所有测试通常都位于金字塔的最底层。事实上,随着时间的推移,这已经变得如此普遍,以至于开发测试和单元测试现在实际上是彼此的同义词,导致混淆,这只会因为会议演讲、博客文章、书籍,甚至一些IDE(就JetBrains Rider而言,所有测试都是单元测试)而进一步造成混乱。

虽然金字塔是将软件测试转变为解决问题的一次高尚尝试,但这种模型显然存在许多问题。在粒子中。

.