>问题
我正在使用测试驱动开发,但无法使我的测试很好地定义我的代码。我的问题的一个简单例子如下。
我有MyObject
,我想从中称methodA()
或methodB()
属于OtherObject
,这取决于MyObject
在自己的callMethod(int)
中收到的论点。
预期的代码(和所需的功能(
这本质上是我希望代码做的事情 - 但我想先进行测试:
public class MyObject {
private final OtherObject otherObject;
public MyObject(OtherObject otherObject) {
this.otherObject = otherObject;
}
public void callMethod(int i) {
switch (i) {
case 0:
otherObject.methodA();
break;
case 1:
otherObject.methodB();
break;
}
}
}
首先编写测试
为此,我首先编写一个测试 - 检查调用callMethod(0)
时是否调用了methodA()
。我使用JUnit和Mockito。
public class MyObjectTest {
private final OtherObject mockOtherObject = mock(OtherObject.class);
private final MyObject myObject = new MyObject(mockOtherObject);
@Test
public void callsMethodA_WhenArgumentIs0() {
myObject.callMethod(0);
verify(mockOtherObject).methodA();
}
}
我创建了消除错误所需的类/方法,并通过实现MyObject
的方法使测试通过,如下所示:
public void callMethod(int i) {
otherObject.methodA();
}
接下来测试另一个选项 - 调用callMethod(1)
@Test
public void callsMethodB_WhenArgumentIs1() {
myObject.callMethod(1);
verify(mockOtherObject).methodB();
}
我得到了以下最终解决方案:
public void callMethod(int i) {
otherObject.methodA();
otherObject.methodB();
}
A. 问题
这有效,但显然不是我想要的。如何使用测试进行到我想要的代码?在这里,我已经测试了我想要的行为。我能想到的唯一解决方案是为我不想看到的行为编写更多的测试。
在此示例中,可以再编写 2 个测试来检查是否未调用其他方法,但在一般情况下,这样做肯定是一个更大的问题。当有更多的选项时,根据情况调用哪些方法和多少种不同的方法会更加复杂。
假设我的例子中有 3 个方法 - 我是否必须编写 3 个测试来检查调用了正确的方法 - 那么如果我正在检查 3 种情况中没有调用其他 2 种方法,那么还有 6 种方法?(无论你是否尝试坚持每个测试的一个断言,你仍然必须写下它们。
看起来测试的数量将取决于代码有多少选项。
另一种选择是只编写if
或switch
语句,但从技术上讲,它不会由测试驱动。
我认为您需要对代码进行稍微大一点的了解。 不要考虑它应该调用什么方法,而是考虑这些方法的整体效果应该是什么。
- 调用
callMethod(0)
的输出和副作用应该是什么? - 调用
callMethod(1)
的输出和副作用应该是什么?
不要根据打电话给methodA
或methodB
来回答,而是从外面可以看到什么。 应该从callMethod
返回什么(如果有的话(? callMethod
的调用者可以看到哪些其他行为?
如果methodA
做了一些callMethod
调用者可以观察到的特殊操作,那么请将其包含在您的测试中。 如果在发生callMethod(0)
时观察该行为很重要,请对其进行测试。 如果重要的是不要在callMethod(1)
发生时观察这种行为,那么也要测试一下。
关于你的具体例子,我想说你做得完全正确。 测试应指定所测试类的行为。 如果您需要指定您的类在某些情况下不执行某些操作,那就这样吧。 在另一个例子中,这不会打扰你。 例如,在此方法中检查这两个条件可能不会引起任何异议:
public void save(){
if(isDirty)
persistence.write(this);
}
在一般情况下,你又是对的。 增加方法的复杂性会使TDD更加困难。 意想不到的结果是,这是TDD的最大好处之一。 如果你的测试是隐藏的,那么你的代码也太复杂了。 这将很难推理,也很难维护。 如果您听取测试,您将考虑以简化测试的方式更改设计。
在您的示例中,我可能会不理会它(它非常简单(。 但是,如果case
的数量增加,我会考虑这样的更改:
public class MyObject {
private final OtherObjectFactory factory;
public MyObject(OtherObjectFactory factory) {
this.factory = factory;
}
public void callMethod(int i) {
factory.createOtherObject(i).doSomething();
}
}
public abstract class OtherObject{
public abstract void doSomething();
}
public class OtherObjectFactory {
public OtherObject createOtherObject(int i){
switch (i) {
case 0:
return new MethodAImpl();
case 1:
return new MethodBImpl();
}
}
}
请注意,此更改会给您尝试解决的问题增加一些开销;对于两种情况,我不会打扰它。 但随着病例的增长,这扩展得非常好:您添加了一个新的OtherObjectFactory
测试和一个新的OtherObject
实现。 你永远不会改变MyObject
,或者它是测试;它只有一个简单的测试。 这也不是使测试更简单的唯一方法,这只是我想到的第一件事。
总的来说,如果你的测试很复杂,这并不意味着测试无效。 好的测试和好的设计是同一枚硬币的两面。 测试需要一次咬掉问题的一小部分才能有效,就像代码需要一次解决问题的一小部分才能进行维护和凝聚一样。 两只手互相洗手。
好问题。将TDD应用于字母(特别是像您那样使用魔鬼代言人技术(确实揭示了一些有趣的问题。
马克·西曼(Mark Seemann(最近有一篇关于类似问题的文章,他证明了使用不同的,稍微严格的模拟可以解决问题。我不知道 Mockito 是否可以做到这一点,但是对于 Moq 等框架,在您的示例中,将mockOtherObject
设置为严格的模拟会导致我们想要的异常,因为将调用未准备好的方法methodB()
。
话虽如此,这仍然属于"测试你的代码不应该做的事情",我不太喜欢验证事情不会发生 - 它使你的测试变得僵化了很多。我看到的唯一例外是,如果一种方法对您的系统足够关键/危险,以证明使用防御手段来确保它不被调用是合理的,但这不应该经常发生。
现在,有些东西可能会胜过整个难题 - TDD周期的重构部分。
在该步骤中,您应该意识到switch
语句有点臭味。更模块化、更解耦的方式怎么样?如果我们仔细想想,callMethod()
要采取的行动实际上是由
-
MyObject
的实例化器(在构造时传递适当的OtherObject
( -
callMethod()
的调用方(传递适当的i
参数,该参数将依赖于方法调用(
因此,另一种解决方案可能是以某种方式将传递的i
与构造中注入的对象中的一个方法结合起来,以触发预期的操作(@tallseth 的工厂示例正是关于这一点的(。
如果你这样做,OtherObject
不必再有 2 种方法 - switch
语句和魔鬼代言人错误完全消失。