TDD by example (2) -- 接战
分析
在开始写代码前,首先分析一下这个问题,做一个粗略的规划。我们可以写一个to-do list:
随机生成答案
检查输入是否合法
判断猜测结果
记录历史猜测数据并显示
判断猜测次数,如果满6次但是未猜对则判负
如果4个数字全中,则判胜
架构
这个程序比较简单,也不需要在架构上做太多考虑,只需要注意将输入输出与系统的核心逻辑分开就行了。
开发环境
.NET 3.5 + VS2008 + NUnit 2.5 + Moq 3.0 + Unity 1.2
实现
下面就要开始一个功能一个功能的实现了。挑选功能时尽量先选择问题的核心逻辑开始,我们可以看到“随机生成答 案”和“判断猜测结果”这两个是整个系统的核心逻辑,因此先从他们入 手。
判断猜测结果
先从最简单的测试开始,如果没有一个数字是正确的,返回0A0B
public void should_return_0A0B_when_no_number_is_correct()
{
var answer = new int[] {1, 2, 3, 4};
var guess = new int[] {5, 6, 7, 8};
Game game = new Game(answer);
string result = game.Guess(guess);
Assert.That(result, Is.EqualTo("0A0B"));
}
这里面有一个问题,就是如何将答案交给Game类,一种方式是将answer数组直接传给Game的构造函数,另一种是将AnswerGenerator
的引用传递给Game类,让Game自己去“要”答案。我们先选择简单的方式,直接把answer交给
Game。
好了,接着我们创建Game类和Guess方法,让编译能够通过
{
public Game(int[] answer)
{
throw new NotImplementedException();
}
public string Guess(int[] guess)
{
throw new NotImplementedException();
}
}
运行一下测试,失败了,这正是我们期望的结果。下面写些代码让测试能够通过,注意,我们现在的目标是尽快让这个测试通过,所以不需要考虑其他的情况,只要用最简单的方式实现就行了。
{
private readonly int[] answer;
public Game(int[] answer)
{
this.answer = answer;
}
public string Guess(int[] guess)
{
int bCount = 0;
foreach (int i in guess)
{
if(answer.Contains(i))
{
bCount++;
}
}
return string.Format("{0}A{1}B", 0, bCount);
}
}
好的,我们再写一个测试,看看如果有几个数位置不正确但是数正确的情况
public void should_return_0A2B_when_two_numbers_are_correct_but_positions_are_not_correct()
{
var answer = new int[] { 1, 2, 3, 4 };
var guess = new int[] { 3, 4, 5, 6 };
Game game = new Game(answer);
string result = game.Guess(guess);
Assert.That(result, Is.EqualTo("0A2B"));
}
运行一下测试,应该失败。等等,怎么通过了?当你写完一个测试在没有修改代码的情况下运行却通过的时候,就要小心了。发生这种情况可能有几种可能,
一种就是测试写错了,使得错误地实现却通过了测试;第二种是这个测试和另一个测试是等价的,他们测的是一个东西,那么这个测试就是重复的浪费;最后一种就
是碰巧,为了通过前面的测试而写的代码,恰巧也包含了通过本测试的功能,这种情况有的时候是一种巧合,但是有时却代表你在实现前边测试的时候,做得太多
了,已经超出了你的目标--通过测试--有过渡设计的倾向。
分析一下,这个测试没有任何错误,因此不是第一种情况;这个测试和前一个测试测得也不是一个东西,前一个测试测的是临界值,这个是一般值;那么只剩下第三种情况了,碰巧了。
好的,那我们再写下一个测试,将A考虑进来
public void should_return_1A0B_when_one_number_is_correct_and_position_is_correct_too()
{
var answer = new int[] { 1, 2, 3, 4 };
var guess = new int[] { 1, 5, 6, 7 };
Game game = new Game(answer);
string result = game.Guess(guess);
Assert.That(result, Is.EqualTo("1A0B"));
}
运行测试,失败。修改代码
{
int aCount = 0;
int bCount = 0;
for (int i = 0; i < guess.Length; i++)
{
if(answer[i] == guess[i])
{
aCount++;
}
else if(answer.Contains(guess[i]))
{
bCount++;
}
}
return string.Format("{0}A{1}B", aCount, bCount);
}
运行所有测试,全部通过,很好。
我们再写两个测试,覆盖既有A又有B和4A0B的情况
public void should_return_2A2B_when_two_numbers_are_pisition_correct_and_two_are_nunmber_correct()
{
var answer = new int[] { 1, 2, 3, 4 };
var guess = new int[] { 1, 2, 4, 3 };
Game game = new Game(answer);
string result = game.Guess(guess);
Assert.That(result, Is.EqualTo("2A2B"));
}
[Test]
public void should_return_4A0B_when_all_numbers_are_pisition_correct()
{
var answer = new int[] { 1, 2, 3, 4 };
var guess = new int[] { 1, 2, 3, 4 };
Game game = new Game(answer);
string result = game.Guess(guess);
Assert.That(result, Is.EqualTo("4A0B"));
}
运行测试,全部通过,这说明我们的实现已经覆盖了这两种情况了。
因为前面的代码都比较简单,因此在每次测试之后,我并没有重构。但是写完了所有测试之后,看看代码,感觉不是太好,测试中有重复代码
,实现的算法也不是太满意。首先先重构一下测试代码,消除重复。一般的方式是把创建测试对象和测试环境的代码提取到setup中。不过我个人不太喜欢
setup,因为如果一个类中的测试方法比较多,看后面的测试的时候,根本就看不到setup方法,这样要理解测试干了什么就需要翻页并结合两个方法的代
码,不利于理解,但是实际用起来还要具体问题具体分析了。在这里我选择将局部变量都消除掉,简化代码行数,同时又不会损失可读性。
public class TestGame
{
[Test]
public void should_return_0A0B_when_no_number_is_correct()
{
Assert.That(new Game(new int[] {1, 2, 3, 4}).Guess(new int[] {5, 6, 7, 8}), Is.EqualTo("0A0B"));
}
[Test]
public void should_return_0A2B_when_two_numbers_are_correct_but_positions_are_not_correct()
{
Assert.That(new Game(new int[] { 1, 2, 3, 4 }).Guess(new int[] { 3, 4, 5, 6 }), Is.EqualTo("0A2B"));
}
[Test]
public void should_return_1A0B_when_one_number_is_correct_and_position_is_correct_too()
{
Assert.That(new Game(new int[] { 1, 2, 3, 4 }).Guess(new int[] { 1, 5, 6, 7 }), Is.EqualTo("1A0B"));
}
[Test]
public void should_return_2A2B_when_two_numbers_are_pisition_correct_and_two_are_nunmber_correct()
{
Assert.That(new Game(new int[] { 1, 2, 3, 4 }).Guess(new int[] { 1, 2, 4, 3 }), Is.EqualTo("2A2B"));
}
[Test]
public void should_return_4A0B_when_all_numbers_are_pisition_correct()
{
Assert.That(new Game(new int[] { 1, 2, 3, 4 }).Guess(new int[] { 1, 2, 3, 4 }), Is.EqualTo("4A0B"));
}
}
功能代码部分,我觉得代码还算是清晰,也容易看得懂,就是循环有点不爽,因为在Contains方法里还要再循环一次,相当于又嵌套了一个循环,这样在最 坏的情况下,每次猜测的复杂度是n2。我想到的办法是在构造函数里构造一个查找表,这样算法的复杂度可以 降为n(如果有兴趣可以想想有没有更好的算法,从效率和可读性两方面考虑)
{
private readonly int[] answer;
private readonly Dictionary<int, int> lookupTable = new Dictionary<int, int>();
public Game(IAnswerGenerator answerGenerator)
{
this.answer = answerGenerator.Generate();
for (int i = 0; i < 10; i++)
{
lookupTable.Add(i,-1);
}
for (int i = 0; i < answer.Length; i++)
{
int num = answer[i];
lookupTable[num] = i;
}
}
public string Guess(int[] guess)
{
int aCount = 0;
int bCount = 0;
for (int i = 0; i < guess.Length; i++)
{
int num = guess[i];
int index = lookupTable[num];
if(index == i)
{
aCount++;
}
else if(index > -1)
{
bCount++;
}
}
return string.Format("{0}A{1}B", aCount, bCount);
}
}
运行测试,全部通过。但是再看看代码,似乎更难以理解了。考虑到答案只有4个数字,程序本身的性能也要求不高,而且最关键的是,我们还没有写完程序,不知道会不会有性能问题,也不知道如果有性能问题,这个地方是不是一个瓶颈,因此过早的优化而牺牲可读性 是得不偿失的。 所以我还是选择原来的实现方式,保证代码清晰易懂。
Mock
下面我们看看如果Game类依赖于另一个类来获得答案如何写测试。首先和上面一样写一个测试,让Game的构造函数接受一个
IAnswerGenerator。然后构造一个mock对象来模拟AnswerGenerator的行为。
public void should_return_0A0B_when_no_number_is_correct()
{
var mock = new Mock<IAnswerGenerator>();
mock.Setup(generator => generator.Generate()).Returns(new int[] {1, 2, 3, 4});
Game game = new Game(mock.Object);
Assert.That(game.Guess(new int[] {5, 6, 7, 8}), Is.EqualTo("0A0B"));
}
这
里我们用一个接口IAnswerGenerator来将Game和AnswerGenerator解耦,并用Mock来模拟
AnswerGenerator的行为。这样可以在AnswerGenerator类还不存在的情况下,就把依赖于它的Game类做出来。
然后按照上面的步骤一个一个写出测试和实现代码。由于每个测试中都有同样的创建mock的代码,而且代码不多,可以提取到setup方法中,最后的代码如下
public class TestGame
{
private Game game;
[SetUp]
public void Setup()
{
var mock = new Mock<IAnswerGenerator>();
mock.Setup(generator => generator.Generate()).Returns(new int[] { 1, 2, 3, 4 });
game = new Game(mock.Object);
}
[Test]
public void should_return_0A0B_when_no_number_is_correct()
{
Assert.That(game.Guess(new int[] { 5, 6, 7, 8 }), Is.EqualTo("0A0B"));
}
[Test]
public void should_return_0A2B_when_two_numbers_are_correct_but_positions_are_not_correct()
{
Assert.That(game.Guess(new int[] {3, 4, 5, 6}), Is.EqualTo("0A2B"));
}
[Test]
public void should_return_1A0B_when_one_number_is_correct_and_position_is_correct_too()
{
Assert.That(game.Guess(new int[] { 1, 5, 6, 7 }), Is.EqualTo("1A0B"));
}
[Test]
public void should_return_2A2B_when_two_numbers_are_pisition_correct_and_two_are_nunmber_correct()
{
Assert.That(game.Guess(new int[] { 1, 2, 4, 3 }), Is.EqualTo("2A2B"));
}
[Test]
public void should_return_4A0B_when_all_numbers_are_pisition_correct()
{
Assert.That(game.Guess(new int[] { 1, 2, 3, 4 }), Is.EqualTo("4A0B"));
}
}
Game类的代码
{
int[] Generate();
}
public class Game
{
private readonly int[] answer;
public Game(IAnswerGenerator generator)
{
this.answer = generator.Generate();
}
public string Guess(int[] guess)
{
int aCount = 0;
int bCount = 0;
for (int i = 0; i < guess.Length; i++)
{
if(answer[i] == guess[i])
{
aCount++;
}
else if(answer.Contains(guess[i]))
{
bCount++;
}
}
return string.Format("{0}A{1}B", aCount, bCount);
}
}
由于后面还要演示IoC的使用,因此会继续使用mock方式编写出来的代码
好了,看看我们的to-do list,已经完成了一个,下一篇继续实现其他功能
随机生成答案
检查输入是否合法
判断猜测结果
记录历史猜测数据并显示
判断猜测次数,如果满6次但是未猜对则判负
如果4个数字全中,则判胜