比起木偶,我更喜欢假货

2020-10-14 15:41:59

软件测试的主要目的是在程序到达目标用户之前检测程序中的任何潜在缺陷。这通常是通过建立定义支持的用户交互以及预期结果的功能需求,然后使用(自动)测试来验证它们来实现的。

因此,这类测试提供的价值直接取决于它们模拟的场景与软件实际使用方式的相似程度。其中的任何偏差都会降低这一价值,因为根据测试运行的结果对潜在生产系统的状态进行推理变得更加困难。

在理想的世界中,我们的测试场景,包括它们执行的环境,应该与现实生活条件完美匹配。这总是可取的,但可能并不总是实用的,因为系统可能依赖于难以测试的组件,因为它们不可用,或者因为它们的行为不一致或速度慢。

在这种情况下,一种常见的做法是用充当双重测试的轻量级替代项替换这种依赖项。尽管这样做确实会降低信心,但在设计健壮且一致的测试套件时,这通常是不可避免的权衡。

也就是说,虽然可以用不同的方式实现双重测试,但大多数情况下,开发人员倾向于将模仿作为默认选择。这是不幸的,因为它会导致对mock的过度使用,而其他形式的替代通常更合适,从而使测试具有实现意识并且很脆弱。

在编写测试时,我更喜欢尽可能避免模仿,转而依赖假实现。它们需要一些额外的前期投资,但提供了许多需要考虑的实际优势。

在本文中,我们将查看这两种版本的测试双重测试之间的差异,确定使用其中一种覆盖另一种会对测试设计产生怎样的影响,以及为什么使用假测试通常会导致更易于管理的测试套件。

当我们进入软件术语领域时,词语开始慢慢失去它们的意义。在这方面,测试术语特别臭名昭著,因为它似乎总是给开发人员带来很多不确定性。

不出所料,“模拟”的概念或它与其他类型的替代品有何根本不同也是其中之一。尽管它的用法非常普遍,但这个术语并没有一个被普遍接受的解释。

根据Gerard Meszaros介绍的原始定义,模拟是一种非常具体的替代类型,用于验证被测系统及其依赖项之间的交互。然而,现在,区别变得有点模糊了,因为这个术语通常用来指使用诸如Moq、Mockito、Jest等框架创建的更广泛类别的对象。

根据最初的定义,这样的替代品可能不一定是嘲弄的,但承认这些技术细节几乎没有什么好处。因此,为了简单起见,我们将在整篇文章中坚持对该术语的这种更口语化的理解。

一般说来,模拟是一种替代品,它假装功能与其真实对应的功能相同,但返回的是预定义的响应。从结构的角度来看,它确实实现了与实际组件相同的外部接口,但是该实现完全是肤浅的。

事实上,模拟根本不是为了具有有效的功能。其目的更确切地说是模拟各种操作的结果,以便被测试的系统执行给定场景所需的行为。

除此之外,模拟还可以用来验证系统内发生的副作用。这是通过记录方法调用并检查它们出现的次数及其参数是否符合预期来实现的。

让我们来看看所有这些在实践中是如何运作的。例如,假设我们正在构建一个依赖于由以下接口表示的某些二进制文件存储的系统:

公共接口{ReadFileAsync(Filename);DownloadFileAsync(filename,outputFilePath);UploadFileAsync(filename,stream);UploadManyFilesAsync(FileNameStreamMap);}

正如我们所看到的,它提供了读取和上传文件的基本操作,以及一些更专门的方法。上述抽象的实际实现与我们无关,但出于复杂性的考虑,我们可以假装它依赖于某个昂贵的云供应商,并且不适合进行测试。

在此基础上构建的另一个组件负责加载和保存文本文档:

Public class{private readonly_storage;public DocumentManager(Storage)=>;_storage=storage;private static GetFileName(Document Name)=>;$";docs/{document Name}";;public async GetDocumentAsync(Document Name){filename=GetFileName(Document Name);等待使用STREAM=AWAIT_STORAGE。ReadFileAsync(文件名);使用StreReader=new(流);返回等待StreReader。ReadToEndAsync();}公共异步SaveDocumentAsync(DocentName,Content){filename=GetFileName(DocentName);Data=Encoding.UTF8。GetBytes(内容);ALWAIT USING STREAM=NEW(数据);AWAIT_STORAGE。UploadFileAsync(文件名,流);}}。

该类为我们提供了对原始文件访问的抽象,并公开了直接处理文本内容的方法。它的实现并不是特别复杂,但是让我们设想一下我们无论如何都要测试它。

正如我们在前面指出的,在我们的测试中使用IBlobStorage的真正实现会很麻烦,因此我们不得不求助于双重测试。当然,更简单的方法之一是创建模拟实现:

[]public Async i_CAN_Get_the_Content_of_an_Existing_Document(){//安排等待使用document Stream=new(new{0x68,0x65,0x6c,0x6c,0x6f});blobStorage=Mock。()的;模拟的。Get(BlobStorage)。设置(bs=>;bs。ReadFileAsync(";docs/test.txt";))。ReturnsAsync(Document Stream);document Manager=new(BlobStorage);//act content=await document Manager。GetDocumentAsync(";test.txt";);//断言内容。应该()。Be(";hello";);}[]public async I_CAN_UPDATE_the_content_of_a_document(){//安排blobStorage=Mock。Of();document Manager=new(BlobStorage);//等待document Manager执行操作。SaveDocumentAsync(";test.txt";,";hello";);//断言模拟。Get(BlobStorage)。验证(bs=>;bs。UploadFileAsync(";docs/test.txt";,it.。Is(s=>;/*流校验*/);}。

在上面的代码片段中,第一个测试尝试验证使用者是否可以检索文档,因为它已经存在于存储中。为了简化这一前提条件,我们将模拟配置为在使用预期的文件名调用ReadFileAsync()时返回硬编码字节流。

然而,在这样做的过程中,我们无意中对DocumentManager如何在幕后工作做出了一些非常有力的假设。也就是说,我们假设:

这些细节现在可能是真的,但将来很容易改变。例如,我们可以决定将文件存储在不同的路径下,或者将对ReadFileAsync()的调用替换为DownloadFileAsync(),以此作为抢占本地缓存文件的方法。

在这两种情况下,从用户的角度看不到实现中的更改,因为表面级别的行为将保持不变。但是,因为我们编写的测试依赖于系统的内部细节,所以它将开始失败,这表明我们的代码中存在错误,而实际上并没有错误。

第二个场景的工作方式略有不同,但也存在相同的问题。要验证文档在保存时是否在存储中正确持久化,它会检查进程中是否发生了对UploadFileAsync()的调用。

同样,不难想象底层实现可能会以打破这一测试的方式进行更改的情况。例如,我们可能决定稍微优化一下行为,不直接上传文档,而是将它们保存在内存中,并使用UploadManyFilesAsync()成批发送。

经验丰富的模拟实践者可能会争辩说,如果我们将模拟配置为不那么严格,这些缺点中的一些可以减轻。在本例中,我们可以修改测试,使其期望调用任何上载方法,而不是特定的方法,同时根本不检查参数:

[]public async i_can_update_the_content_of_a_document(){//安排UploadMethodCalled=false;blobStorage=Mock。()的;模拟的。Get(BlobStorage)。设置(bs=>;bs。UploadFileAsync(IT.。IsAny(),//任何参数->;都可以。IsAny()//任何参数->;OK))。Callback(()=>;or therUploadMethodCalled=true);模拟。Get(BlobStorage)。设置(bs=>;bs。UploadManyFilesAsync(//任何参数->;确认。IsAny()。Callback(()=>;or therUploadMethodCalled=true);document Manager=new(BlobStorage);//动作等待document Manager。SaveDocumentAsync(";test.txt";,";hello";);//assert同样为UploadMethodCalled。应该()。BeTrue();}。

我们正在使用的模拟框架(MoQ)不允许我们直接验证是否调用了任何一个给定的方法,因此我们需要一个变通方法。为此,我们注入一个回调,该回调将变量的值设置为true,然后使用它检查相应的结果。

正如您可能看到的,此更改显著增加了测试的复杂性,因为我们突然发现自己要处理一些额外的状态和更复杂的模拟设置。我们试图核实的到底是什么,或者我们的做法是否正确,也变得不那么清楚了,这使得整个场景更难推理。

尽管做了这么多努力,这项测试仍然没有我们希望的那么有弹性。例如,将另一个方法添加到IBlobStorage并从DocumentManager调用它将导致测试失败,因为之前没有教过模拟如何处理它。您可以看到,所有这些问题和复杂性只能在具有大型测试套件的实际项目中进行很差的推断。

无论如何,依赖于模拟的测试本质上与系统的实现相耦合,其结果是脆弱的。这不仅增加了额外的维护成本,因为这样的测试需要不断更新,而且还会使它们的价值大大降低。

面对潜在的衰退,它们没有为我们提供安全网,而是将我们锁定在现有的实现中,阻碍了进化。正因为如此,引入实质性更改和重构代码变得更加困难,最终也会令人气馁。

从逻辑上讲,为了避免测试和底层实现之间的强耦合,我们的双重测试需要完全独立于使用它们的场景。正如你可能猜到的,这正是假货的用武之地。

从本质上说,假货是一种替代品,代表着与真正的假货相比,一种重量轻但功能齐全的替代品。它提供了一个实际有效的端到端实现,而不是仅仅尝试使用预配置的响应来履行合同。

尽管它的功能与真实组件相似,但通过采用某些捷径,可以故意使假实现变得更简单。例如,可以将赝品编程为使用内存中的提供程序,而不是依赖远程数据库服务器。这使得它在测试时更容易访问,同时保留了它的大部分核心行为。

与模拟不同,伪通常不是通过动态代理在运行时创建的,而是像其他常规类型一样静态定义的。虽然使用模拟框架生成假实现在技术上也是可能的,但这样做几乎没有任何好处。

现在,让我们回到我们的文件存储接口,并创建一个可以在测试中使用的假实现。以下是我们可以做到这一点的方法之一:

Public class:{private readonly_files=new(StringCompeller.Ordinal);public ReadFileAsync(文件名){data=_files[filename];stream=new(Data);return Task。FromResult(Stream);}public Async DownloadFileAsync(filename,outputFilePath){使用输入等待读取文件异步(文件名);使用输出=文件等待。创建(OutputFilePath);等待输入。CopyToAsync(Output);}public Async UploadFileAsync(文件名,流){等待使用缓冲区=new();等待流。CopyToAsync(BUFFER);DATA=BUFFER。ToArray();_files[文件名]=data;}public async UploadManyFilesAsync(FileNameStreamMap){foreach(fileNameStreamMap中的var(filename,stream)){等待上传FileAsync(filename,stream);}。

如上所述,我们的伪BLOB存储使用散列映射来跟踪上传的文件及其内容。对于DownloadFileAsync()和UploadManyFilesAsync()等更高级的操作,真正的实现可能是使用一些优化的例程,但这里我们只是组合现有的功能。

请注意,上面的实现没有对它将如何在测试中使用做出任何假设。相反,它可以有效地替代我们系统中的实际BLOB存储组件。

正因为如此,伪装尽可能地复制真实依赖的行为也很重要。这意味着我们可能需要考虑各种细微差别,其中一些是:

没有正确处理这些方面并不会使实现完全无效,但可能会降低其在特定边缘情况下的价值。归根结底,即使使用假货,我们也无法获得与在真实环境中进行测试一样的信心,这就是为什么正确的端到端仍然是必要的。

我们在这里必须考虑的细节也可能与我们在使用模拟时所做的支持实现的假设没有太大不同,因为两者实际上都不是由组件的接口管理的。然而,主要的区别在于,我们在这里建立的耦合是在组件的TestDouble和实际实现之间,而不是在TestDouble和它的使用者的内部细节之间。

因为我们的FAKE是与测试本身分开定义的,所以它的设计不依赖于与系统其余部分的任何特定交互。因此,更改DocumentManager的实现应该不会导致测试失败。

有了这个问题,让我们考虑一下加入假货实际上是如何影响我们的场景设计的。最初的本能可能是把它转换成这样的东西:

[]public async I_CAN_Get_the_Content_of_an_Existing_Document(){//安排blobStorage=new();document Manager=new(BlobStorage);等待使用document Stream=new(new{0x68,0x65,0x6c,0x6c,0x6f});等待blobStorage。UploadFileAsync(";docs/test.txt";,document Stream);//仍然支持实现--^//Act Content=等待DocentManager。GetDocumentAsync(";test.txt";);//断言内容。应该()。是(";hello";);}。

在这里,我们采用一个现有的测试,而不是配置一个模拟来返回预配置的响应,而是创建一个假的BLOB存储并直接用数据填充它。这样,我们就不需要假设检索文档应该调用某个方法,而是只需要依赖我们的false提供的行为的完整性。

然而,尽管我们能够消除大多数假设,但我们并没有消除所有这些假设。也就是说,我们的测试仍然预期调用GetDocumentAsync()应该在文档/名称空间内查找文件,因为这是我们在排列阶段将其上传到的位置。

这个问题源于这样一个事实,即我们再次依赖DocumentManager与IBlobStorage交互的方式,但这一次不是由双重测试引起的,而是由测试本身的设计引起的。为了避免它,我们需要调整场景,使其围绕系统的外部行为,而不是它与依赖项的关系。

[]PUBLIC Async I_can_get_the_content_of_a_previously_saved_document(){//安排blobStorage=NEW();DocumentManager=NEW(BlobStorage);等待DocumentManager。SaveDocumentAsync(";test.txt";,";hello";);//动作内容=等待文档管理器。GetDocumentAsync(";test.txt";);//断言内容。应该()。是(";hello";);}。

现在,我们不是直接通过FakeBlobStorage创建文件,而是使用DocumentManager创建文件。这使场景更接近于实际的使用者如何与我们正在测试的类交互。

从系统行为的角度来看,我们唯一关心的是保存的文档是否可以在事后检索。任何不相关的细节都与我们无关,因此没有理由对其进行测试。

正因为如此,我们不必担心文件在存储中的持久化位置、上传的确切方式、使用的格式或编码,以及其他类似的方面。由于上面的测试只验证外部行为,因此它对内部细节没有任何过激的假设。

同样值得注意的是,与我们以前依赖模仿时的尝试相比,我们只需编写很少的代码。这个额外的好处来自这样一个事实,即设计良好的赝品本质上是可重用的,这对可维护性有很大帮助。

如果您习惯于纯化单元测试,这种方法一开始可能看起来有点奇怪,因为我们不是验证单个操作的结果,而是验证它们如何组合在一起以创建内聚功能。从总体上看,后者要重要得多,因为我们获得的信心直接取决于我们的测试与软件实际使用方式的匹配程度。

由于赝品是用来提供现实的和潜在的非平凡的实现,因此也应该测试它们的行为是有意义的。测试双重测试的想法可能看起来很奇怪,因为我们从来不用模拟来做这件事,但在这种情况下,它实际上是完全合理的。

事实上,将赝品定义为主项目的一部分甚至是很常见的,因为其余代码驻留在主项目中,而不是与测试一起定义。许多库和框架还经常提供假实现作为其核心包的一部分,以便其他开发人员也可以更容易地编写自己的测试。

测试假货的过程与测试正常生产代码没有任何不同。例如,对于FakeBlobStorage,我们可以验证其行为的重要方面,如下所示:

[]PUBLIC Async PREVICE_UPLOADED_FILE_CAN_BE_RETRIED(){//安排blobStorage=new();fileData=new{0x68,0x65,0x6c,0x6c,0x6f};等待使用fileStream=new(FileData);等待blobStorage。UploadFileAsync(";test.txt";,fileStream);//使用actualFileStream=await blobStorage执行等待。ReadFileAsync(";test.txt";);actualFileData=actualFileStream。ToArray();//Assert。

.