什么是好的单元测试?

97

我相信大多数人都在编写大量自动化测试,并且在单元测试时也遇到了一些常见的陷阱。

我的问题是,为了避免未来出现问题,你是否遵循编写测试的任何行为规范? 更具体地说:好的单元测试具有哪些属性或者你如何编写你的测试用例?

鼓励提供与编程语言无关的建议。

18个回答

93

让我首先推荐一些资源 - 《Java单元测试实践》(也有用C#-NUnit的版本..但我只有这个版本..在大部分情况下,它是中立的。推荐阅读)

好的测试应该是一个 TRIP(缩写不够粘性 - 我有一份书中的备忘单打印出来,以确保我没记错..)

  • 自动化:测试的调用和结果检查应该是自动的。
  • 全面:覆盖范围;虽然错误倾向于聚集在代码的某些区域周围,但要确保测试所有关键路径和情景。如果必要,使用工具来了解未经测试的区域。
  • 可重复:测试每次都应该产生相同的结果。测试不应依赖于不可控参数。
  • 独立性:非常重要。
    • 测试应该每次只测试一个功能。多个断言是可以接受的,只要它们都在测试一个特定的功能或行为。当测试失败时,它应该指出问题的位置。
    • 测试不应该相互依赖,必须保证隔离。不能假设测试执行的顺序。在每个测试之前使用适当的设置/拆卸方法确保“干净”的状态。
  • 专业化:从长远来看,你的测试代码将和生产代码一样多(如果不是更多),因此遵循同样的良好设计标准来编写测试代码。方法类应该被反映出其意图的名称分解得很好,没有重复,测试用例的命名良好等。

  • 好的测试也要运行。任何运行时间超过半秒的测试都需要加以改进。测试套件运行所需时间越长,就会越不经常运行。开发人员在测试之间尝试悄悄进行更改,如果出现问题,将需要更长时间才能找出是哪个更改造成了问题。

  • 更新于2010年08月:

    • 易读:这可以被认为是专业领域的一部分,但它不能被强调得足够多。一个酸性测试是找到一个不属于你团队的人,并让他/她在几分钟内确定测试行为。测试需要像生产代码一样维护-因此,即使需要更多的努力,使其易于阅读。测试应该对称(遵循一定的模式)和简洁(一次测试一个行为)。使用一致的命名约定(例如 TestDox 风格)。避免在测试中混杂“附带细节”,成为极简主义者。

    除此之外,大多数其他指南都可以削减低效的工作:例如,“不要测试您不拥有的代码”(例如第三方 DLL)。不要测试 getter 和 setter。关注成本效益比或缺陷概率。


    我们可能在使用 Mocks 方面存在分歧,但这篇文章很好地阐述了单元测试的最佳实践。 - Justin Standard
    我会将这个作为答案提升,因为我发现“一次旅行”缩写很有用。 - Spoike
    3
    我基本上同意,但是想指出测试非您所拥有的代码也有益处... 您正在测试它是否符合您的要求。否则,您如何确信升级不会破坏您的系统?(当然,在这样做时,请记住成本效益比。) - Disillusioned
    @Craig - 我相信你指的是(接口级别)回归测试(或某些情况下的学习者测试),它记录了您所依赖的行为。我不会为第三方代码编写“单元”测试,因为a.供应商比我更了解该代码b.供应商不受保留任何特定实现的约束。我无法控制对该代码库的更改,也不想花时间修复升级后的破损测试。因此,我宁愿编写一些高级回归测试以检测我使用的行为是否出现问题并得到通知。 - Gishu
    @Gishu:是的,绝对没错!测试必须仅在接口层面进行;实际上,你应该最多只测试你实际使用的功能。此外,在选择用什么编写这些测试时,我发现简单直接的“单元”测试框架通常非常适合。 - Disillusioned

    42
    1. 不要编写臃肿的测试代码。“单元测试”中的“单元”意味着尽可能将每个测试用例设计为原子化隔离的。如果必须编写辅助条件,应该使用mock对象,而不是手动重新创建太多典型用户环境的代码。
    2. 不要测试显然已经正确的功能。避免测试来自第三方供应商的类,特别是提供框架核心API的类。例如,不要测试向供应商的Hashtable类添加一项。
    3. 考虑使用代码覆盖率工具,例如NCover,以帮助发现您尚未测试的边缘情况。
    4. 试着在实现之前编写测试。将测试看作更像是规范,指导您进行实现。另外还有行为驱动开发(BDD),这是测试驱动开发的一个更具体的分支。
    5. 保持一致性。如果只对部分代码编写测试,那么测试几乎没有用处。如果你在团队中工作,而其他人并没有编写测试,那么测试也没有什么用处。说服自己和所有人测试的重要性(以及节省时间的属性),或者就不要浪费时间。

    1
    好的回答。但是如果在交付过程中不对所有内容进行单元测试,也不会那么糟糕。当然,这是可取的,但需要平衡和实用主义。关于让同事们加入;有时候你只需要这样做来证明价值并作为参考点。 - Martin Clarke
    1
    我同意。然而,从长远来看,你需要能够依赖测试的存在,即能够假设常见陷阱将被它们捕捉到。否则,其好处将大大减少。 - Sören Kuklau
    2
    如果你只为部分代码编写测试,那么它几乎没有用处。但是这真的是这样吗?我的一些项目只有20%的代码覆盖率(关键/易出错区域),它们对我非常有帮助,而且项目也很好。 - dr. evil
    1
    我同意Slough的观点。即使只有少数测试,只要它们编写得好并且足够隔离,它们将极大地帮助我们。 - Spoike

    41

    这里大多数答案似乎是涉及单元测试最佳实践的一般性问题(何时、何地、为什么和什么),而不是实际撰写测试本身的方法(如何)。由于问题似乎非常专注于“如何”部分,因此我想发布这篇文章,它来自于我在公司进行的“brown bag”演示。

    Womp的五个编写测试法则:


    1. 使用长而具有描述性的测试方法名称。

       - Map_DefaultConstructorShouldCreateEmptyGisMap()
       - ShouldAlwaysDelegateXMLCorrectlyToTheCustomHandlers()
       - Dog_Object_Should_Eat_Homework_Object_When_Hungry()
    

    2. 以安排/操作/断言的方式编写你的测试。

    • 虽然这种组织策略已经存在一段时间并且有很多称呼,但最近“AAA”缩写的引入是一个很好的方式来理解它。使所有测试都符合AAA风格使它们易于阅读和维护。

    3. 在你的Asserts中始终提供失败消息。

    Assert.That(x == 2 && y == 2, "An incorrect number of begin/end element 
    processing events was raised by the XElementSerializer");
    
    • 一种简单而有益的实践是在您的运行应用程序中明确显示失败的内容。如果您不提供消息,通常会在失败输出中获得类似于“期望为true,实际为false”的信息,这会使您不得不阅读测试以找出问题所在。

    4. 对测试进行注释 - 业务假设是什么?

      /// A layer cannot be constructed with a null gisLayer, as every function 
      /// in the Layer class assumes that a valid gisLayer is present.
      [Test]
      public void ShouldNotAllowConstructionWithANullGisLayer()
      {
      }
    
    • 这似乎很明显,但这种做法将保护您的测试完整性,以免那些一开始就不理解测试背后原因的人破坏或修改本来可以正常运行的测试。我见过很多这样的例子。
    • 如果测试是微不足道的或方法名已经足够描述,可以不必添加注释。

    5. 每个测试必须始终还原其触及的任何资源的状态

    • 尽可能使用模拟避免处理真实资源。
    • 清理必须在测试级别上完成。测试不能依赖于执行顺序。

    2
    +1 是因为点1、2和5很重要。如果你已经使用了具有描述性的测试方法名称,那么3和4似乎有些过分了,但是如果测试范围很大(功能或验收测试),我建议对测试进行文档记录。 - Spoike

    17

    请记住以下目标(改编自Meszaros的书xUnit Test Patterns)

    • 测试应该减少风险,而不是增加风险。
    • 测试应该易于运行。
    • 测试应该易于维护,因为系统围绕它们进行演化。

    为了使这更容易实现:

    • 测试应该只因一个原因而失败。
    • 测试应该只测试一件事情。
    • 尽量减少测试依赖(无依赖数据库、文件、UI等)。

    不要忘记你也可以使用xUnit框架进行集成测试但要保持集成测试和单元测试分离


    我猜你的意思是你已经从Gerard Meszaros的书《xUnit测试模式》中进行了改编。http://xunitpatterns.com - Spoike
    优秀的观点。单元测试可以非常有用,但避免陷入编写复杂、相互依赖的单元测试的陷阱非常重要,这会给尝试更改系统带来巨大的负担。 - Wedge

    9
    一些优秀单元测试的特点:
    • 当测试失败时,应立即明确问题所在。如果必须使用调试器来跟踪问题,则您的测试不够细粒度。每个测试只有一个断言可以帮助解决这个问题。

    • 重构时,不应该存在测试失败的情况。

    • 测试运行速度应非常快,以至于您从未犹豫过是否运行它们。

    • 所有测试始终都应该通过,没有非确定性结果。

    • 单元测试应该像你的生产代码一样被很好地重构。

    @Alotor:如果您建议库只在其外部API上进行单元测试,我不同意。我希望为每个类编写单元测试,包括我不向外部调用者公开的类。(然而,如果我感觉需要为私有方法编写测试,则需要重构。)


    编辑:关于“每个测试只有一个断言”可能引起重复的评论。具体来说,如果您有一些设置场景的代码,然后想对其进行多个断言,但每个测试只能有一个断言,那么您可能会在多个测试中重复设置。

    我不采取这种方法。相反,我使用每个场景的测试装置。这是一个粗略的例子:

    [TestFixture]
    public class StackTests
    {
        [TestFixture]
        public class EmptyTests
        {
            Stack<int> _stack;
    
            [TestSetup]
            public void TestSetup()
            {
                _stack = new Stack<int>();
            }
    
            [TestMethod]
            [ExpectedException (typeof(Exception))]
            public void PopFails()
            {
                _stack.Pop();
            }
    
            [TestMethod]
            public void IsEmpty()
            {
                Assert(_stack.IsEmpty());
            }
        }
    
        [TestFixture]
        public class PushedOneTests
        {
            Stack<int> _stack;
    
            [TestSetup]
            public void TestSetup()
            {
                _stack = new Stack<int>();
                _stack.Push(7);
            }
    
            // Tests for one item on the stack...
        }
    }
    

    我不同意每个测试只有一个断言的说法。在一个测试中有更多的断言,你将会拥有更少的复制粘贴测试用例。我认为一个测试用例应该专注于一个场景或代码路径,并且断言应该源自于实现该场景所需的所有假设和要求。 - Lucas B
    我认为我们都同意DRY原则适用于单元测试。正如我所说,“单元测试应该被很好地重构”。然而,有多种方法可以解决重复问题。你提到的一种方法是编写一个单元测试,首先调用被测试的代码,然后进行多次断言。另一种方法是为场景创建一个新的“测试夹具”,在Initialize/Setup步骤中调用被测试的代码,然后有一系列的单元测试只进行断言。 - Jay Bazuzi
    我的经验法则是,如果你在使用复制粘贴,那么你做错了什么。我最喜欢的说法之一是“复制粘贴不是设计模式”。我也同意每个单元测试只有一个断言通常是一个好主意,但我并不总是坚持这一点。我更喜欢更一般的“每个单元测试测试一件事情”的方法。虽然通常会转化为每个单元测试一个断言。 - Jon Turner

    9

    测试应该是隔离的。一个测试不应依赖于另一个测试。更进一步,测试不应该依赖于外部系统。换句话说,测试你的代码,而不是你的代码所依赖的代码。你可以将这些交互作为集成或功能测试的一部分进行测试。


    7
    你需要的是对被测试类行为的描述。
    1. 验证预期行为。
    2. 验证错误情况。
    3. 覆盖类内所有代码路径。
    4. 执行类内所有成员函数。
    基本目的是提高您对类行为的信心。
    当您考虑重构代码时,这尤其有用。Martin Fowler在他的网站上发表了一篇有趣的 文章 关于测试。
    希望对你有所帮助。
    祝好,
    Rob

    Rob - 机械化的做法虽然不错,但缺乏意图。你为什么要这样做?这样思考可能有助于其他人走上TDD之路。 - Mark Levison

    7

    测试应当一开始失败。接着,你应该编写代码使它们通过,否则你就有可能编写一个存在缺陷且总是通过的测试。


    @Rismo 不是绝对的。按照定义,Quarrelsome 在这里写的是“先测试”方法所特有的,而这是 TDD 的一部分。TDD 也考虑了重构。我读过的最聪明的定义是 TDD = 先测试 + 重构。 - Spoike
    没错,它不一定是TDD,只要确保你的测试首先失败。然后再接入其余部分。这在进行TDD时最常见,但你也可以在不使用TDD时应用它。 - Quibblesome

    6

    好的测试需要易于维护。

    对于复杂的环境,我还没有完全弄清楚如何做到这一点。

    所有的教科书在你的代码库开始达到数十万或数百万行代码时都开始出现问题。

    • 团队互动激增
    • 测试用例数量激增
    • 组件之间的交互激增。
    • 构建所有单元测试所需的时间成为构建时间的重要部分
    • API更改可能会影响数百个测试用例。即使生产代码更改很容易。
    • 将进程定序到正确状态所需的事件数量增加,从而增加了测试执行时间。

    良好的架构可以控制某些交互爆炸,但随着系统变得越来越复杂,自动化测试系统也随之增长。

    这是你开始处理权衡的地方:

    • 只测试外部API,否则重构内部会导致显著的测试用例重做。
    • 每个测试的设置和拆卸变得更加复杂,因为封装的子系统保留更多状态。
    • 夜间编译和自动化测试执行时间增加到几个小时。
    • 增加的编译和执行时间意味着设计师不会运行所有测试
    • 为了减少测试执行时间,你考虑定序测试以减少设置和拆卸

    你还需要决定:

    在代码库中存储测试用例的位置?

    • 如何记录测试用例?
    • 测试夹具是否可以重复使用以节省测试用例维护?
    • 当夜间测试用例执行失败时会发生什么?谁来进行分析?
    • 如何维护模拟对象?如果有20个模块都使用自己的模拟日志API,更改API会迅速扩散。不仅测试用例会改变,而且这20个模拟对象也会改变。这些20个模块是由许多不同团队在几年内编写的。这是一个经典的重用问题。
    • 个人和他们的团队明白自动化测试的价值,只是不喜欢其他团队的做法。 :-)

    我可以永远地继续下去,但我的观点是:

    测试需要易于维护。


    6

    我喜欢前面提到的《使用 NUnit 进行实用单元测试》一书中的“Right BICEP”缩写:

    • Right:结果正确吗?
    • B:所有边界条件是否正确?
    • I:我们能否检查反向关系
    • C:我们可以使用其他方法交叉检查结果吗?
    • E:我们可以强制发生错误条件吗?
    • P:性能特征在范围内吗?

    个人认为,通过检查是否获得正确的结果(例如,在加法函数中,1+1应该返回2),尝试使用您可以想到的所有边界条件(例如使用两个其总和大于整数最大值的数字进行加法)并强制发生网络故障等错误条件,您可以取得相当大的进展。


    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接