目录

我们为游戏开始所写的RSpec代码实例规定了简单的交互:发送消息到输出端。接下来的响应要复杂得多。我们许拗指定一个算法:为玩家提供的答案做反馈。

对于这个算法,我们已经在Cucumber中提出了14个例子。如果算法有误,例子就会报错。必须所有的例子都通过检验,功能才算实现。那么,我们从如何着手呢?

从简单的例子开始

从商业价值的角度来看,为了让功能通过,选取场景的顺序无关紧要。因此我们可以从最简单的场景开始,一步步让整个功能通过。

现在我们寻找最容易处理的切入点。即使没有写出所有例子,也应该能猜到最简单的情况莫过于猜想全部错误,这时的mark反馈是空的。刚好这就是场景轮廓中的第一个例子,但就算写例子时它不是第一个,也应该知道这是首选的切入点。

按照下面的代码修改程序game_spec.rb

  #encoding: utf-8
  require 'spec_helper'

  module Codebreaker
    describe Game do
>>    let(:output){ double('output').as_null_object }
>>    let(:game){ Game.new(output) }

      describe "#start" do
        it "发出欢迎消息" do
          output.should_receive(:puts).with('欢迎来玩破译密码的游戏!')
          game.start('1234')
        end

        it "游戏提示" do
          output.should_receive(:puts).with('请从1-6的数字中选择4个作为猜想答案:')
          game.start('1234')
        end
      end

>>    describe "#guess" do
>>      context "没有匹配" do
>>        it "返回mark空字符串''" do
>>          game.start('1234')
>>          output.should_receive(:puts).with('')
>>          game.guess('5555')
>>        end
>>      end
>>    end
>>  end
  end

我们把let()程序块的声明提到外围,这样所有实例都可以使用它们。

执行rspec命令,得到如下输出:

1) Codebreaker::Game#guess 没有匹配 返回mark空字符串''
   Failure/Error: output.should_receive(:puts).with('')
     (Double "output").puts("")
         expected: 1 time with arguments: ("")
         received: 0 times with arguments: ("")
   # ./spec/codebreaker/game_spec.rb:25

错误信息提示我们输出参数不对。所以,我们改进guess()代码:

def guess(guess)
  @output.puts ''
end

再次运行rspec,成功通过了。现在重新运行cucumber试试看,你应该看到第一个场景已经通过了,但剩下的场景仍然未能通过。通过1个,还有13个。

最简单的例子通过后,下一步做哪个例子呢?

在剩下例子中寻找最简单的那个

这次我们选择只有一个数字通过的情况。一个数字被猜中,但位置可能正确,也可能不正确。

context "1个匹配" do
  it "返回mark为'-'" do
    game.start('1234')
    output.should_receive(:puts).with('-')
    game.guess('2555')
  end
end

运行rspec,得到:

1) Codebreaker::Game#guess 1个匹配 返回mark为'-'
   Failure/Error: output.should_receive(:puts).with('-')
     Double "output" received :puts with unexpected arguments
       expected: ("-")
            got: ("")

现在是无论何种情况都发送''到output,因此得到上面的提示。

我们开始改进代码:

def start(secret)
  @secret = secret
  @output.puts '欢迎来玩破译密码的游戏!'
  @output.puts '请从1-6的数字中选择4个作为猜想答案:'
end
def guess(guess)
  if @secret.include?(guess[0])
    @output.puts '-'
  else
    @output.puts ''
  end
end

这确实解决了问题,但运行一下rspec,测试可以通过。而运行cucumber则发现又解决了一个场景。但如果全部按照这样的思路来解决问题(这种方式也确实能解决问题),不仅需要写的重复代码会非常多,而且结构有点混乱。

重构

如同之前讨论的,重构是改进内部设计但不改变外部行为的技术。现在我们有很多方法改进设计,最常用的是删除重复部分,因此我们从这个思路开始。在guess()中有两行代码是重复的,用来发消息到output。我们做如下修改:

def guess(guess)
  if @secret.include?(guess[0])
    mark = '-'
  else
    mark = ''
  end
  @output.puts mark
end

现在运行rspec,确信还是全部通过测试的。运行cucumber也没有意外发生。继续吧!

回头继续看specs代码,增加得到一个'+'的情况:

context "1个匹配" do
  it "返回mark为'+'" do
    game.start('1234')
    output.should_receive(:puts).with('+')
    game.guess('1555')
  end
end

运行rspec,新的错误提示出来了:

1) Codebreaker::Game#guess 1个匹配 返回mark为'+'
   Failure/Error: output.should_receive(:puts).with('+')
     Double "output" received :puts with unexpected arguments
       expected: ("+")
            got: ("-")

再次改进game.rb代码:

def guess(guess)
  if guess[0] == @secret[0]
    mark = '+'
  elsif @secret.include?(guess[0])
    mark = '-'
  else
    mark = ''
  end
  @output.puts mark
end

specs测试可以通过,cucumber也通过了更多的测试。

再次重构

这一次,相信很多人已经知道要重构哪部分的设计了。可以试着自己先写一下,然后看下面的答案(让全部Cucumber通过测试的解法不止一种):

def guess(guess)
  mark1, mark2 = '', ''
  (0..3).each do |index|
    if exact_match?(guess, index)
      mark1 << '+'
    elsif number_match?(guess, index)
      mark2 << '-'
    end
  end
  @output.puts mark1 +mark2
end

def exact_match?(guess, index)
  guess[index] == @secret[index]
end

def number_match?(guess, index)
  @secret.include?(guess[index])
end

运行rspec和cucumber,全部通过测试了!

总结

在本章,我们通过简单的步骤驱动了算法的实现。我们也展示了使用重构的技巧。

刚开始,我们让最简单的实例通过测试,然后接着分析下一个实例,逐步进行。

我们发现,在这种一小步一小步的演进中,思路会逐步清晰起来。这里面,重构技术起了关键作用。