单元测试应用程序逻辑和不信任的语言构造之间的界线在哪里?


87

考虑这样的函数:

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地址以防欺诈目的。

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

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

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

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


44
运行后。你有饼干
伊万

6
关于您的更新:为什么您要模拟平底锅?或面团?这些听起来像是简单的内存对象,应该很容易创建,因此没有理由不应该将它们作为一个单元进行测试。请记住,“单元测试”中的“单元”并不意味着“单个类”。它的意思是“用于完成任务的最小的代码单元”。一个平底锅可能只不过是一个用于存放面团物体的容器,因此人们希望能够单独测试它,而不是仅仅从外面测试bakeCookies方法。–
sara

11
归根结底,这里工作的基本原则是您编写足够的测试以确保代码能够正常工作,并且当有人进行更改时,这是足够的“煤矿中的金丝雀”。而已。没有神奇的咒语,公式化的假设或教条式的断言,这就是为什么人们普遍认为85%至90%的代码覆盖率(而不是100%)是极好的。
罗伯特·哈维

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

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

Answers:


118

应该savePeople()进行单元测试吗?是。您不是在测试dataStore.savePerson有效的方法,db连接是否有效的方法,甚至不是foreach有效的方法。您正在测试savePeople是否可以通过合同履行承诺。

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


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

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

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

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

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

36

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

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

最后,测试不仅会检查您是否可以正确保存PersonStore,还可以检查很多人。这也将使我产生疑问,例如是否需要其他检查,例如“我是否需要确保该savePeople函数是原子性的,要么全部保存要么不保存?”,“它是否能以某种方式为无法返回的人员返回错误?”无法保存?这些错误看起来如何?”,依此类推。所有这些不仅仅只是检查是否使用a forEach或其他形式的迭代。

不过,如果只是在一次savePerson交付后立即节省一个以上的人的要求,那么我将更新现有的测试savePerson以运行新功能savePeople,以确保它仍然可以通过首先委派一个人来节省一个人,然后通过新的测试对一个以上的人的行为进行驱动测试,思考是否有必要使该行为成为原子行为。


4
基本上测试接口,而不是实现。
史努比(Snoop)

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

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

感谢更新。您将如何测试savePeople?正如我在OP的最后一段中所描述的那样?
约拿(Jonah)2016年

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

21

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()对use 实现的更改saveBulkPerson()就不会破坏单元测试saveBulkPerson()。而且,如果saveBulkPerson()某种方式无法按预期工作,则您的单元测试捕获该问题。

还是这种测试相当于测试内置的forEach语言构造?

如前所述,尝试测试预期结果和功能接口,而不是实现(除非您正在执行集成测试-然后捕获特定的函数调用可能有用)。如果有多种方法来实现功能,则所有这些方法都应与单元测试一起使用。

关于您的问题更新:

测试状态更改!例如,将使用一些面团。根据您的实现,断言已使用的量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记录到数据存储中
  • 它委派了遇到的任何异常/错误(如果有)

前3个检查可以使用dataStoreand的模拟,存根或伪造完成emailService(您确实不想在测试时发送电子邮件)。由于我必须查找一些注释,因此有以下区别:

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

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

预期的异常/错误是有效的测试用例:如果发生此类事件,您将确认该函数的行为与预期的一样。这可以通过在需要时让相应的模拟/伪/桩对象抛出来实现。

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

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

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

令人遗憾的是,在这种情况下,单元测试失去了部分信誉:由于更改了单元测试,因此更改后的功能与以前相同,因此无法确认功能。

例如,考虑有人oven.preheat()在您的Cookie烘焙示例中添加了一个对(优化!)的调用:

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

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


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

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

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

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

“让模拟程序将通过任何方法添加的所有内容存储在数组中,并检查测试数据(myPeople的元素)是否在数组中” –仍然是“真实的”数据库,只是临时的,内置的内存。“恕我直言,模拟仍应具有与真实对象相同的可观察行为” –我想您可以倡导这一点,但这并不是“模拟”在测试文献或任何我喜欢的流行库中的意思。已经看到。模拟仅验证使用预期参数调用的预期方法。
乔纳

13

这种测试提供的主要价值在于,它可以使您的实现具有可重构性。

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

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

使用适当的单元测试,您可以按照上面的实现方式编写它,如果发现需要对其进行优化,则可以使用自动测试的安全网自由地进行操作,以捕获任何错误。自然,这会根据测试的质量而有所不同,因此请进行自由测试并进行良好测试。

对该行为进行单元测试的第二个好处是可以用作其目的的文档。这个琐碎的例子可能是显而易见的,但是考虑到下一个要点,它可能非常重要。

其他人指出的第三个优点是,您可以测试掩盖的细节,这些细节很难通过集成或验收测试进行测试。例如,如果要求原子存储所有用户,那么您可以为此编写一个测试用例,这使您可以了解其行为是否符合预期,并且还可以作为该需求的文档给新开发者。

我将添加来自TDD老师的想法。不要测试该方法。测试行为。换句话说,您没有测试savePeople有效,您正在测试可以在一个呼叫中保存多个用户。

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


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

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

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

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

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

6

应该bakeCookies()测试吗?是。

假设您认为这样的功能应该如何进行单元测试?对于我来说,很难想象有任何一种单元测试都不能简单地模拟面团,锅和烤箱,然后断言在它们上调用了方法。

并不是的。仔细查看该函数应该执行的操作-应该将oven对象设置为特定状态。查看代码,似乎pandough对象的状态并没有多大关系。因此,您应该传递一个oven对象(或模拟它),并在函数调用结束时断言该对象处于特定状态。

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

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

单元测试具有两个功能:

  1. 它测试一切正常。这是单元测试提供的最不有用的功能,似乎您似乎仅在提出问题时才考虑使用此功能。

  2. 它检查以确保该程序的将来修改不会破坏以前实现的功能。这是单元测试中最有用的功能,它可以防止将错误引入大型程序。在向程序添加功能时,它在常规编码中很有用,但在重构和优化时(在不改变程序任何可观察行为的情况下,实现该程序的核心算法会发生巨大变化)则更有用。

不要测试函数内部的代码。而是测试该功能是否按照其声明的方式工作。当您以这种方式查看单元测试(测试功能,而不是代码)时,您将意识到,您永远不会测试语言构造甚至应用程序逻辑。您正在测试API。


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

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

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

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

3

是否应该对savePeople()进行单元测试,或者这种测试是否等同于测试内置的forEach语言构造?

是。但是您可以通过重新测试结构的方式做到这一点。

这里要注意的是savePerson中途失败时该函数的行为?应该如何运作?

是函数提供的那种微妙的行为,您应该使用单元测试来强制执行。


是的,我同意应该测试微妙的错误条件,但是imo并不是一个有趣的问题-答案很明确。因此,出于我的问题,我特别声明的理由savePeople不应对错误处理负责。再次澄清一下,假设savePeople对象负责遍历列表并将每个项目的保存委托给另一种方法,是否仍应进行测试?
约拿(Jonah)2016年

@Jonah:如果您要坚持仅将单元测试限制在foreach结构上,而不在结构之外进行任何条件,副作用或行为,那么您是对的;新的单元测试真的不是那么有趣。
罗伯特·哈维

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

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

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

3

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

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

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

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


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

1

是否应该对savePeople()进行单元测试,或者这种测试是否等同于测试内置的forEach语言构造?

@BryanOakley已经回答了这个问题,但是我有一些额外的争论(我想):

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

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

第三,在TDD方法(强制性测试)和外部测试中实施琐碎的测试是有价值的。

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


1

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

如何在不进行集成测试的情况下对void函数进行单元测试?

例如,如果我们将您的cookie烘焙功能更改为返回cookie,则应该立即进行测试。

如果必须在调用函数后调用pan.GetCookies,尽管我们可以质疑它是“真正的集成测试”还是“但是我们只是测试pan对象?”

我认为您是对的,因为可以对所有内容进行模拟的单元测试以及仅检查函数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或集成测试的优势。


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

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

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

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

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

0

您还应该测试bakeCookies-例如/应该bakeCookies(egg, pan, oven)产生什么?煎蛋还是例外?自己pan也不oven关心实际成分,因为它们都不应该,但bakeCookies通常应该产生cookie。更一般地可以取决于如何dough获得以及是否存在它成为纯粹的任何机会,egg或如water来代替。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.