在一个单元测试中可以有多个断言吗?


396

在对此出色文章的评论中,Roy Osherove提到了OAPT项目,该项目旨在在单个测试中运行每个断言。

以下内容写在项目的主页上:

正确的单元测试应该仅出于一个原因而失败,这就是为什么您应该在每个单元测试中使用一个断言。

而且,罗伊(Roy)在评论中写道:

我的指导原则通常是每次测试都测试一个逻辑概念。您可以在同一对象上具有多个断言 。它们通常是要测试的相同概念。

我认为在某些情况下需要多个断言(例如Guard Assertion),但总的来说,我尝试避免这种情况。你有什么意见?请提供一个真实的示例,其中确实需要多个断言。


2
在没有多个断言的情况下如何进行模拟?对模拟的每个期望本身就是一个断言,包括您施加的任何调用顺序。
Christopher Creutzig 2013年

15
我已经看到了过去滥用一种方法的哲学。一个老同事使用时髦的继承机制来实现这一点。它导致了许多子类(每个分支一个)和许多测试,这些测试执行相同的设置/拆卸过程,只是为了检查不同的结果。它运行缓慢,难以阅读,并且存在严重的维护问题。我从未说服他改回更经典的方法。Gerard Meszaros的书详细讨论了这个主题。
特拉维斯公园公园

3
我认为,作为一般经验法则,您应尽量减少每次测试的断言数量。但是,只要测试足以将问题缩小到代码中的特定位置,那么它就是有用的测试。
ConditionRacer

2
我已经看到了使用多个断言而不是RowTest(MbUnit)/ TestCase(NUnit)来测试各种边缘情况行为的情况。使用适当的工具完成工作!(不幸的是,MSTest似乎还没有行测试功能。)
GalacticCowboy

@GalacticCowboy您可以获取RowTestTestCase使用测试数据源的类似功能。我正在使用非常成功的简单CSV文件。
julealgon 2014年

Answers:


236

我认为这不一定是一件坏事,但我确实认为我们应该努力在测试中只包含单个断言。这意味着您编写了更多的测试,而我们的测试最终一次只能测试一件事。

话虽如此,我可能会说一半的测试实际上只有一个断言。我认为只有在您的测试中有大约五个以上断言时,它才会变成代码(测试?)的味道

您如何解决多个断言?


9
像这样的答案-即可以,但在一般情况下则不好(-:
Murph 2010年

5
嗯 为什么要这么做?方法执行完全一样吗?
jgauffin 2012年

197
每个单元测试一个断言是测试读者向上和向下滚动能力的好方法。
汤姆(Tom)

3
这背后有什么理由吗?照原样,此当前答案仅说明了应该是什么,但没有说明原因。
史蒂文·杰里斯

37
强烈反对。答案没有列出具有单个断言的任何优点,而明显的缺点是仅由于Internet上的答案如此,您才需要复制粘贴测试。如果您需要测试一个结果的多个字段或单个操作的多个结果,则绝对应该在独立的断言中声明所有这些字段,因为与在大的Blob声明中测试它们相比,它提供了更多有用的信息。在一个好的测试中,您将测试单个操作,而不是单个操作的结果。
彼得(Peter

295

测试仅应出于一个原因而失败,但这并不总是意味着仅应有一个Assert语句。恕我直言,保持“ 安排,行动,断言 ”模式更为重要。

关键是您只有一个动作,然后使用断言检查该动作的结果。但这是“安排,行动,断言,测试结束 ”。如果您想通过执行其他操作来继续测试,然后再进行更多声明,请改为进行单独的测试。

我很高兴看到多个assert语句构成测试同一动作的一部分。例如

[Test]
public void ValueIsInRange()
{
  int value = GetValueToTest();

  Assert.That(value, Is.GreaterThan(10), "value is too small");
  Assert.That(value, Is.LessThan(100), "value is too large");
} 

要么

[Test]
public void ListContainsOneValue()
{
  var list = GetListOf(1);

  Assert.That(list, Is.Not.Null, "List is null");
  Assert.That(list.Count, Is.EqualTo(1), "Should have one item in list");
  Assert.That(list[0], Is.Not.Null, "Item is null");
} 

可以将它们组合成一个断言,但这与坚持您应该必须做的事情不同。合并它们没有任何改善。

例如第一个可能

Assert.IsTrue((10 < value) && (value < 100), "Value out of range"); 

但这并不是更好-错误消息的针对性较低,并且没有其他优点。我敢肯定,您还会想到其他将两个或三个(或更多)断言组合为一个大布尔条件的示例,这使得它更难阅读,更难更改且更难以弄清失败的原因。为什么仅仅为了规则而这样做呢?

注意:我在这里编写的代码是带有NUnit的C#,但是这些原理将适用于其他语言和框架。语法也可能非常相似。


32
关键是您只有一个动作,然后使用断言检查该动作的结果。
阿弥陀佛(Amitābha),2015年

1
我认为,如果Arrange耗费时间,则拥有更多的Assert也很有趣。
Rekshino

1
@Rekshino,如果安排很费时间,我们可以共享安排代码,例如,通过将安排代码放入测试初始化​​例程中。
Shaun Luttin

2
因此,如果将我与Jaco的答案进行比较,“只有一个断言”就变成“只有一组断言”,这对我来说更有意义。
Walfrat '18

2
这是一个很好的答案,但我不同意一个断言并不更好。错误的断言示例不会更好,但这并不意味着如果正确执行一个断言也不会更好。许多库都允许使用自定义断言/匹配器,因此可以创建一些尚不存在的东西。例如Assert.IsBetween(10, 100, value),打印Expected 8 to be between 10 and 100 优于两个独立的断言在我看来。您当然可以争辩说这不是必须的,但是通常值得考虑在将它们全部归并之前将其简化为单个断言是否容易。
Thor84no

85

我从来没有想过有一个以上的断言是一件坏事。

我一直在做:

public void ToPredicateTest()
{
    ResultField rf = new ResultField(ResultFieldType.Measurement, "name", 100);
    Predicate<ResultField> p = (new ConditionBuilder()).LessThanConst(400)
                                                       .Or()
                                                       .OpenParenthesis()
                                                       .GreaterThanConst(500)
                                                       .And()
                                                       .LessThanConst(1000)
                                                       .And().Not()
                                                       .EqualsConst(666)
                                                       .CloseParenthesis()
                                                       .ToPredicate();
    Assert.IsTrue(p(ResultField.FillResult(rf, 399)));
    Assert.IsTrue(p(ResultField.FillResult(rf, 567)));
    Assert.IsFalse(p(ResultField.FillResult(rf, 400)));
    Assert.IsFalse(p(ResultField.FillResult(rf, 666)));
    Assert.IsFalse(p(ResultField.FillResult(rf, 1001)));

    Predicate<ResultField> p2 = (new ConditionBuilder()).EqualsConst(true).ToPredicate();

    Assert.IsTrue(p2(new ResultField(ResultFieldType.Confirmation, "Is True", true)));
    Assert.IsFalse(p2(new ResultField(ResultFieldType.Confirmation, "Is False", false)));
}

在这里,我使用多个断言来确保可以将复杂的条件转换为预期的谓词。

我仅测试一个单元(ToPredicate方法),但涵盖了我在测试中想到的所有内容。


46
由于错误检测,多个断言是不好的。如果您的第一个Assert.IsTrue失败,则其他断言将不会执行,并且您也不会从中获取任何信息。另一方面,如果您用5次断言而不是5次测试可以得到一些有用的信息
Sly

6
如果所有断言都测试同一种功能,您是否认为这仍然很糟糕?像上面一样,该示例测试条件,如果其中任何一个失败,则应对其进行修复。如果前一个断言失败,您可能会错过最后两个断言对您来说是否重要?
畏缩

106
我一次解决我的问题。因此,测试可能不止一次失败的事实并不困扰我。如果我将它们分开,则会出现相同的错误,但是会同时出现。我发现一次更容易解决问题。我承认,在这种情况下,最后两个断言可能可以重构为自己的测试。
马特·艾伦

19
您的案例很有代表性,这就是为什么NUnit具有附加属性TestCase的原因-nunit.org/?p=testCase&r=2.5
Restuta 2010年

10
google C ++测试框架具有ASSERT()和EXPECT()。ASSERT()在失败时停止,而EXPECT()继续。当您想在测试中验证多个内容时,这非常方便。
ratkok 2011年

21

当我使用单元测试来验证高级行为时,我绝对将多个断言放入单个测试中。这是我实际上用于一些紧急通知代码的测试。测试之前运行的代码会将系统置于一种状态,在这种状态下,如果运行主处理器,则会发送警报。

@Test
public void testAlarmSent() {
    assertAllUnitsAvailable();
    assertNewAlarmMessages(0);

    pulseMainProcessor();

    assertAllUnitsAlerting();
    assertAllNotificationsSent();
    assertAllNotificationsUnclosed();
    assertNewAlarmMessages(1);
}

它代表了过程中每个步骤都需要存在的条件,以使我确信代码的行为符合我的期望。如果一个断言失败,我不在乎其余的断言甚至不会被执行。因为系统状态不再有效,所以这些后续的断言将不会告诉我任何有价值的信息。*如果assertAllUnitsAlerting()失败,那么assertAllNotificationSent()在确定导致先前错误的原因之前,我不知道如何处理成功或失败并纠正它。

(*-好吧,可以想象它们可能在调试问题时很有用。但是,已经收到了测试失败的最重要信息。)


当您这样做时,最好将测试框架与相关测试一起使用,这样会更好(例如testng支持此功能)
Kemoda 2012年

8
我也这样编写测试,以便您可以确信代码的功能和状态更改,我认为这不是单元测试,而是集成测试。
mdma

您对将其重构为assertAlarmStatus(int numberOfAlarmMessages);有何看法?
Borjab

1
您的断言将为您提供非常好的测试名称。
比约恩·蒂普林

最好让测试运行,即使输入无效也是如此。它以这种方式为您提供了更多信息(尤其是当您不希望它通过时仍然通过)。
CurtainDog

8

我认为另一个方法中的多个断言不是一件坏事的另一个原因在以下代码中进行了描述:

class Service {
    Result process();
}

class Result {
    Inner inner;
}

class Inner {
    int number;
}

在我的测试中,我只想测试service.process()Inner类实例中返回正确的数字。

而不是测试...

@Test
public void test() {
    Result res = service.process();
    if ( res != null && res.getInner() != null ) Assert.assertEquals( ..., res.getInner() );
}

我正在做

@Test
public void test() {
    Result res = service.process();
    Assert.notNull(res);
    Assert.notNull(res.getInner());
    Assert.assertEquals( ..., res.getInner() );
}

2
这是一件好事,您的测试中不应包含任何条件逻辑。它使测试更加复杂且可读性较差。我猜罗伊(Roy)在他的博客文章中概述了这一点,即多数情况下,对一个对象进行多次断言是可以的。因此,您拥有的只是警惕的主张,可以拥有它们。
Restuta 2012年

2
Assert.notNull的是多余的,如果测试为空,则测试将失败并显示NPE。
萨拉

1
此外,if如果res是,您的第一个示例(带有)将通过null
sara

1
@kai notNull是多余的,我同意,但是我认为拥有断言(并且如果我也不懒于发送适当的消息)而不是异常也更干净……
Betlista 2016年

1
失败的断言也会引发异常,在这两种情况下,您都将直接链接到通过附带的堆栈跟踪将其抛出的确切行,因此我个人不希望通过任何会被检查的前提条件来使测试混乱。我更希望使用onelineràla Assert.assertEquals(..., service.process().getInner());,如果行变得“太长”,则可以使用提取的变量
sara 2016年

6

我认为在很多情况下,编写多个断言在测试只能出于一个原因而失败的规则内是有效的。

例如,想象一个解析日期字符串的函数:

function testParseValidDateYMD() {
    var date = Date.parse("2016-01-02");

    Assert.That(date.Year).Equals(2016);
    Assert.That(date.Month).Equals(1);
    Assert.That(date.Day).Equals(0);
}

如果测试失败是由于一种原因,则说明解析不正确。如果您认为该测试可能由于三个不同的原因而失败,那么恕我直言,您对“一个原因”的定义可能太细粒度了。


3

我不知道在[Test]方法本身内部包含多个断言是一个好主意的任何情况。人们喜欢拥有多个断言的主要原因是他们正试图为每个要测试的类创建一个[TestFixture]类。相反,您可以将测试分为更多的[TestFixture]类。这使您可以查看多种方式,使代码可能无法按照预期的方式做出反应,而不仅仅是第一个断言失败的方式。实现此目的的方法是,每个类至少要有一个目录,并且其中包含许多[TestFixture]类。每个[TestFixture]类都将以您要测试的对象的特定状态命名。[SetUp]方法将使对象进入类名所描述的状态。然后,您将拥有多个[Test]方法,这些方法在给定对象的当前状态的情况下断言您期望为真的不同事物。每个[Test]方法均以其声明的事物命名,除非它可能以概念命名,而不仅仅是代码的英语读出。然后,每个[Test]方法实现都只需要声明一行内容的一行代码即可。这种方法的另一个优点是,通过查看类和方法名称,您可以很清楚地了解要测试的内容和期望的内容,从而使测试变得易于阅读。随着您开始意识到要测试的所有小巧情况以及发现错误,这也将更好地扩展。除了它可能以概念命名之外,而不仅仅是代码的英文读出。然后,每个[Test]方法实现都只需要声明一行内容的一行代码即可。这种方法的另一个优点是,通过查看类和方法名称,您可以很清楚地了解要测试的内容和期望的内容,从而使测试变得易于阅读。随着您开始意识到要测试的所有小巧情况以及发现错误,这也将更好地扩展。除了它可能以概念命名之外,而不仅仅是代码的英文读出。然后,每个[Test]方法实现都只需要声明一行内容的一行代码即可。这种方法的另一个优点是,通过查看类和方法名称,您可以很清楚地了解要测试的内容和期望的内容,从而使测试变得易于阅读。随着您开始意识到要测试的所有小巧情况以及发现错误,这也将更好地扩展。以及仅通过查看类和方法名称即可得到的结果。随着您开始意识到要测试的所有小巧情况以及发现错误,这也将更好地扩展。以及仅通过查看类和方法名称即可得到的结果。随着您开始意识到要测试的所有小巧情况以及发现错误,这也将更好地扩展。

通常,这意味着[SetUp]方法内的最后一行代码应在[TestFixture]的私有实例变量中存储属性值或返回值。然后,您可以使用不同的[Test]方法对此实例变量声明多个不同的内容。您还可以断言,既然处于期望状态,则将被测试对象的不同属性设置为什么。

有时,在使被测对象进入所需状态时,您需要沿途进行断言,以确保在使对象进入所需状态之前没有弄乱。在这种情况下,这些额外的断言应出现在[SetUp]方法内。如果[SetUp]方法内部出现问题,则很明显,在对象进入要测试的期望状态之前,测试存在问题。

您可能遇到的另一个问题是,您可能正在测试预期会引发的异常。这可能会诱使您不遵循上述模型。但是,仍然可以通过在[SetUp]方法内捕获异常并将其存储到实例变量中来实现。这将允许您断言关于异常的不同事物,每个事物都有其自己的[Test]方法。然后,您还可以声明有关被测对象的其他信息,以确保不会因抛出异常而产生意外副作用。

示例(这将分解成多个文件):

namespace Tests.AcctTests
{
    [TestFixture]
    public class no_events
    {
        private Acct _acct;

        [SetUp]
        public void SetUp() {
            _acct = new Acct();
        }

        [Test]
        public void balance_0() {
            Assert.That(_acct.Balance, Is.EqualTo(0m));
        }
    }

    [TestFixture]
    public class try_withdraw_0
    {
        private Acct _acct;
        private List<string> _problems;

        [SetUp]
        public void SetUp() {
            _acct = new Acct();
            Assert.That(_acct.Balance, Is.EqualTo(0));
            _problems = _acct.Withdraw(0m);
        }

        [Test]
        public void has_problem() {
            Assert.That(_problems, Is.EquivalentTo(new string[] { "Withdraw amount must be greater than zero." }));
        }

        [Test]
        public void balance_not_changed() {
            Assert.That(_acct.Balance, Is.EqualTo(0m));
        }
    }

    [TestFixture]
    public class try_withdraw_negative
    {
        private Acct _acct;
        private List<string> _problems;

        [SetUp]
        public void SetUp() {
            _acct = new Acct();
            Assert.That(_acct.Balance, Is.EqualTo(0));
            _problems = _acct.Withdraw(-0.01m);
        }

        [Test]
        public void has_problem() {
            Assert.That(_problems, Is.EquivalentTo(new string[] { "Withdraw amount must be greater than zero." }));
        }

        [Test]
        public void balance_not_changed() {
            Assert.That(_acct.Balance, Is.EqualTo(0m));
        }
    }
}

在这种情况下,如何处理TestCase输入?
西蒙·吉尔比

因此,在我目前的组织中,我们有20,000多个单元测试,与您显示的非常相似。这是一场噩梦。许多测试设置代码被复制/粘贴,导致错误的测试设置和无效的测试通过。对于每个[Test]方法,将重新实例化该类,然后[SetUp]再次执行该方法。这会杀死.NET垃圾收集器,并导致测试运行极其缓慢:本地运行5分钟以上,在构建服务器上运行20分钟以上。20K测试应在大约2-3分钟内运行。我根本不会推荐这种测试样式,尤其是对于大型测试套件。
fourpastmidnight

@fourpastmidnight您所说的大部分内容似乎都是有效的批评,但是关于复制和粘贴设置代码并因此出错的要点不是结构问题,而是不负责任的程序员(这可能是不负责任的经理或不利环境的结果)比差劲的程序员更多)。如果人们只是复制并粘贴代码并希望它是正确的并且不想打扰理解代码,那么无论出于任何原因,无论在任何情况下,他们都需要受过培训,不要这样做,或者如果不能这样做,就应该放手。训练有素。这违反了编程的每个原则。
still_dreaming_1

但总的来说,我同意,这是疯狂的过度杀伤力,将导致大量的膨胀/行李/重复,从而导致各种问题。我曾经很疯狂,推荐这样的东西。那就是我希望能够在前一天每天对自己说的话,因为那意味着我永远不会停止寻找更好的做事方法。
still_dreaming_1

@ still_dreaming_1 wrt“不负责任的程序员”:我同意这种行为是一个主要问题。但是,无论好坏,这种测试结构的确会引起这种行为。除了不良的开发实践,我对这种形式的主要反对意见是它确实会破坏测试性能。没有什么比运行缓慢的测试套件更糟糕的了。运行缓慢的测试套件意味着人们不会在本地运行测试,甚至会倾向于在中间版本上跳过它们(再次出现人员问题,但还是会发生),这可以通过确保启动快速运行的测试来避免用。
fourpastmidnight

2

只有在测试失败时,才在同一测试中具有多个断言。然后,您可能必须调试测试或分析异常,以找出失败的断言。在每个测试中只有一个断言,通常更容易找出问题所在。

我想不出确实需要多个断言的情况,因为您总是可以在同一断言中将它们重写为多个条件。但是,例如,如果您有几个步骤来验证步骤之间的中间数据,而不是冒着由于输入错误而使后面的步骤崩溃的风险,那么这可能是更可取的。


1
如果将多个条件组合为一个断言,那么一旦失败,您所知道的就是一个失败。使用多个断言,您将特别了解其中的一些断言(直到失败并包括故障在内)。考虑检查返回的数组是否包含单个值:检查它是否不为null,然后它恰好具有一个元素,然后是该元素的值。(取决于平台)仅立即检查该值可能会提供null解除引用(比null断言失败有用),并且不检查数组长度。
理查德

@Richard:获取结果,然后从结果中提取内容将是一个分几个步骤的过程,因此我在答案的第二段中进行了介绍。
Guffa

2
经验法则:如果您在测试中有多个断言,则每个断言都应有不同的消息。那么您就没有这个问题了。
安东尼

并且使用质量测试运行程序(例如NCrunch)将在测试代码和被测代码中准确显示测试失败的那一行。
fourpastmidnight

2

如果测试失败,您将不知道以下断言是否也会中断。通常,这意味着您会丢失有价值的信息以找出问题的根源。我的解决方案是使用一个断言但具有多个值:

String actual = "val1="+val1+"\nval2="+val2;
assertEquals(
    "val1=5\n" +
    "val2=hello"
    , actual
);

这使我可以立即查看所有失败的断言。我使用多行代码,因为大多数IDE会在比较对话框中并排显示字符串差异。


2

如果您在一个测试功能中有多个断言,我希望它们与您正在进行的测试直接相关。例如,

@Test
test_Is_Date_segments_correct {

   // It is okay if you have multiple asserts checking dd, mm, yyyy, hh, mm, ss, etc. 
   // But you would not have any assert statement checking if it is string or number,
   // that is a different test and may be with multiple or single assert statement.
}

进行大量测试(即使您觉得这可能是一个过大的考验)也不是一件坏事。您可以争辩说,进行至关重要且最基本的测试更为重要。因此,在进行断言时,请确保正确放置断言语句,而不必过多担心多个断言。如果需要多个,请使用多个。


1

单元测试的目的是为您提供有关失败原因的尽可能多的信息,而且还可以帮助您首先准确地找出最根本的问题。当您从逻辑上知道一个断言将因另一个断言失败而失败,或者换句话说,测试之间存在依赖关系时,将它们作为单个测试中的多个断言进行滚动是有意义的。这样做的好处是不会在测试结果中散布明显的故障,如果我们在一次测试中对第一个断言采取行动,则可以消除这些故障。在这种关系不存在的情况下,自然会优先选择将这些断言分离为单独的测试,因为否则,要找到这些失败将需要多次迭代运行才能解决所有问题。

如果您随后还以这样的方式设计单元/类,即需要编写过于复杂的测试,则可以减轻测试过程中的负担,并可能促进更好的设计。


1

是的,只要失败的测试为您提供足够的信息以能够诊断失败,就可以有多个断言。这将取决于您要测试的内容以及故障模式。

正确的单元测试应该仅出于一个原因而失败,这就是为什么您应该在每个单元测试中使用一个断言。

我从来没有发现这样的表述是有帮助的(类应该有一个改变的理由就是这样无益的格言的一个例子)。考虑两个字符串相等的断言,这在语义上等同于两个字符串的长度相同并且相应索引处的每个字符均相等的断言。

我们可以概括地说,任何多个断言的系统都可以重写为单个断言,并且任何单个断言都可以分解为一组较小的断言。

因此,仅关注代码的清晰度和测试结果的清晰度,并以此来指导您使用的断言数量,反之亦然。


0

答案很简单-如果您测试一个函数更改了同一对象甚至两个不同对象的多个属性,而该函数的正确性取决于所有这些更改的结果,那么您想断言所有这些更改均已正确执行!

我有一个逻辑概念的想法,但相反的结论是,任何函数都不能改变一个以上的对象。但是根据我的经验,这不可能在所有情况下都实现。

采取银行交易的逻辑概念-在大多数情况下,从一个银行帐户中提取金额必须包括将该金额添加到另一个帐户中。您永远都不想将这两件事分开,它们形成一个原子单元。您可能要创建两个函数(withdraw / addMoney),并另外编写两个不同的单元测试。但是,这两个动作必须在一个事务中进行,并且您还想确保该事务有效。在这种情况下,仅确保单个步骤成功是远远不够的。在测试中,您必须检查两个银行帐户。

可能会有更复杂的示例,您首先不会在单元测试中进行测试,而是在集成或验收测试中进行测试。但是这些边界是流畅的,恕我直言!决定并非那么容易,这取决于环境和个人喜好。从一个帐户中提取资金并将其添加到另一个帐户中仍然是一个非常简单的功能,并且绝对是单元测试的候选人。


-1

这个问题与意大利面条和千层面代码之间的平衡这一经典问题有关。

拥有多个断言可能很容易陷入意粉问题,而您对测试的内容一无所知,但每个测试只有一个断言可能会使您的测试同样难以理解,因为在一个宽面条中包含多个测试,使得发现哪个测试无法完成什么工作。

有一些例外,但是在这种情况下,将摆放在中间是解决方案。


-3

通常,我什至不同意“仅出于一个原因而失败”。更重要的是,测试要简短,并且可以清楚地读取imo。

但是,这并非总是可以实现的,并且当测试复杂时,使用(长)描述性名称,并且测试更少的内容更有意义。

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.