换句话说,您必须准确了解代码的详细算法。
不完全的。您必须准确了解代码的详细行为,如从代码本身外部观察到的那样。实现此行为的算法,或算法的组合,或任何级别的抽象/嵌套/计算/等。对测试无关紧要。测试只关心达到预期的结果。
因此,测试的价值在于它们是代码应该如何表现的规范。因此,只要仍然可以针对测试对其进行验证,代码就可以随意更改。您可以提高性能、重构可读性和可支持性等。测试确保行为保持不变。
例如,假设我想编写一个将两个数字相加的函数。您可能在脑海中知道您将如何实施它,但暂时将这些知识放在一边。你还没有实现它。首先,您正在实施测试...
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 世界中,我们会先编写最后一个实现,然后编写更多测试并逐渐改进代码。
这被称为红、绿、重构循环。在一个简单的、不那么做作的示例保龄球比赛中很好地说明了这一点。该练习的目的是练习该循环:
- 首先,编写一个期望某些行为的测试。这是循环的红色部分,因为如果没有适当的行为,测试将失败。
- 接下来,编写代码来展示该行为。这是循环的绿色部分,因为它的目的是让测试通过。只有让测试通过。
- 最后,重构代码并改进它。这自然是循环的重构部分。
你被卡住的地方是你永远处于循环的重构部分。您已经在考虑如何使代码变得更好。什么算法是正确的,如何优化它,最终应该如何编写。为此,TDD 是一种耐心的练习。不要写最好的代码......但是。
- 首先,确定代码应该做什么,仅此而已。
- 接下来,编写执行此操作的代码,仅此而已。
- 最后,改进该代码并使其变得更好。
更新
我遇到了一些让我想起了这个问题的东西,但我突然想到了一个随机的东西。也许我误解了你所问的情况。你如何管理你的依赖关系?也就是说,您使用的是哪种依赖注入方法?听起来这可能是这里讨论的问题的根源。
大约只要我记得,我就使用过诸如Common Service Locator之类的东西(或者,更常见的是,相同概念的本土实现)。在这样做的过程中,我倾向于一种非常具体的依赖注入风格。听起来你正在使用不同的风格。也许是构造函数注入?为了这个答案,我将假设构造函数注入。
那么让我们说,正如你所指出的,它MyMathObject
依赖于MyOtherClass1
and MyOtherClass2
。使用构造函数注入,这使得足迹MyMathObject
看起来像这样:
public class MyMathObject
{
public MyMathObject(MyOtherClass1 firstDependency, MyOtherClass2 secondDependency)
{
// implementation details
}
public int Add(int addend, int augend)
{
// implementation details
}
}
因此,正如您所指出的,测试需要提供其依赖关系或模拟。在类的足迹中,没有实际使用MyOtherClass1
or的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
}
}
没有泄漏的抽象,没有外部已知的依赖关系。随着实现细节的改变,测试不需要改变。这是将测试与他们正在测试的对象分离的另一个步骤。