TDD by example (2) -- 接战

本文通过一个具体的案例,展示了测试驱动开发(TDD)的过程。从需求分析到实现,再到重构,详细介绍了如何逐步完善代码和测试。

分析

在开始写代码前,首先分析一下这个问题,做一个粗略的规划。我们可以写一个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 [] { 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   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 [] {  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考虑进来

[Test]
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 " ));
}

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

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 [] {  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方法,这样要理解测试干了什么就需要翻页并结合两个方法的代 码,不利于理解,但是实际用起来还要具体问题具体分析了。在这里我选择将局部变量都消除掉,简化代码行数,同时又不会损失可读性。

[TestFixture]
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(如果有兴趣可以想想有没有更好的算法,从效率和可读性两方面考虑)

public   class  Game
{
    
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的行为。 

[Test]
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方法中,最后的代码如下

 

[TestFixture]
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类的代码

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原则

转载于:https://www.cnblogs.com/wangyh/archive/2009/07/08/TDD-by-example-2.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值