考虑这样的函数:

function savePeople(dataStore, people) {
    people.forEach(person => dataStore.savePerson(person));
}


它可能会这样使用:

myDataStore = new Store('some connection string', 'password');
myPeople = ['Joe', 'Maggie', 'John'];
savePeople(myDataStore, myPeople);


让我们假设Store有其自己的单元测试,或由供应商提供。无论如何,我们相信Store。让我们进一步假设错误处理(例如,数据库断开错误)不是savePeople的责任。确实,让我们假设商店本身是一个神奇的数据库,它不可能以任何方式出错。考虑到这些假设,问题是:

应该对savePeople()进行单元测试,还是应该对内置的forEach语言结构进行测试?

我们当然可以,传递一个模拟dataStore并断言每个人调用一次dataStore.savePerson()。您当然可以说这样的测试可提供针对实现更改的安全性:例如,如果我们决定用传统的forEach循环或某种其他迭代方法替换for。因此,测试并非完全无关紧要。然而,这似乎非常接近...


这是另一个可能更富有成果的例子。考虑一个仅协调其他对象或功能的功能。例如:

function bakeCookies(dough, pan, oven) {
    panWithRawCookies = pan.add(dough);
    oven.addPan(panWithRawCookies);
    oven.bakeCookies();
    oven.removePan();
}


假设您认为应该对这样的函数进行单元测试?我很难想像任何类型的单元测试都不能简单地模拟doughpanoven,然后断言在它们上面调用了方法。但是这样的测试无非是复制了该函数的确切实现。

无法以有意义的黑盒方式测试功能是否表明功能本身存在设计缺陷?如果是这样,如何改进呢?


为了更加清楚地激发激发bakeCookies示例的问题,我将添加一个更现实的场景,我已经遇到尝试向遗留代码添加测试并重构遗留代码时。

当用户创建新帐户时,需要做很多事情:1)需要在数据库中创建新用户记录2)需要发送欢迎电子邮件3)需要记录用户的IP地址以防欺诈目的。

所以我们想创建一个将所有“新用户”步骤联系在一起的方法:

引发错误,我们希望错误冒泡到调用代码中,以便它可以处理合适的错误。如果API代码正在调用它,则可能会将错误转换为适当的http响应代码。如果通过Web界面调用它,则可能会将错误转换为适当的消息以显示给用户,依此类推。关键是该函数不知道如何处理可能引发的错误。

我的困惑的实质是,要对此类函数进行单元测试,似乎有必要在测试自身(通过指定以某种顺序在模拟程序上调用方法),这似乎是错误的。

评论

运行后。你有饼干吗?

关于您的更新:为什么您要模拟平底锅?或面团?这些听起来像是简单的内存对象,应该很容易创建,因此没有理由不应该将它们作为一个单元进行测试。请记住,“单元测试”中的“单元”并不意味着“单个类”。它的意思是“用于完成任务的最小的代码单元”。一个平底锅可能只不过是一个用于存放面团物体的容器,因此人们希望将其单独进行测试,而不是仅仅从外部测试bakeCookies方法。
归根结底,这里工作的基本原则是您编写足够的测试以确保代码能够正常工作,并且当有人进行更改时,这是足够的“煤矿中的金丝雀”。而已。没有神奇的咒语,公式化的假设或教条式的断言,这就是为什么人们普遍认为85%至90%的代码覆盖率(而不是100%)是极好的。

不幸的是,@ RobertHarvey固执的陈词滥调和TDD声音叮咬,虽然一定会赢得您的一致认可,但无助于解决现实世界中的问题。为此,您需要弄脏双手并冒着回答实际问题的风险

以降低循环复杂性的顺序进行单元测试。相信我,在使用此功能之前您会用光时间

#1 楼

savePeople()应该进行单元测试吗?是。您不是要测试dataStore.savePerson是否有效,或者数据库连接是否有效,甚至foreach是否正常。您正在测试savePeople是否履行了其通过合同作出的承诺。

想象这种情况:某人对代码库做了很大的重构,并意外地删除了实现的forEach部分,因此它总是保存第一项。您不想让单元测试抓住这一点吗?

评论


@RobertHarvey:有很多灰色区域,因此区分IMO并不重要。不过,您是对的-测试“它调用正确的函数”并不是真正重要的,而是“不管它如何执行”,而是“它执行正确的事情”。重要的是进行测试,在给定功能的一组特定输入的情况下,您获得了一组特定的输出。但是,我可以看到最后一句话会造成混乱,因此我删除了它。

–布莱恩·奥克利(Bryan Oakley)
16-6-22在2:25



“您正在测试savePeople是否履行其通过合同做出的承诺。”这个。这么多。

–爱神
16 Jun 22'7:24



除非您有涵盖它的“端到端”系统测试。

–伊恩
16年6月22日在11:32

@Ian端到端测试不能代替单元测试,它们是免费的。仅仅因为您可以进行端到端测试以确保您保存某个人的名单并不意味着您不应该具有单元测试来涵盖整个测试。

– Vincent Savard
16年6月22日在12:43

@VincentSavard,但是如果以其他方式控制风险,则可以降低单元测试的成本/收益。

–伊恩
16年6月22日在14:22

#2 楼

通常,当人们进行“事后测试”开发时会出现这种问题。从TDD的角度解决此问题,在实施之前先进行测试,然后再练习一次问自己这个问题。

至少在我通常是外而内的TDD应用程序中,在实现savePeople之后,我将不会实现类似savePerson的功能。 savePeoplesavePerson功能将作为一个功能开始,并由相同的单元测试进行测试驱动。在重构步骤中,经过几次测试后,两者之间就会出现分离。这种工作方式还会带来一个问题,即函数savePeople应该在哪里-它是自由函数还是dataStore的一部分。

最后,测试不仅会检查您是否可以正确地将Person保存在Store中,但也可以保存很多人。这也将使我产生疑问,例如是否需要进行其他检查,例如“我是否需要确保savePeople函数是原子函数,要么全部保存要么不保存?” “不会被保存?这些错误看起来如何?”,依此类推。所有这一切都不仅仅是检查forEach或其他形式的迭代的使用。然后,我将更新savePerson的现有测试以通过新功能savePerson运行,以确保它仍然可以通过简单地委派一个人来挽救一个人,然后通过新测试来测试一个以上人的行为,并考虑是否必须使该行为具有原子性。

评论


基本上测试接口,而不是实现。

–窥探
16年6月22日在2:15

公平有见地的观点。但是,我确实以某种方式躲避了我的真正问题:)您的回答是:“在现实世界中,在一个设计良好的系统中,我认为不会存在这种简化版本的问题。”同样,很公平,但是我专门创建了这个简化的版本,以强调更普遍问题的实质。如果您无法超越示例的人工性质,则可以想象另一个示例,您确实有理由要使用类似的功能,该功能仅执行迭代和委派。或者,也许您认为这根本不可能?

–乔纳
16-6-22在2:16



@Jonah更新了。我希望它能更好地回答您的问题。这都是非常基于观点的,可能与本网站的目标背道而驰,但这当然是一个非常有趣的讨论。顺便说一句,我试图从专业工作的角度回答,在这种情况下,我们必须努力使所有应用程序行为都退出单元测试,而不管实现的难度如何,因为我们有责任构建经过良好测试和如果我们离开的话,新维护者的文件系统。对于个人或非关键(金钱也很关键)项目,我有不同的看法。

– MichelHenrich
16年6月22日在2:54

感谢更新。您将如何精确测试savePeople?正如我在OP的最后一段中所述还是其他方式?

–乔纳
16年6月22日在3:11

抱歉,我没有明确说明“不涉及模拟”部分。我的意思是我不会像您建议的那样对savePerson函数使用模拟,而是通过更通用的savePeople测试它。 Store的单元测试将更改为通过savePeople运行,而不是直接调用savePerson,因此,不使用任何模拟。但是,当然不应该存在数据库,因为我们想将编码问题与实际数据库中发生的各种集成问题隔离开来,因此这里仍然有一个模拟。

– MichelHenrich
16年6月22日在3:53

#3 楼


应该对savePeople()进行单元测试


是的,应该。但是,请尝试以与实现无关的方式编写测试条件。例如,将您的使用示例转换为单元测试:

function testSavePeople() {
    myDataStore = new Store('some connection string', 'password');
    myPeople = ['Joe', 'Maggie', 'John'];
    savePeople(myDataStore, myPeople);
    assert(myDataStore.containsPerson('Joe'));
    assert(myDataStore.containsPerson('Maggie'));
    assert(myDataStore.containsPerson('John'));
}


此测试可以执行多项操作:函数savePeople()的合约

它并不关心savePeople()的实现

它记录了savePeople()的示例用法


请注意,您仍然可以模拟/存根/伪造数据存储。在这种情况下,我将不检查显式函数调用,而是检查操作结果。这样,我的测试就为将来的更改/重构做好了准备。

例如,您的数据存储实现将来可能会提供saveBulkPerson()方法-现在对savePeople()的实现进行更改以使用saveBulkPerson()不会失败只要saveBulkPerson()可以正常工作,就可以进行单元测试。并且如果saveBulkPerson()不能按预期方式工作,则您的单元测试将捕获该问题。 />
如前所述,请尝试测试预期结果和功能接口,而不是实现(除非您正在执行集成测试-然后捕获特定的函数调用可能有用)。如果有多种方法来实现功能,则所有方法均应与单元测试一起使用。

关于问题的更新:

测试状态变化!例如。一些面团将被使用。根据您的实现,断言dough的使用量适合pan,或者断言dough已用完。在函数调用后,断言pan包含cookie。断言oven为空/处于与以前相同的状态。

对于其他测试,请验证边缘情况:如果在调用之前oven不为空,会发生什么?如果dough不够,会发生什么?如果pan已经满了?

您应该能够从面团,平底锅和烤箱的物体本身推断出这些测试所需的所有数据。无需捕获函数调用。

实际上,大多数TDD用户在编写函数之前就编写了测试,因此他们并不依赖于实际的实现。


对于最新添加的内容:


当用户创建新帐户时,需要进行以下操作:1)需要创建新的用户记录在数据库中创建2)需要发送欢迎电子邮件3)为了欺诈目的,需要记录用户的IP地址。

因此,我们想创建一种将所有“新用户”联系在一起的方法“步骤:

function createNewUser(validatedUserData, emailService, dataStore) {
    userId = dataStore.insertUserRecord(validateduserData);
    emailService.sendWelcomeEmail(validatedUserData);
    dataStore.recordIpAddress(userId, validatedUserData.ip);
}



对于这样的函数,我会模拟/存根/伪造(无论看起来更通用)dataStoreemailService参数。该函数不对任何参数单独进行任何状态转换,而是将它们委托给其中一些方法。我会尝试验证对函数的调用是否完成了4件事:


它将用户插入到数据存储中
发送(或至少调用了相应的方法)一封欢迎电子邮件
,它将用户IP记录到数据存储中。
它委派了它遇到的任何异常/错误(如果有的话)

前三项检查可以通过模拟进行, dataStoreemailService的存根或伪造品(您确实不想在测试时发送电子邮件)。由于我不得不查找一些注释,因此有以下区别:


伪品是指与原始物品具有相同行为且在一定程度上无法区分的物体。它的代码通常可以在测试之间重用。例如,这可以是用于数据库包装程序的简单内存数据库。
存根只是实现了满足此测试所需操作所需的尽可能多的内容。在大多数情况下,存根特定于一个测试或一组测试,只需要原始方法的一小部分即可。在此示例中,它可能是dataStore,仅实现了insertUserRecord()recordIpAddress()的合适版本。
模拟是一个对象,可让您验证其用法(通常是通过评估对其方法的调用来实现)。我会尝试在单元测试中谨慎使用它们,因为通过使用它们,您实际上是在测试功能实现,而不是对其接口的遵循性,但是它们仍然有其用途。存在许多模拟框架可帮助您仅创建所需的模拟。


请注意,如果这些方法中的任何一个引发错误,我们都希望该错误冒泡至调用代码,因此它可以处理它认为合适的错误。如果被API代码调用,则可能会将错误转换为适当的HTTP响应代码。如果通过Web界面调用它,则可能会将错误转换为适当的消息以显示给用户,依此类推。关键是此函数不知道如何处理可能引发的错误。


预期的异常/错误是有效的测试用例:如果发生此类事件,您可以进行确认。如果发生这种情况,该功能将按照您期望的方式运行。这可以通过在需要时让相应的模拟/假/桩对象抛出来实现。


我的困惑的本质是,对这种功能进行单元测试似乎需要重复精确的操作。测试本身中的实现(通过指定以某种顺序对模拟进行调用的方法),这似乎是错误的。


有时这是必须要做的(尽管您在集成测试中最在乎这一点)。更常见的是,还有其他方法可以验证预期的副作用/状态变化。

验证确切的函数调用会导致相当脆弱的单元测试:仅对原始函数进行很小的更改就会导致它们失败。这可能是不希望的,但是每当您更改功能时(例如重构,优化,错误修复...),都需要更改相应的单元测试。

遗憾的是,在这种情况下,单元测试会失去一些可信度:由于更改了单元测试,因此更改后的功能与以前相同,因此无法确认功能。

例如,考虑有人添加了呼叫到cookie烘焙示例中的oven.preheat()(优化!):


如果模拟了烤箱对象,尽管该方法的行为是可观察的,但它不会期望调用并导致测试失败不会改变(希望您仍然有很多cookie)。
存根可能会失败,也可能不会失败,这取决于您是仅添加要测试的方法还是使用一些虚拟方法添加整个接口。 >伪造不应该失败,因为它应该实现方法(根据接口)

在我的单元测试中,我尝试尽可能通用: nges,但可见的行为(从调用者的角度来看)仍然相同,我的测试应该通过。理想情况下,我唯一需要更改现有单元测试的情况应该是错误修复(测试的缺陷,而不是测试中的函数)。

评论


问题在于,一旦编写myDataStore.containsPerson('Joe'),您就假定功能测试数据库存在。完成此操作后,您将编写集成测试而不是单元测试。

–乔纳
16年6月22日在14:06

我假设我可以依靠拥有一个测试数据存储(我不在乎这是真实的还是模拟的),并且一切都按设置工作(因为我应该已经针对这些情况进行了单元测试)。该测试要测试的唯一一件事是,只要该数据存储实现了预期的接口,那么savePeople()实际上会将这些人员添加到您提供的任何数据存储中。集成测试将是,例如,检查我的数据库包装程序实际上是否对方法调用进行了正确的数据库调用。

–hoffmale
16年6月22日在15:13

要澄清的是,如果您使用的是模拟,那么您所能做的就是断言该模拟上的方法已被调用,也许带有某些特定参数。之后,您无法断言模拟的状态。因此,如果要在调用被测方法之后对数据库状态进行断言,例如在myDataStore.containsPerson('Joe')中,则必须使用某种类型的功能数据库。一旦采取了这一步骤,就不再是单元测试。

–乔纳
16年6月22日在15:52

它不必是真正的数据库-只是实现与实际数据存储相同的接口的对象(请阅读:它通过了数据存储接口的相关单元测试)。我仍然认为这是一个模拟。让模拟程序将通过任何方法添加的所有内容存储在数组中,并检查测试数据(myPeople的元素)是否在数组中。恕我直言,模拟仍然应具有与真实对象相同的可观察行为,否则,您正在测试是否符合模拟接口,而不是真实接口。

–hoffmale
16年6月22日在16:40

在调用savePeople之前,您可能应该测试myDataStore不包含Person('Joe')。否则,如果myDataStore是已在其他测试中使用的数据库,则可能已经有一个Joe。如果savePeople以某种方式停止工作,您不会注意到它。

–埃里克·杜米尼尔(Eric Duminil)
17/09/27在18:22

#4 楼

该测试提供的主要价值是使您的实现可重构。

我在职业生涯中曾做过许多性能优化,并且经常发现与您演示的确切模式有关的问题:保存N个实体进入数据库,执行N次插入。通常,使用单个语句进行批量插入会更有效。

另一方面,我们也不想过早优化。如果通常一次只能节省1-3个人,那么编写优化的批处理可能就太过分了。

通过适当的单元测试,可以按照上面的实现方式进行编写,如果您如果发现您需要对其进行优化,则可以通过自动测试的安全网自由地进行操作,以捕获任何错误。自然,这取决于测试的质量,因此请进行自由测试并进行良好的测试。

对这种行为进行单元测试的第二个好处是可以用作其目的的文档。这个琐碎的例子可能很明显,但是鉴于下面的要点,它可能非常重要。很难通过集成或验收测试来测试。例如,如果要求原子存储所有用户,那么您可以为此编写一个测试用例,这使您可以了解其行为是否符合预期,并且还可以作为该需求的文档给新的开发人员。

我将添加我从TDD讲师那里得到的想法。不要测试该方法。测试行为。换句话说,您并没有测试savePeople是否有效,而是在一次呼叫中可以保存多个用户。

当我停止将单元测试视为验证程序正常工作时,我发现我进行质量单元测试和TDD的能力得到了提高,但是,他们却验证了代码单元是否符合我的期望。那些是不同的。他们没有验证它是否有效,但他们验证了它是否符合我的想法。当我开始以这种方式思考时,我的观点发生了变化。

评论


批量插入重构示例就是一个很好的例子。我在OP中建议的可能的单元测试-一个dataStore的模拟对列表中的每个人都调用了savePerson-但是会因批量插入重构而中断。对我而言,这表明这是一个糟糕的单元测试。但是,我看不到另一种可以通过批量实现和每人节省一个实现的方法,而不使用实际的测试数据库并对此进行断言,这似乎是错误的。您能否提供适用于两种实现方式的测试?

–乔纳
16 Jun 22'在2:45



@ jpmc26关于测试人们被救的测试呢?

–user253751
16年6月22日在23:48

@immibis我不明白那是什么意思。大概,真正的商店由数据库支持,因此您必须对它进行模拟或存根以进行单元测试。因此,到那时,您将测试您的模拟或存根可以存储对象。那完全没用。最好的办法是断言每个输入都会调用savePerson方法,如果用大容量插入替换了循环,则不再需要调用该方法。这样您的测试就会失败。如果您还有其他想法,我会接受的,但是我还没有看到。 (没看到那是我的意思。)

– jpmc26
16 Jun 23'在0:16



@immibis我认为这不是有用的测试。使用伪造的数据存储并没有给我任何信心,它可以与真实的东西一起工作。我怎么知道我的假冒作品像真实的东西?我宁愿让一套集成测试来涵盖它。 (我可能应该在这里的第一条评论中澄清我的意思是“任何单元测试”。)

– jpmc26
16 Jun 23'在2:20



@immibis我实际上是在重新考虑自己的位置。由于诸如此类的问题,我一直对单元测试的价值表示怀疑,但是即使您伪造输入,我也可能低估了该价值。我确实知道输入/输出测试往往比模拟繁重的测试有用得多,但是也许拒绝使用伪造的输入实际上是这里问题的一部分。

– jpmc26
16年6月23日在16:28

#5 楼

应该对bakeCookies()进行测试吗?是的。


假设您认为应该对这样的函数进行单元测试?我很难想像任何类型的单元测试都不能简单地模拟面团,锅和烤箱,然后断言在它们上调用了方法。仔细查看该函数应该执行的操作-应该将oven对象设置为特定状态。查看代码,看来pandough对象的状态并没有多大关系。因此,您应该传递oven对象(或对其进行模拟),并在函数调用结束时断言该对象处于特定状态。

换句话说,您应断言bakeCookies()烘烤了cookie。

对于非常短的功能,单元测试似乎只不过是重言式。但是请不要忘记,您的程序将比您受雇编写程序的时间更长。该功能将来可能会更改,也可能不会更改。

单元测试具有两个功能:


它测试一切正常。这是单元测试所用的最不有用的功能,而且看来您似乎只在询问问题时才考虑使用此功能。这是单元测试中最有用的功能,它可以防止将错误引入大型程序。在向程序添加功能时,它在常规编码中很有用,但在重构和优化时(在不改变程序任何可观察行为的情况下,实现程序的核心算法发生了显着变化)则更有用。不要测试函数内部的代码。而是测试该功能是否按照其说明的方式工作。当您以这种方式看待单元测试(测试功能,而不是代码)时,您将意识到您永远不会测试语言构造甚至应用程序逻辑。您正在测试API。

评论


嗨,谢谢您的回复。您介意查看我的第二次更新,并考虑如何在该示例中对功能进行单元测试吗?

–乔纳
16年6月22日在13:56

我发现当您可以使用真实烤箱或假烤箱时,此方法可能会有效,但模拟烤箱的效果则明显较差。模仿(根据Meszaros的定义)没有任何状态,只是记录了被调用的方法。我的经验是,当以这种方式测试诸如bakeCookies之类的功能时,它们倾向于在重构期间中断,这不会影响应用程序的可观察行为。

–James_pic
16年6月22日在16:55

完全是@James_pic。是的,这就是我正在使用的模拟定义。因此,根据您的评论,在这种情况下您会怎么做?放弃测试?编写易碎的,重复执行的测试吗?还有别的吗

–乔纳
16年6月22日在21:02

@Jonah我通常会考虑使用集成测试来测试该组件(我发现警告,它可能由于现代工具的质量而难以调试,但过时了),或者麻烦创建一个半令人信服的假货。

–James_pic
16年6月23日在8:44

#6 楼


应该对savePeople()进行单元测试,还是将这些测试等同于测试内置的forEach语言构造?


是。但是您可以用一种可以重新测试构造的方法来实现。它应该如何工作?

函数应该提供这种微妙的行为,您应该在单元测试中强制执行。

评论


是的,我同意应该测试微妙的错误条件,但是imo并不是一个有趣的问题-答案很明确。因此,我之所以特别指出的原因是,出于我的问题,savePeople不应对错误处理负责。再次说明一下,假设savePeople只负责遍历列表并将每个项目的保存委派给另一种方法,是否仍应对其进行测试?

–乔纳
16年6月22日在2:06

@Jonah:如果您要坚持仅将单元测试限制在foreach构造上,而不限制其外部的任何条件,副作用或行为,那么您是对的;新的单元测试真的不是那么有趣。

–罗伯特·哈维(Robert Harvey)
16年6月22日在2:27

@jonah-是否应该遍历并保存尽可能多的内容,或者在出错时停止?一次保存无法决定这一点,因为它不知道其使用方式。

– Telastyn
16年6月22日在2:44

@jonah-欢迎来到该网站。问与答格式的主要组成部分之一是我们无法为您提供帮助。您的问题当然可以为您提供帮助,但同时也可以帮助访问该站点的其他人寻找问题的答案。我回答了你问的问题。如果您不喜欢答案或宁愿移门柱也不是我的错。坦率地说,其他答案似乎说的是相同的基本内容,尽管更有说服力。

– Telastyn
16年6月22日在13:26

@Telastyn,我试图获得有关单元测试的见解。我最初的问题还不够清楚,因此我添加了一些说明,以使对话朝着真正的问题发展。您选择将其解释为我在“正确”游戏中以某种方式欺骗您。我花了数百个小时回答有关代码审查和SO的问题。我的目的始终是帮助正在回答的人。如果不是,那是您的选择。您不必回答我的问题。

–乔纳
16年6月22日在13:35

#7 楼

关键是您对特定功能的琐碎看法。大部分编程都是微不足道的:分配一个值,做一些数学运算,然后做一个决定:如果是,那就继续循环,直到...孤立地说,所有这些都是微不足道的。您刚刚读完任何教编程语言的书的前5章。

编写测试是如此容易的事实应该表明您的设计还不错。您愿意不容易测试的设计吗?

“这永远不会改变。”是大多数失败的项目开始的方式。单元测试仅确定在某些情况下单元是否按预期工作。让它通过,然后您就可以忽略它的实现细节,而只需使用它即可。将大脑空间用于下一个任务。

知道事情如预期的那样非常重要,在大型项目尤其是大型团队中并非易事。如果程序员有一个共同点,那就是我们所有人都必须处理别人的糟糕代码的事实。我们至少可以做的是进行测试。如有疑问,请编写测试并继续。

评论


感谢您的反馈。好点。我真正想回答的问题(我刚刚添加了另一个更新以澄清问题)是测试功能的适当方法,这些功能仅通过委派调用其他服务序列即可。在这种情况下,似乎适合于“记录合同”的单元测试只是对函数实现的重述,声称在各种模拟中都调用了方法。然而,在那种情况下,测试与实现完全相同……。

–乔纳
16年6月22日在13:45

#8 楼


是否应该对savePeople()进行单元测试,或者这样的测试是否等同于测试内置的forEach语言构造?还有一些额外的参数(我想):

首先,单元测试用于测试合同的履行,而不是API的实现;测试应设置先决条件然后调用,然后检查效果,副作用,任何不变性和后期条件。当您决定测试什么时,API的实现无关紧要。

其次,当函数更改时,您的测试将在那里检查不变量。现在它不会改变的事实并不意味着您不应该进行测试。

第三,在TDD方法(强制执行该方法)中实施微不足道的测试很有价值。

在编写C ++时,对于我的类,我倾向于编写一个琐碎的测试,以实例化一个对象并检查不变量(可赋值,常规等)。我发现令人惊讶的是,此测试在开发过程中被破坏了几次(例如-通过在类中添加一个不可移动的成员,这是错误的)。

#9 楼

我认为您的问题可以归结为:

如果不将其作为集成测试,如何对void函数进行单元测试?例如,可以立即看出测试应该是什么。平移对象?'

我认为您是对的,因为对所有内容进行了模拟的单元测试以及仅检查函数xy和z被称为缺少值。

但是!我认为在这种情况下,您应该重构void函数以返回可测试的结果或使用真实对象并进行集成测试

---更新createNewUser示例


需要在数据库中创建新的用户记录
需要发送欢迎电子邮件
出于欺诈目的,需要记录用户的IP地址。

确定,因此这次函数的结果不容易返回。我们要更改参数的状态。

这是我引起争议的地方。
我为有状态参数创建具体的模拟实现

,亲爱的读者,请尝试控制您的愤怒!

如此...

var validatedUserData = new UserData(); //we can use the real object for this
var emailService = new MockEmailService(); //a simple mock which saves sentEmails to a List<string>
var dataStore = new MockDataStore(); //a simple mock which saves ips to a List<string>

//run the test
target.createNewUser(validatedUserData, emailService, dataStore);

//check the results
Assert.AreEqual(1, emailService.EmailsSent.Count());
Assert.AreEqual(1, dataStore.IpsRecorded.Count());
Assert.AreEqual(1, dataStore.UsersSaved.Count());


这将被测方法的实现细节与所需行为分开。一个替代实现:

function createNewUser(validatedUserData, emailService, dataStore) {
  userId = dataStore.bulkInsedrtUserRecords(new [] {validateduserData});
  emailService.addEmailToQueue(validatedUserData);
  emailService.ProcessQueue();
  dataStore.recordIpAddress(userId, validatedUserData.ip);
}


仍将通过单元测试。另外,您还具有能够在测试中重用模拟对象并将其注入到应用程序中以进行UI或集成测试的优势。

评论


这不是一个集成测试,仅仅是因为您提到了两个具体类的名称...集成测试是关于测试与外部系统(例如磁盘IO,DB,外部Web服务等)的集成。 -内存,快速,检查我们感兴趣的事物等。我同意让该方法直接返回Cookie的感觉虽然更好。

–萨拉
16年6月22日在7:30

等待。就我们所知,pan.getcookies向厨师发送电子邮件,要求他们在有机会的情况下将cookie从烤箱中取出。

–伊万
16年6月22日在7:49

我认为这在理论上是可能的,但这将是一个非常令人误解的名称。谁听说过发送电子邮件的烤箱设备?但我明白你的意思,这取决于。我假设这些协作类是叶子对象或只是普通的内存中对象,但是如果它们确实是偷偷摸摸的东西,则需要谨慎。我认为电子邮件发送绝对应该比这更高。这看起来像实体中的失败和肮脏的业务逻辑。

–萨拉
16年6月22日在8:16

这是一个反问,但是:“谁听说过发送电子邮件的烤箱设备?” venturebeat.com/2016/03/08/…

–clacke
16年6月22日在12:38

嗨,伊万,我认为这个答案与我真正要问的最接近。我认为您关于bakeCookies返回烤好的饼干的观点很明确,发布后我曾有所考虑。所以我认为这又不是一个很好的例子。我又添加了一个更新,希望能提供一个更现实的示例来说明促使我提出问题的原因。非常感谢您的投入。

–乔纳
16年6月22日在13:40

#10 楼

您还应该测试bakeCookies-例如,bakeCookies(egg, pan, oven)会产生什么?煎蛋还是例外? panoven都不会对它们的实际成分感兴趣,因为它们都不应该这样做,但是bakeCookies通常应该产生cookie。更一般而言,它取决于如何获得dough以及是否有可能成为egg或例如。 water代替。