代码之家  ›  专栏  ›  技术社区  ›  Steve Steiner

单元测试期间调试断言的最佳实践

  •  41
  • Steve Steiner  · 技术社区  · 16 年前

    单元测试的大量使用会阻碍调试断言的使用吗?似乎在测试代码中的调试断言意味着单元测试不应该存在,或者调试断言不应该存在。只有一个“似乎是合理的原则。这是常见的做法吗?还是在单元测试时禁用调试断言,以便它们可以用于集成测试?

    编辑:我将“Assert”更新为debug Assert,以便将测试代码中的Assert与运行测试后检查状态的单元测试中的行区分开来。

    这里还有一个例子,我相信它展示了迪勒玛: 单元测试通过一个保护函数的无效输入,该函数断言其输入是有效的。单元测试不应该存在吗?这不是公共职能。也许检查输入会杀死perf?或者断言不应该存在?该函数受保护,而不是私有的,因此它应该检查其输入的安全性。

    11 回复  |  直到 16 年前
        1
  •  37
  •   Alex Humphrey    15 年前

    这是一个完全正确的问题。

    首先,很多人认为你的断言是错误的。我想很多调试专家会不同意。虽然用断言检查不变量是一个好的实践,但是断言不应该局限于状态不变量。事实上,除了检查不变量外,许多专家调试器会告诉您断言任何可能导致异常的条件。

    例如,请考虑以下代码:

    if (param1 == null)
        throw new ArgumentNullException("param1");
    

    没关系。但是,当抛出异常时,堆栈将被解除绑定,直到有人处理异常(可能是某个顶级默认处理程序)。如果执行暂停在那一点(你可能在Windows应用程序中有一个模式异常对话框),你有机会附加一个调试器,但是你可能已经丢失了很多本来可以帮助你解决这个问题的信息,因为大多数堆栈都已解除。

    现在考虑一下:

    if (param1 == null)
    {
        Debug.Fail("param1 == null");
        throw new ArgumentNullException("param1");
    }
    

    现在,如果出现问题,就会弹出模态断言对话框。执行立即暂停。您可以自由地附加所选的调试器,并精确地调查堆栈上的内容以及系统在确切故障点的所有状态。在发布版本中,仍然会有一个异常。

    现在我们如何处理您的单元测试?

    考虑一个单元测试,它测试上面包含断言的代码。要检查当param1为空时是否引发异常。您希望该特定断言失败,但任何其他断言失败都将表明有问题。您希望允许特定测试的特定断言失败。

    你解决这个问题的方法将取决于你使用的语言等。但是,如果您使用.NET,我有一些建议(我实际上还没有尝试过,但我以后会更新帖子):

    1. 检查Trace.Listeners。查找DefaultTraceListener的任何实例并将AssertUiEnabled设置为false。这将阻止弹出模式对话框。您也可以清除侦听器集合,但无论如何都无法进行跟踪。
    2. 写下你自己的TraceListener来记录断言。如何记录断言取决于您。记录失败消息可能不够好,因此您可能需要遍历堆栈以找到断言来自的方法,并将其也记录下来。
    3. 测试结束后,请检查发生的唯一断言失败是否是您所期望的。如果出现其他情况,则测试失败。

    例如,一个TraceListener包含了执行类似堆栈遍历的代码,我将搜索supersert.NET的supersertListener并检查其代码。(如果您对使用断言进行调试非常认真,那么集成SUPERASSERT.NET也是值得的)。

    大多数单元测试框架都支持测试设置/拆卸方法。您可能需要添加代码来重置跟踪侦听器,并断言在这些区域中没有任何意外的断言失败,以最小化重复并防止错误。

    更新:

    下面是一个可以用于单元测试断言的TraceListener示例。您应该向Trace.Listeners集合添加一个实例。您可能还想提供一些简单的方法,让您的测试能够抓住监听器。

    注:这要归功于John Robbins的supersert.NET。

    /// <summary>
    /// TraceListener used for trapping assertion failures during unit tests.
    /// </summary>
    public class DebugAssertUnitTestTraceListener : DefaultTraceListener
    {
        /// <summary>
        /// Defines an assertion by the method it failed in and the messages it
        /// provided.
        /// </summary>
        public class Assertion
        {
            /// <summary>
            /// Gets the message provided by the assertion.
            /// </summary>
            public String Message { get; private set; }
    
            /// <summary>
            /// Gets the detailed message provided by the assertion.
            /// </summary>
            public String DetailedMessage { get; private set; }
    
            /// <summary>
            /// Gets the name of the method the assertion failed in.
            /// </summary>
            public String MethodName { get; private set; }
    
            /// <summary>
            /// Creates a new Assertion definition.
            /// </summary>
            /// <param name="message"></param>
            /// <param name="detailedMessage"></param>
            /// <param name="methodName"></param>
            public Assertion(String message, String detailedMessage, String methodName)
            {
                if (methodName == null)
                {
                    throw new ArgumentNullException("methodName");
                }
    
                Message = message;
                DetailedMessage = detailedMessage;
                MethodName = methodName;
            }
    
            /// <summary>
            /// Gets a string representation of this instance.
            /// </summary>
            /// <returns></returns>
            public override string ToString()
            {
                return String.Format("Message: {0}{1}Detail: {2}{1}Method: {3}{1}",
                    Message ?? "<No Message>",
                    Environment.NewLine,
                    DetailedMessage ?? "<No Detail>",
                    MethodName);
            }
    
            /// <summary>
            /// Tests this object and another object for equality.
            /// </summary>
            /// <param name="obj"></param>
            /// <returns></returns>
            public override bool Equals(object obj)
            {
                var other = obj as Assertion;
    
                if (other == null)
                {
                    return false;
                }
    
                return
                    this.Message == other.Message &&
                    this.DetailedMessage == other.DetailedMessage &&
                    this.MethodName == other.MethodName;
            }
    
            /// <summary>
            /// Gets a hash code for this instance.
            /// Calculated as recommended at http://msdn.microsoft.com/en-us/library/system.object.gethashcode.aspx
            /// </summary>
            /// <returns></returns>
            public override int GetHashCode()
            {
                return
                    MethodName.GetHashCode() ^
                    (DetailedMessage == null ? 0 : DetailedMessage.GetHashCode()) ^
                    (Message == null ? 0 : Message.GetHashCode());
            }
        }
    
        /// <summary>
        /// Records the assertions that failed.
        /// </summary>
        private readonly List<Assertion> assertionFailures;
    
        /// <summary>
        /// Gets the assertions that failed since the last call to Clear().
        /// </summary>
        public ReadOnlyCollection<Assertion> AssertionFailures { get { return new ReadOnlyCollection<Assertion>(assertionFailures); } }
    
        /// <summary>
        /// Gets the assertions that are allowed to fail.
        /// </summary>
        public List<Assertion> AllowedFailures { get; private set; }
    
        /// <summary>
        /// Creates a new instance of this trace listener with the default name
        /// DebugAssertUnitTestTraceListener.
        /// </summary>
        public DebugAssertUnitTestTraceListener() : this("DebugAssertUnitTestListener") { }
    
        /// <summary>
        /// Creates a new instance of this trace listener with the specified name.
        /// </summary>
        /// <param name="name"></param>
        public DebugAssertUnitTestTraceListener(String name) : base()
        {
            AssertUiEnabled = false;
            Name = name;
            AllowedFailures = new List<Assertion>();
            assertionFailures = new List<Assertion>();
        }
    
        /// <summary>
        /// Records assertion failures.
        /// </summary>
        /// <param name="message"></param>
        /// <param name="detailMessage"></param>
        public override void Fail(string message, string detailMessage)
        {
            var failure = new Assertion(message, detailMessage, GetAssertionMethodName());
    
            if (!AllowedFailures.Contains(failure))
            {
                assertionFailures.Add(failure);
            }
        }
    
        /// <summary>
        /// Records assertion failures.
        /// </summary>
        /// <param name="message"></param>
        public override void Fail(string message)
        {
            Fail(message, null);
        }
    
        /// <summary>
        /// Gets rid of any assertions that have been recorded.
        /// </summary>
        public void ClearAssertions()
        {
            assertionFailures.Clear();
        }
    
        /// <summary>
        /// Gets the full name of the method that causes the assertion failure.
        /// 
        /// Credit goes to John Robbins of Wintellect for the code in this method,
        /// which was taken from his excellent SuperAssertTraceListener.
        /// </summary>
        /// <returns></returns>
        private String GetAssertionMethodName()
        {
    
            StackTrace stk = new StackTrace();
            int i = 0;
            for (; i < stk.FrameCount; i++)
            {
                StackFrame frame = stk.GetFrame(i);
                MethodBase method = frame.GetMethod();
                if (null != method)
                {
                    if(method.ReflectedType.ToString().Equals("System.Diagnostics.Debug"))
                    {
                        if (method.Name.Equals("Assert") || method.Name.Equals("Fail"))
                        {
                            i++;
                            break;
                        }
                    }
                }
            }
    
            // Now walk the stack but only get the real parts.
            stk = new StackTrace(i, true);
    
            // Get the fully qualified name of the method that made the assertion.
            StackFrame hitFrame = stk.GetFrame(0);
            StringBuilder sbKey = new StringBuilder();
            sbKey.AppendFormat("{0}.{1}",
                                 hitFrame.GetMethod().ReflectedType.FullName,
                                 hitFrame.GetMethod().Name);
            return sbKey.ToString();
        }
    }
    

    您可以在每个测试开始时将断言添加到AllowedFailures集合中,以获得您期望的断言。

    在每个测试结束时(希望您的单元测试框架支持一个测试分解方法)执行以下操作:

    if (DebugAssertListener.AssertionFailures.Count > 0)
    {
        // TODO: Create a message for the failure.
        DebugAssertListener.ClearAssertions();
        DebugAssertListener.AllowedFailures.Clear();
        // TODO: Fail the test using the message created above.
    }
    
        2
  •  13
  •   Avi    11 年前

    IMHO debug.asserts摇滚。这个 great article 演示如何通过将app.config添加到单元测试项目并禁用对话框来阻止它们中断单元测试:

    <?xml version="1.0" encoding="utf-8"?>
    <configuration>
    <system.diagnostics>
        <assert assertuienabled="false"/>
    </system.diagnostics>
    

        3
  •  7
  •   Charlie Martin    16 年前

    您的代码中的断言是(应该是)对读者说“这个条件在这一点上应该总是正确的”的语句。通过一些规则,它们可以成为确保代码正确的一部分;大多数人将它们用作调试打印语句。单元测试是 演示 您的代码正确地执行了一个特定的测试用例;当然,它们可以同时记录需求,并提高您对代码确实正确的信心。

    明白区别了吗?程序断言可以帮助您纠正错误,单元测试可以帮助您建立其他人对代码正确的信心。

        4
  •  7
  •   Orion Edwards    7 年前

    正如其他人所提到的,调试断言意味着 应该永远是真的 . (这个词的意思是 不变性 ).

    如果您的单元测试传递的是使断言失败的虚假数据,那么您必须问自己一个问题-为什么会发生这种情况?

    • 如果被测函数是 想象上的 为了处理虚假数据,那么很明显,断言不应该存在。
    • 如果函数是 具备处理此类数据的能力(如assert所示),那么为什么要对其进行单元测试?

    第二点是相当多的开发人员似乎陷入了困境。单元测试检查您的代码要处理的所有事情,并断言或抛出其他所有事情的异常-毕竟,如果您的代码不是为处理这些情况而构建的,并且您导致了这些情况的发生,您希望发生什么?
    你知道C/C++文档中那些关于“未定义行为”的部分吗?就这样。保释和保释。


    更新以澄清:另一方面,你最终意识到你只应该使用 Debug.Assert 因为内在的东西叫其他内在的东西。 如果您的代码暴露给第三方(即,它是一个库或其他东西),那么您可以期望的输入是没有限制的,因此您应该正确地验证并抛出异常或其他什么,您还应该对此进行单元测试

        5
  •  2
  •   Andrew Grant    16 年前

    一个好的单元测试设置将能够捕获断言。如果触发断言,则当前测试应失败,并运行下一个测试。

    在我们的库中,低级调试功能(如TTY/ASSERTS)具有调用的处理程序。默认处理程序将打印f/break,但客户端代码可以为不同的行为安装自定义处理程序。

    我们的UnitTest框架安装了自己的处理程序,用于记录消息并对断言抛出异常。然后,UnitTest代码将捕获这些异常(如果发生),并将它们与断言的语句一起记录为失败。

    您还可以在单元测试中包括断言测试-例如。

    CHECK_ASSERT(someList.getAt(someList.size()+1);//ASSERT发生时测试通过

        6
  •  1
  •   duffymo    16 年前

    您是指“通过契约编程”的断言,还是CPPUnit/JUnit断言的C++/Java断言?最后一个问题让我相信是前者。

    有趣的问题,因为据我所知,当您部署到生产环境时,这些断言通常在运行时关闭。(这有点违背目的,但那是另一个问题。)

    我认为在测试代码时应该将它们留在代码中。您编写测试以确保适当地实施这些前提条件。测试应该是一个“黑盒”;测试时您应该充当类的客户机。如果您碰巧在生产中关闭了它们,它不会使测试无效。

        7
  •  1
  •   philant    16 年前

    首先要有两个按合同设计的断言 单元测试,您的单元测试框架应该能够捕获断言。如果您的单元测试由于DbC中止而中止,那么您就不能运行它们。这里的替代方法是在运行(读编译)单元测试时禁用这些断言。

    既然您正在测试非公共函数,那么使用无效参数调用函数的风险是什么?你的单元测试不包括这个风险吗?如果您按照TDD(测试驱动开发)技术编写代码,他们应该这样做。

    如果您真的希望/需要代码中的那些Dbc类型断言,那么可以删除将无效参数传递给具有这些断言的方法的单元测试。

    但是,当您有粗粒度的单元测试时,Dbc类型断言在较低级别的函数(单元测试不直接调用)中可能很有用。

        8
  •  1
  •   Faisal Memon    12 年前

    即使单元测试已经就绪,也应该保留调试断言。

    这里的问题不是区分错误和问题。

    如果函数检查错误的参数,则不应导致调试断言。相反,它应该返回一个错误值。用错误的参数调用函数是错误的。

    如果传递给函数的数据正确,但由于运行时内存不足而无法正常运行,则代码应为此问题发出调试断言。这是一个基本假设的例子,如果它们不成立,“所有的赌注都结束了”,所以你必须终止。

    在您的情况下,请编写单元测试,以提供错误的值作为参数。它应该期望一个错误返回值(或类似的值)。得到一个断言?--重构代码以产生错误。

    注意,无缺陷问题仍然会触发断言;例如,硬件可能会损坏。在您的问题中,您提到了集成测试;事实上,断言不正确组合的集成系统是断言领域;例如,加载了不兼容的库版本。

    注意,“debug”断言的原因是勤奋/安全和快速/小型之间的权衡。

        9
  •  0
  •   Paul Kohler    15 年前

    就像其他人提到的那样 Debug.Assert 声明应该总是 真的 ,即使参数不正确,断言也应为true以阻止应用程序进入无效状态等。

    Debug.Assert(_counter == somethingElse, "Erk! Out of wack!");
    

    你不应该测试这个(可能也不想,因为你真的无能为力!)

    我可能有点离题,但我的印象是,也许你所说的断言更适合作为“论点例外”,例如。

    if (param1 == null)
      throw new ArgumentNullException("param1", "message to user")
    

    代码中的那种“断言”仍然是可以测试的。

    主键:-)

        10
  •  0
  •   DotBert    8 年前

    问这个问题已经有一段时间了,但是我想我有一种不同的方法来验证来自使用C代码的单元测试中的Debug.Assert()调用。注意 #if DEBUG ... #endif 在调试配置中不运行时跳过测试所需的块(在这种情况下,无论如何都不会触发debug.Assert())。

    [TestClass]
    [ExcludeFromCodeCoverage]
    public class Test
    {
        #region Variables              |
    
        private UnitTestTraceListener _traceListener;
        private TraceListenerCollection _originalTraceListeners;
    
        #endregion
    
        #region TestInitialize         |
    
        [TestInitialize]
        public void TestInitialize() {
            // Save and clear original trace listeners, add custom unit test trace listener.
            _traceListener = new UnitTestTraceListener();
            _originalTraceListeners = Trace.Listeners;
            Trace.Listeners.Clear();
            Trace.Listeners.Add(_traceListener);
    
            // ... Further test setup
        }
    
        #endregion
        #region TestCleanup            |
    
        [TestCleanup]
        public void TestCleanup() {
            Trace.Listeners.Clear();
            Trace.Listeners.AddRange(_originalTraceListeners);
        }
    
        #endregion
    
        [TestMethod]
        public void TheTestItself() {
            // Arrange
            // ...
    
            // Act
            // ...
            Debug.Assert(false, "Assert failed");
    
    
    
        // Assert
    
    #if DEBUG        
        // NOTE This syntax comes with using the FluentAssertions NuGet package.
        _traceListener.GetWriteLines().Should().HaveCount(1).And.Contain("Fail: Assert failed");
    #endif
    
        }
    }
    

    UnitTestTraceListener类如下所示:

    [ExcludeFromCodeCoverage]
    public class UnitTestTraceListener : TraceListener
    {
        private readonly List<string> _writes = new List<string>();
        private readonly List<string> _writeLines = new List<string>();
    
        // Override methods
        public override void Write(string message)
        {
            _writes.Add(message);
        }
    
        public override void WriteLine(string message)
        {
            _writeLines.Add(message);
        }
    
        // Public methods
        public IEnumerable<string> GetWrites()
        {
            return _writes.AsReadOnly();
        }
    
        public IEnumerable<string> GetWriteLines()
        {
            return _writeLines.AsReadOnly();
        }
    
        public void Clear()
        {
            _writes.Clear();
            _writeLines.Clear();
        }
    }
    
        11
  •  0
  •   tekHedd    7 年前

    大量使用单元测试会阻碍调试断言的使用吗?

    不,恰恰相反。单元测试通过在运行您编写的白盒测试时对内部状态进行双重检查,使调试断言更有价值。在单元测试期间启用Debug.Assert非常重要,因为很少发布启用调试的代码(除非性能根本不重要)。调试代码只有两次是在以下情况下运行的:1)进行真正的集成测试(除了良好的意图外),2)运行单元测试。

    使用Debug.Assert测试检查不变量很容易。当单元测试运行时,这些检查充当健全性检查。

    Assert所做的其他事情是指向代码中出错的第一个点。这可以大大减少单元测试时的调试时间 找出问题所在。

    这会增加单元测试的值。

    似乎在测试代码中的调试断言意味着单元测试不应该存在,或者调试断言不应该存在。

    恰到好处。这个问题是关于真实发生的事情。正确的?因此,您需要在代码中使用调试断言,并且需要在单元测试期间触发它们。调试断言可能在单元测试期间触发的可能性清楚地表明,调试断言应该在单元测试期间启用。

    断言触发意味着要么您的测试不正确地使用了您的内部代码(并且应该被修复),要么测试中的某些代码不正确地调用了其他内部代码,或者某个基本假设是错误的。你不写测试,因为你认为你的假设是错误的,你。。。事实上,你知道。你写测试是因为至少你的一些假设可能是错误的。在这种情况下冗余是可以的。

    “只能有一个”似乎是一个合理的原则。这是常见的做法吗?还是在单元测试时禁用调试断言,以便它们可以用于集成测试?

    冗余只会影响单元测试的运行时间。如果你 真正地 有100%的覆盖率,运行时可能是一个问题。否则,我强烈反对。在测试过程中自动检查假设没有错。这实际上就是“测试”的定义。

    这里还有一个例子,我相信它展示了dilema:一个单元测试通过了一个保护函数的无效输入,这个保护函数断言它的输入是有效的。单元测试不应该存在吗?这不是公共职能。也许检查输入会杀死perf?或者断言不应该存在?该函数受保护,而不是私有的,因此它应该检查其输入的安全性。

    通常,单元测试框架的目的并不是在违背不变假设的情况下测试代码的行为。换言之,如果您编写的文档中说“如果将null作为参数传递,则结果未定义”,则无需验证结果是否确实不可预测。如果故障结果定义得很清楚,那么它们就不是未定义的,并且1)它不应该是Debug.Assert,2)您应该确切地定义结果是什么,以及3)测试结果。如果您需要对内部调试断言的质量进行单元测试,那么1)Andrew Grant将断言框架作为可测试资产的方法可能应该作为答案进行检查,2)哇,您的测试覆盖率真棒!我认为这很大程度上是基于项目需求的个人决定。但我仍然认为调试断言是必要的和有价值的。

    换句话说:Debug.Assert()大大增加了单元测试的值,冗余是一个特性。