TDD by example (2) -- 接战

分析

在开始写代码前,首先分析一下这个问题,做一个粗略的规划。我们可以写一个to-do list:

随机生成答案
检查输入是否合法
判断猜测结果
记录历史猜测数据并显示
判断猜测次数,如果满6次但是未猜对则判负
如果4个数字全中,则判胜

架构

这个程序比较简单,也不需要在架构上做太多考虑,只需要注意将输入输出与系统的核心逻辑分开就行了。

开发环境

.NET 3.5 + VS2008 + NUnit 2.5 + Moq  3.0 + Unity 1.2

实现

下面就要开始一个功能一个功能的实现了。挑选功能时尽量先选择问题的核心逻辑开始,我们可以看到“随机生成答 案”和“判断猜测结果”这两个是整个系统的核心逻辑,因此先从他们入 手。

判断猜测结果  

先从最简单的测试开始,如果没有一个数字是正确的,返回0A0B

[Test]
public void should_return_0A0B_when_no_number_is_correct()
{
    var answer 
= new int[] {1234};
    var guess 
= new int[] {5678};
    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 class Game
{
    
public Game(int[] answer)
    {
        
throw new NotImplementedException();
    }

    
public string Guess(int[] guess)
    {
        
throw new NotImplementedException();
    }
}


运行一下测试,失败了,这正是我们期望的结果。下面写些代码让测试能够通过,注意,我们现在的目标是尽快让这个测试通过,所以不需要考虑其他的情况,只要用最简单的方式实现就行了。

public class Game
{
    
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);
    }
}

好的,我们再写一个测试,看看如果有几个数位置不正确但是数正确的情况

[Test]
public void should_return_0A2B_when_two_numbers_are_correct_but_positions_are_not_correct()
{
    var answer 
= new int[] { 1234 };
    var guess 
= new int[] { 3456 };
    Game game 
= new Game(answer);
    
string result = game.Guess(guess);
    Assert.That(result, Is.EqualTo(
"0A2B"));
}

运行一下测试,应该失败。等等,怎么通过了?当你写完一个测试在没有修改代码的情况下运行却通过的时候,就要小心了。发生这种情况可能有几种可能, 一种就是测试写错了,使得错误地实现却通过了测试;第二种是这个测试和另一个测试是等价的,他们测的是一个东西,那么这个测试就是重复的浪费;最后一种就 是碰巧,为了通过前面的测试而写的代码,恰巧也包含了通过本测试的功能,这种情况有的时候是一种巧合,但是有时却代表你在实现前边测试的时候,做得太多 了,已经超出了你的目标--通过测试--有过渡设计的倾向。

分析一下,这个测试没有任何错误,因此不是第一种情况;这个测试和前一个测试测得也不是一个东西,前一个测试测的是临界值,这个是一般值;那么只剩下第三种情况了,碰巧了。

好的,那我们再写下一个测试,将A考虑进来

[Test]
public void should_return_1A0B_when_one_number_is_correct_and_position_is_correct_too()
{
    var answer 
= new int[] { 1234 };
    var guess 
= new int[] { 1567 };
    Game game 
= new Game(answer);
    
string result = game.Guess(guess);
    Assert.That(result, Is.EqualTo(
"1A0B"));
}

运行测试,失败。修改代码

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);
}

运行所有测试,全部通过,很好。

我们再写两个测试,覆盖既有A又有B和4A0B的情况

[Test]
public void should_return_2A2B_when_two_numbers_are_pisition_correct_and_two_are_nunmber_correct()
{
    var answer 
= new int[] { 1234 };
    var guess 
= new int[] { 1243 };
    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[] { 1234 };
    var guess 
= new int[] { 1234 };
    Game game 
= new Game(answer);
    
string result = game.Guess(guess);
    Assert.That(result, Is.EqualTo(
"4A0B"));
}

运行测试,全部通过,这说明我们的实现已经覆盖了这两种情况了。

因为前面的代码都比较简单,因此在每次测试之后,我并没有重构。但是写完了所有测试之后,看看代码,感觉不是太好,测试中有重复代码 ,实现的算法也不是太满意。首先先重构一下测试代码,消除重复。一般的方式是把创建测试对象和测试环境的代码提取到setup中。不过我个人不太喜欢 setup,因为如果一个类中的测试方法比较多,看后面的测试的时候,根本就看不到setup方法,这样要理解测试干了什么就需要翻页并结合两个方法的代 码,不利于理解,但是实际用起来还要具体问题具体分析了。在这里我选择将局部变量都消除掉,简化代码行数,同时又不会损失可读性。

[TestFixture]
public class TestGame
{
    [Test]
    
public void should_return_0A0B_when_no_number_is_correct()
    {
        Assert.That(
new Game(new int[] {1234}).Guess(new int[] {5678}), 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[] { 1234 }).Guess(new int[] { 3456 }), 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[] { 1234 }).Guess(new int[] { 1567 }), 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[] { 1234 }).Guess(new int[] { 1243 }), Is.EqualTo("2A2B"));
    }
    [Test]
    
public void should_return_4A0B_when_all_numbers_are_pisition_correct()
    {
        Assert.That(
new Game(new int[] { 1234 }).Guess(new int[] { 1234 }), Is.EqualTo("4A0B"));
    }
}

功能代码部分,我觉得代码还算是清晰,也容易看得懂,就是循环有点不爽,因为在Contains方法里还要再循环一次,相当于又嵌套了一个循环,这样在最 坏的情况下,每次猜测的复杂度是n2。我想到的办法是在构造函数里构造一个查找表,这样算法的复杂度可以 降为n(如果有兴趣可以想想有没有更好的算法,从效率和可读性两方面考虑)

public class Game
{
    
private readonly int[] answer;
    
private readonly Dictionary<intint> lookupTable = new Dictionary<intint>();

    
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的行为。 

[Test]
public void should_return_0A0B_when_no_number_is_correct()
{
    var mock 
= new Mock<IAnswerGenerator>();
    mock.Setup(generator 
=> generator.Generate()).Returns(new int[] {1234});
    Game game 
= new Game(mock.Object);
    Assert.That(game.Guess(
new int[] {5678}), Is.EqualTo("0A0B"));
}

这 里我们用一个接口IAnswerGenerator来将Game和AnswerGenerator解耦,并用Mock来模拟 AnswerGenerator的行为。这样可以在AnswerGenerator类还不存在的情况下,就把依赖于它的Game类做出来。

然后按照上面的步骤一个一个写出测试和实现代码。由于每个测试中都有同样的创建mock的代码,而且代码不多,可以提取到setup方法中,最后的代码如下

 

[TestFixture]
public class TestGame
{
    
private Game game;
    [SetUp]
    
public void Setup()
    {
        var mock 
= new Mock<IAnswerGenerator>();
        mock.Setup(generator 
=> generator.Generate()).Returns(new int[] { 1234 });
       
game = new Game(mock.Object);
    }
    [Test]
    
public void should_return_0A0B_when_no_number_is_correct()
    {
        Assert.That(game.
Guess(new int[] { 5678 }), 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[] {3456}), 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[] { 1567 }), 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[] { 1243 }), Is.EqualTo("2A2B"));
    }

    [Test]
    
public void should_return_4A0B_when_all_numbers_are_pisition_correct()
    {
        Assert.That(game
.Guess(new int[] { 1234 }), Is.EqualTo("4A0B"));
    }
}

Game类的代码

public interface IAnswerGenerator
{
    
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个数字全中,则判胜

相关文章

如何开始TDD

测试驱动开发之可执行文档

三张卡片帮你记住TDD原则

posted @ 2009-07-08 22:08  Nick Wang (懒人王)  阅读(2366)  评论(17编辑  收藏  举报