第7章 实现算法 《RSpec手册》
我们为游戏开始所写的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,全部通过测试了!
总结
在本章,我们通过简单的步骤驱动了算法的实现。我们也展示了使用重构的技巧。
刚开始,我们让最简单的实例通过测试,然后接着分析下一个实例,逐步进行。
我们发现,在这种一小步一小步的演进中,思路会逐步清晰起来。这里面,重构技术起了关键作用。