16

由于单元测试是一个白盒测试,它假定您必须事先知道您的代码必须处理的所有情况、您的代码必须处理的所有客户端对象(也就是测试中的 Mock 对象)以及正确的执行顺序客户端对象必须出现在代码中(因为单元测试考虑到模拟对象的调用)。换句话说,您必须确切地知道代码的详细算法。在您准确了解代码的算法之前,您必须先编写它!

从我的角度来看,我看不出在编写源代码之前如何编写正确的单元测试。尽管如此,由于功能测试是一种用户需求,因此可以先编写功能测试。你的建议?最良好的问候

为此问题提供了一个示例:
当它们是对象依赖项时,如何在编写源代码之前编写测试代码?

4

5 回答 5

28

换句话说,您必须准确了解代码的详细算法。

不完全的。您必须准确了解代码的详细行为,如从代码本身外部观察到的那样。实现此行为的算法,或算法的组合,或任何级别的抽象/嵌套/计算/等。对测试无关紧要。测试只关心达到预期的结果。

因此,测试的价值在于它们是代码应该如何表现的规范。因此,只要仍然可以针对测试对其进行验证,代码就可以随意更改。您可以提高性能、重构可读性和可支持性等。测试确保行为保持不变。

例如,假设我想编写一个将两个数字相加的函数。您可能在脑海中知道您将如何实施它,但暂时将这些知识放在一边。你还没有实现它。首先,您正在实施测试...

public void CanAddIntegers()
{
    var addend = 1;
    var augend = 1;
    var result = MyMathObject.Add(addend, augend);
    Assert.AreEqual(2, result);
}

现在您有了测试,您可以实现该方法...

public int Add(int addend, int augend)
{
    return ((addend * 2) + (augend * 2)) / 2;
}

哇。等一下……我到底为什么要这样实现它?那么,从测试的角度来看,谁在乎呢?它通过了。实现符合要求。现在我有一个测试,我可以安全地重构代码......

public int Add(int addend, int augend)
{
    return addend + augend;
}

这更理智一点。并且测试仍然通过。其实我可以进一步减少代码...

public int Add(int addend, int augend)
{
    return 2;
}

你猜怎么着?测试仍然通过。这是我们唯一的测试,也是唯一给出的规范,所以代码“有效”。很明显,我们需要改进测试以涵盖更多案例。编写更多测试将为我们提供编写更多代码所需的规范。

事实上,根据TDD 的第三条规则,最后一个实现应该是第一个实现:

不允许您编写任何超过足以通过一个失败的单元测试的生产代码。

因此,在纯粹由鲍勃叔叔驱动的 TDD 世界中,我们会先编写最后一个实现,然后编写更多测试并逐渐改进代码。

这被称为红、绿、重构循环。在一个简单的、不那么做作的示例保龄球比赛中很好地说明了这一点。该练习的目的是练习该循环:

  1. 首先,编写一个期望某些行为的测试。这是循环的红色部分,因为如果没有适当的行为,测试将失败。
  2. 接下来,编写代码来展示该行为。这是循环的绿色部分,因为它的目的是让测试通过。只有让测试通过。
  3. 最后,重构代码并改进它。这自然是循环的重构部分。

你被卡住的地方是你永远处于循环的重构部分。您已经在考虑如何使代码变得更好。什么算法是正确的,如何优化它,最终应该如何编写。为此,TDD 是一种耐心的练习。不要写最好的代码......但是

  1. 首先,确定代码该做什么,仅此而已
  2. 接下来,编写执行此操作的代码,而已
  3. 最后,改进该代码并使其变得更好。

更新

我遇到了一些让我想起了这个问题的东西,但我突然想到了一个随机的东西。也许我误解了你所问的情况。你如何管理你的依赖关系?也就是说,您使用的是哪种依赖注入方法?听起来这可能是这里讨论的问题的根源。

大约只要我记得,我就使用过诸如Common Service Locator之类的东西(或者,更常见的是,相同概念的本土实现)。在这样做的过程中,我倾向于一种非常具体的依赖注入风格。听起来你正在使用不同的风格。也许是构造函数注入?为了这个答案,我将假设构造函数注入。

那么让我们说,正如你所指出的,它MyMathObject依赖于MyOtherClass1and MyOtherClass2。使用构造函数注入,这使得足迹MyMathObject看起来像这样:

public class MyMathObject
{
    public MyMathObject(MyOtherClass1 firstDependency, MyOtherClass2 secondDependency)
    {
        // implementation details
    }

    public int Add(int addend, int augend)
    {
        // implementation details
    }
}

因此,正如您所指出的,测试需要提供其依赖关系或模拟。在类的足迹中,没有实际使用MyOtherClass1or的MyOtherClass2迹象,但有迹象表明需要它们。作为依赖项,它们被构造函数大声宣传。

所以这引出了你问的问题......当一个人还没有实现对象时,如何先编写测试?同样,没有迹象表明仅在对象的面向外部的设计中实际使用。所以依赖是一个需要知道的实现细节。

否则,你首先要这样写:

public class MyMathObject
{
    public int Add(int addend, int augend)
    {
        // implementation details
    }
}

然后你会为它编写你的测试,然后你会实现它并发现依赖关系,然后你会为它重新编写你的测试。问题就在于此。

但是,您发现的问题不是测试或测试驱动开发的问题。问题实际上在于对象的设计。尽管// implementation details已经被掩盖了,但仍有一个实现细节正在逃逸。有一个泄漏的抽象

public class MyMathObject
{
    public MyMathObject(MyOtherClass1 firstDependency, MyOtherClass2 secondDependency)
    {                   ^---Right here                 ^---And here

        // implementation details
    }

    public int Add(int addend, int augend)
    {
        // implementation details
    }
}

该对象没有充分封装和抽象其实现细节。它正在尝试,而依赖注入的使用是朝着这一目标迈出的重要一步。但它还没有完全实现。这是因为作为实现细节的依赖关系是外部可见的,并且其他对象可以从外部知道。(在这种情况下,测试对象。)因此,为了满足依赖关系并使其MyMathObject工作,外部对象需要了解其实现细节。他们都这样做。测试对象、使用它的任何生产代码对象、以任何方式依赖它的任何事物。

为此,您可能需要考虑切换依赖项的管理方式。代替构造函数注入或setter注入之类的东西,进一步反转依赖关系的管理并让对象在内部通过另一个对象解决它们。

使用前面提到的服务定位器作为起始模式,很容易制作一个其唯一目的(其单一职责)是解决依赖关系的对象。如果您使用的是依赖注入框架,那么这个对象通常只是框架功能的传递(但抽象框架本身......所以少了一个依赖,这是一件好事)。如果使用本地开发的功能,则此对象将抽象该功能。

但是你最终得到的是这样的MyMathObject

private SomeInternalFunction()
{
    var firstDependency = ServiceLocatorObject.Resolve<MyOtherClass1>();
    // implementation details
}

因此,即使使用依赖注入,现在的足迹MyMathObject也是:

public class MyMathObject
{
    public int Add(int addend, int augend)
    {
        // implementation details
    }
}

没有泄漏的抽象,没有外部已知的依赖关系。随着实现细节的改变,测试不需要改变。这是将测试与他们正在测试的对象分离的另一个步骤。

于 2013-01-26T14:27:46.320 回答
2

显然,如果您不了解“软件”意图是什么,则无法编写测试,但如果要求规范详细,则绝对有可能。

你可以写一些符合要求但会失败的东西;然后根据规范产生最小的工作量以使测试通过。

除非你是某种天才——第一次剪辑需要重构和引入抽象、模式、可维护性、性能和各种其他因素。

因此,如果了解了需求,您可以先进行测试——但在实现组合在一起之前,测试不会通过,并且只需要使测试通过的实现。

以这种方式工作在现实中并不总是符合要求 - 特别是在难以获得规范的情况下。如果您没有得到作为开发人员所需的东西,您需要小心不要盲目地沿着这条道路前进。在继承代码或添加到“棕地”项目时,通常也无法实现。作为开发人员,尽早确定实用性很重要。

于 2013-01-26T14:22:13.580 回答
1

首先:大量单元测试不需要 Mocks,不需要与其他对象交互;你的问题不适用于那些。

如果新方法的目的或部分目的是对某个协作对象产生影响,那么这就是必须测试的一部分。如果这不是它的目的,但您的实现偶然对合作者产生了影响,那么您不应该测试这种偶然影响。

无论哪种方式,很容易看出您可以在编写代码之前编写测试。如果您的方法应该影响另一个对象,那么测试应该这样说。如果您的方法不应该这样做,则测试不需要说明该方法与其他对象的交互。

于 2013-01-26T17:10:02.110 回答
0

这是人们在开始 TDD 时很难克服的问题。我敢肯定在 SO 上有很多很好的答案,尤其是在programmers.stackexchange.com 上(这个问题可能更适合那个论坛)。

我可以说很多东西来帮助你理解,但没有一个比你实际做一些 TDD 更有效。作为一个快速开始的地方,我将向您推荐这篇关于代码 katas的文章,它链接到一些有用的 TDD 练习。

于 2013-01-26T14:22:55.567 回答
0

不是真的。使用 TDD,您将从您期望产品代码处理的测试用例开始。这并不意味着不应该有产品代码。您的类功能等可以存在。您从失败的测试用例开始,然后不断更改您的产品代码以使其通过。

请参阅http://msdn.microsoft.com/en-us/library/aa730844(v=vs.80).aspx#guidelinesfortdd_topic2

于 2013-01-26T14:23:23.800 回答