说明:
本讲义是我在ThoughtWorks作为咨询师时,为客户开展TDD Code Kata而编写。案例为Guess Number,案例需求来自当时的同事王瑜珩。当时,我们共同在ThoughtWorks的Zynx交付团队,为培养团队TDD能力进行训练时,引入了本案例。讲义中给出的代码问题则来自客户方的受训学员,可谓“真实的代码坏味道”。个人认为TDD不只是开发方法,还应该是设计方法,因此讲义中包含了诸多设计原理、思想和原则。
还剩下两个任务:
究竟应该选择哪一个任务作为第四个任务,并没有定论。从业务逻辑看,“判断游戏结果”任务更重要,它才是整个游戏的核心逻辑。可从技术实现看,“判断游戏结果”可以依赖“记录并显示历史猜测数据”。因为分析“判断游戏结果”任务,实际上做了两件事:其一是判断猜测次数是否超过指定的6次;其二是判断每次猜测的结果。第二件事已经被我们开发的第二个任务覆盖。而对于测试次数而言,如果我们记录了历史猜测数据,那么这个次数也可以唾手可得。
讨论:测试驱动开发需要事先设计吗?
Martin Fowler的文章Is Design Dead?其实就是对此问题的正本清源。由于测试驱动开发提倡“测试先行,简单设计”,许多人就误认为TDD不需要设计,以讹传讹之下,甚至导致许多优秀的设计者抛弃了设计去实践TDD,最后得出TDD不可行的结论。
我个人认为,视场景而定,测试驱动开发仍可进行事先设计。设计并不仅包含技术层面的设计如对OO思想乃至设计模式的运用,它本身还包括对需求的分析与建模。若不分析需求就开始编写测试,就好像没有搞清楚要去的地方,就开始快步前行,最后发现南辕北辙。测试驱动开发提倡的任务分解,实际上就是一种需求的分析。而如何寻找职责,以及识别职责的承担者则可以视为建模设计。测试驱动更像是一种培养设计专注力的手段,就像冥想者通过盘腿静坐的手段来体悟天地一样,测试驱动可以强迫你站在测试的角度(就是使用者的角度)去思考接口,如此才能设计出表现意图的接口。但编写测试自身并不能取代设计,正如盘腿静坐并不等于就是冥想。
在开始测试驱动开发之前,做适度的事先设计,还有利于我们仔细思考技术实现的解决方案。它与测试驱动接口的设计并不相悖。解决方案或许属于实现层面,若过早思考实现,会干扰我们对接口的判断;但完全不理会实现,又可能导致设计方向的走偏。举例来说,如果我们要实现XML消息到Java对象的转换。一种解决方案是通过jaxb将消息转换为Java对象,然后再定义转换映射的Transformer,通过硬编码或者反射的方式将其转换为相关的领域对象。然后在执行了业务操作后,再将返回的结果转换为另一个Jaxb对象。而另一种解决方案则是通过引入模板,例如StringTemplate或者Velocity,定义转换的模板,然后进行替换实现。这两种解决方案的区别,直接影响了我们划分任务的方式。
我们选择“记录并显示历史猜测数据”作为第四个任务。同样,对于此任务,我们要事先考虑清楚,究竟应该由谁来承担这个职责?恩,注意,这里其实包含了两项任务:记录与显示。当我们看到类似“和”、“或者”等并列连接词时,都应该思考它是否表达了多个职责?因此,对于第四个任务,我们应该稍稍拆分一下,分解成两个任务:
那么应该谁来“记录历史猜测数据”?我们应该寻找承担该职责的对象。
知识:寻找职责的承担者
寻找职责的承担者,其实就是寻找某个可以承担该职责的角色。角色又是什么?想象我们现实世界中的角色。看看我们身边,是否角色遍地可寻?BA角色负责分析需求,DEV角色负责实现功能,QA角色负责测试功能是否正确,PM角色负责管理整个项目的进度与项目成员。我们是依据什么来划分角色的?——能力。能力的体现是什么?除了诸多素质要求,最直接的体现就是“知识”。因此,所谓“角色”,就是拥有了相关“知识”从而具有相关“能力”的人。
什么角色应该记录历史猜测数据呢?那就是要寻找谁具有记录历史猜测数据的能力。于是推之于知识,就是谁拥有每一次猜测的数据。显然,Game拥有当前猜测的数据,因此承担责任的应该为Game。
现在,开始编写测试。既然已经辨别出Game对象,就应该针对它编写测试方法,让我们还是从测试方法的业务逻辑描述开始吧:
public class GameTest {
private final Answer actualAnswer = Answer.createAnswer("1 2 3 4");
private Game game;
@Before
public void setUp() throws Exception {
AnswerGenerator answerGenerator = mock(AnswerGenerator.class);
when(answerGenerator.generate()).thenReturn(actualAnswer);
game = new Game(answerGenerator);
}
@Test
public void should_record_every_guess_result() {
game.guess(Answer.createAnswer("2 1 6 7"));
game.guess(Answer.createAnswer("1 2 3 4"));
List guessHistory = game.guessHistory();
assertThat(guessResults.size(), is(2));
assertThat(guessResults.get(0).result(), is("0A2B"));
assertThat(guessResults.get(0).inputAnswer().toString(), is("2 1 6 7"));
assertThat(guessResults.get(1).result(), is("4A0B"));
assertThat(guessResults.get(1).inputAnswer().toString(), is("1 2 3 4"));
}
}
在这里,实际上我驱动出了Game的guessHistory()方法,同时还得到了一个封装了猜测结果的GuessResult对象。与第一个任务不同的是,我没有使用字符串来表示猜测结果,这是因为这里的历史猜测数据不仅包含了猜测结果,还包含了当前的测测数据。
现在,应该考虑“显示历史猜测记录”的任务了。这个功能就是要在猜测了数字之后,在控制台显示历史猜测记录。虽然是控制台,我们仍然认为这属于界面的工作。TDD根本就不应该用来驱动界面设计,还是将注意力放到业务逻辑上来吧。抛开界面,这里的逻辑就转换为:
当用户猜测了数字后,应该显示历史猜测记录。
将界面与业务逻辑分开体现了“关注点分离”原则,也是表现层设计的常用做法。最常见的处理界面设计的模式就是MVC模式。因此在这里可以引入GameController类,就目前而言,它可以负责Game与GameView的协作,所以相应的还可以为界面显示定义一个专属的View对象。
虽然在这里是用控制台显示历史猜测数据信息,实现非常简单,直接调用System.out.println()方法即可,然而我们却很难测试控制台是否显示了该信息。虽然有一些框架也提供了Mock控制台的功能,但就TDD而言,这样的测试并无实际意义。我们需要合理地辨别在功能实现中,哪些内容适合编写自动化测试,哪些内容适合人工测试。因此,这里可以引入Mock框架来模拟GameView,我们只需验证Controller与View之间的协作即可。这时,测试还有助于我们设计出可测试性好的类。
因为是Controller,需要接受用户输入,而非直接传入答案的字符串值。同理,我们在TDD中也不可能测试业务逻辑与控制台的交互。因此,同样需要引入InputCommand类型来封装输入逻辑,然后以Mock框架来模拟InputCommand。 故而,我们为该功能编写的测试为:
public class GameControllerTest {
@Mock
private GameView mockGameView;
@Mock
private InputCommand mockCommand;
@Mock
private AnswerGenerator mockGenerator;
private Game game;
private Answer correctAnswer;
private Answer errorAnswer;
private GameController gameController;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
correctAnswer = Answer.createAnswer("1 2 3 4");
errorAnswer = Answer.createAnswer("1 2 5 6");
when(mockGenerator.generate()).thenReturn(correctAnswer);
game = new Game(mockGenerator);
gameController = new GameController(game, mockGameView);
}
@Test
public