Chapter 14 RSpec::Mocks

Test Doubles, Method Stubs, Message Expectations

1
2
3
thingamajig_double = double('thing-a-ma-jig')
stub_thingamajig = stub('thing-a-ma-jig')
mock_thingamajig = mock('thing-a-ma-jig')

double(),stub(),mock()都会返回一个RSpec::Mocks::Mock的实例
可以在这个实例上生成method stubs和message expectations

1
2
3
4
5
6
7
8
9
10
11
12
13
describe Statement do
  it "logs a message on generate()" do
    customer = stub('customer')
    customer.stub(:name).and_return('Aslak')

    logger = mock('logger')

    statement = Statement.new(customer, logger)
    logger.should_receive(:log).with(/Statement generated for Aslak/)

    statement.generate
  end
end

这段代码中, stub('customer')和mmock('logger')分别生成了2个test double

customer.stub(:name)为customer double添加了一个method stub(打桩方法), :name为方法名 and_return('Aslak')表示:name的返回值为Aslak

logger.should_receive(:log)为logger double设置了一个对于message name() 的expectation
后面的generate()方法里, 如果loggerlog()的调用失败, 则整个example会fail
否则会判断logger.should_receive(:log)后面的条件是否满足(此处为with(xxx),即log()调用是否带参数xxx)

partial stubbing, partial mocking

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
describe WidgetsController do
  describe "PUT update with valid attributes"
    it "finds the widget"
      widget = Widget.new()
      widget.stub(:update_attributes).and_return(true)
      Widget.should_receive(:find).with("37").and_return(widget)
      put :update, :id => 37
    end

    it "updates the widget's attributes" do
      widget = Widget.new()
      Widget.stub(:find).and_return(widget)
      widget.should_receive(:update_attributes).and_return(true)
      put :update, :id => 37
    end

    it "redirects to the list of widgets"
      widget = Widget.new()
      Widget.stub(:find).and_return(widget)
      widget.stub(:update_attributes).and_return(true)
      put :update, :id => 37
      response.should redirect_to(widgets_path)
    end
  end
end

更多关于Method Stubs

One-Line Shortcut

double(),stub(),mock()第一个参数非必填但是强烈建议有, 因为其会作为失败时的消息
此外还可以接受一个hash作为第二参数

1
customer = double('customer', :name => 'Bryan')

等效于

1
2
customer = double('customer')
customer.stub(:name).and_return('Bryan')

Implementation Injection

如果一个stub method需要使用多次而且根据条件不同会有不同返回值, 可以用如下方法
多用于before()

1
2
3
4
5
6
7
8
ages = double('ages')
ages.stub(:age_for) do |what|
  if what == 'drinking'
    21
  elsif what == 'voting'
    18
  end
end

方法链

1
2
article = double()
Article.stub_chain(:recent, :published, :authored_by).and_return(article)

更多关于Message Expectations

执行次数

should_receive(:xxx)要求xxx()被调用且只调用一次, 如果希望调用若干次, 可采用下列方式

1
2
3
4
5
mock_account.should_receive(:withdraw).exactly(5).times
network_double.should_receive(:open_connection).at_most(5).times
network_double.should_receive(:open_connection).at_least(2).times
account_double.should_receive(:withdraw).once
account_double.should_receive(:deposit).twice

如果期待方法不被调用,要使用should_not_receive

1
2
3
network_double.should_not_receive(:open_connection)
network_double.should_receive(:open_connection).never              #不推荐
network_double.should_receive(:open_connection).exactly(0).times   #不推荐

指定期待的参数, with()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
### 指定一个参数, 且值为50
account_double.should_receive(:withdraw).with(50)
### 可以指定任意个参数
checking_account.should_receive(:transfer).with(50, savings_account)
### 第一个参数为给定值, 第二个参数为Fixnum型任意值
source_account.should_receive(:transfer).with(target_account, instance_of(Fixnum))
### 第一个参数可以为任意类型任意值
source_account.should_receive(:transfer).with(anything(), 50)
### 任意类型任意数量参数
source_account.should_receive(:transfer).with(any_args())
### 不传参数
collaborator.should_receive(:message).with(no_args())
### 参数为包含/不包含给定key/value的Hash
with(hash_including('Electric' => '123', 'Gas' => '234'))
with(hash_not_including('Electric' => '123', 'Gas' => '234'))
### 正则表达式
mock_atm.should_receive(:login).with(/.* User/)

自定义的Argument Matchers

自定义一个类, 然后重写==(actual)方法即可
也可以添加一个description()方法以提供失败时输出的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class GreaterThanMatcher

  def initialize(expected)
    @expected = expected
  end

  def description
    "a number greater than #{@expected}"
  end

  def ==(actual)
    actual > @expected
  end
end

def greater_than(floor)
  GreaterThanMatcher.new(floor)
end

calculator.should_receive(:add).with(greater_than(37))

Throwing or Raising

and_raise()可以不传参/一个参数(异常类或异常类实例)
and_throw()传symbol

1
2
3
4
5
6
7
account_double.should_receive(:withdraw).and_raise
account_double.should_receive(:withdraw).and_raise(InsufficientFunds)

the_exception = InsufficientFunds.new(:reason => :on_hold)
account_double.should_receive(:withdraw).and_raise(the_exception)

account_double.should_receive(:withdraw).and_throw(:insufficient_funds)

按序执行

1
2
database.should_receive(:count).with('Roster', :course_id => 37).ordered
database.should_receive(:add).with(student).ordered

只要count()add()之前执行就会pass

Chapter 13 RSpec::Expectations

assertions OR expectations
BDD里, 使用expectations替代了传统测试里的assertions
虽然作用基本是一样的, 但是2者理念不同

传统测试我们先有了代码, 于是我们可以断言(assert)一段代码执行之后会出现生么状况
但是在BDD中, 测试之前还没有代码本体. 我们把自己化身为各种角色, 做出各种行为, 然后期待(expect)会得到某样结果

13.1 should, should_not, and matchers

RSpec为所有Ruby对象添加了should()should_not()方法
每个方法可以接受一个matcher对象或一个包含了特定范围内的ruby操作符的ruby表达式作参数

1
result.should equal(5)

这段代码会先对equal(5)求值, 这是一个RSpec提供的方法, 可以返回一个matcher对象,
之后这个matcherd对象被传给result.should.
should会调用matcher.matches?, 并把self(在这里即是result)作为参数.
如果matches?(self)返回true, 则expectations通过, 开始执行example里下一行代码,
否则should()方法会向matcher索取错误信息并报ExpectationNotMetError

13.2 Built-in Matchers

1
2
3
include(item)
respond_to(message)
raise_error(type)

相等

对于ruby中的四种相等, rspec提供了以下四种对应:

1
2
3
4
a.should == b
a.should === b
a.should eql(b)
a.should equal(b)

另外还要注意 不要使用 != , 而是要用RSpec里提供的should_not方法
因为==实际是调用的ruby方法, 于是 actual.should == expected会被解释称actual.shoul. ==(expected)

actual.should != expected会被解释成!(actual.should.==(expected))
actual和expected如果不等, 则会直接报ExpectationNotMetError

浮点数

1
result.should be_close(5.25, 0.005)

多行文本

1
2
result.should match(/this expression/)
result.should =~ /this expression/

变更

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
expect {
  User.create!(:role => "admin")
}.to change{ User.admins.count }

expect {
  User.create!(:role => "admin")
}.to change{ User.admins.count }.by(1)

expect {
  User.create!(:role => "admin")
}.to change{ User.admins.count }.to(1)

expect {
  User.create!(:role => "admin")
}.to change{ User.admins.count }.from(0).to(1)

expect {
  seller.accept Offer.new(250_000)
}.to change{agent.commission}.by(7_500)

最后面那个也可以写成

1
2
3
agent.commission.should == 0
seller.accept Offer.new(250_000)
agent.commission.should == 7_500

报错

1
2
3
4
5
6
expect {
  account.withdraw 75, :dollars
}.to raise_error(
  InsufficientFundsError,
  /attempted to withdraw 75 dollars/
)

raise_error可以传0个,1个或2个参数
第一个参数可以是error class, 错误信息的字符串 或者匹配错误信息的正则表达式
如果第一个参数为error class, 可以传第二个参数, String或Regexp

手动抛出异常

1
2
3
4
5
course = Course.new(:seats => 20)
  20.times { course.register Student.new }
lambda {
  course.register Student.new
}.should throw_symbol(:course_full, 20)

参数形式与raise_error类似, 但是第一个参数必须为symbol, 第二个参数可为任意类型

13.3 Predicate Matchers

所谓predicate method, 即是以?并且返回一个boolean值的方法
在RSpec里, 可以用be_xxx, be_a_xxx, be_an_xxx 来描述一个predicate method

13.4 Be True in the Eyes of Ruby

1
2
3
4
5
6
true.should be_true
0.should be_true
"this".should be_true

false.should be_false
nil.should be_false

对于特殊的只期待true/false的场合, 可以使用equal

1
2
true.should equal(true)
false.should equal(false)

13.5 Have Whatever You Like

have_xxx

has_xxx?这类predicate method, 可以使用have_xxx的Predicate Matchers

1
2
request_parameters.has_key?(:id).should == true
request_parameters.should have_key(:id)

Owned Collections

1
field.players.select {|p| p.team == home_team }.length.should == 9

够ruby, 但是不够English, 于是可以写成

1
home_team.should have(9).players_on(field)

其中, have()返回一个无法响应players_on()方法的matcher
之后这个matcher把players_on()方法代理到home_team

这么写一来易读(从English角度), 二来可以鼓励添加诸如players_on()这样的有用的方法

Unowned Collections

对于需要描述的对象本身就是collection的情况, 需要判断其size/length

1
collection.should have(37).items

其中items是语法糖, 后面会有进一步说明

同样的, String也适用, 其中characters也是语法糖

1
"this string".should have(11).characters

除了have()以外, 还有have_at_least()have_at_most()
have_exactly()have()同义

1
2
3
day.should have_exactly(24).hours
dozen_bagels.should have_at_least(12).bagels
internet.should have_at_most(2037).killer_social_networking_apps

have()究竟如何工作的

have()方法会返回一个RSpec::Matchers::Have实例
实例里面记录了传入have()的数目作为指定collection的包含元素数目的期待值

1
result.should have(3).things

其实等价于

1
result.should(Have.new(3).things)

Have类重写了method_missing方法, 使其能记录自己无法响应的方法(在这里就是things), 并返回self
于是Have.new(3).things, 最终返回了一个包含期待的collection元素数目(3)以及可能的collection名字(things)的Have对象

紧接着, 这个Have对象被传递给了should()方法
should()调用matcher.matches?(self)

而在matches?()方法里, 首先会判断目标对象(result)是否能响应之前纪录的things方法
若能相应, 则在result.things上调用length或者size(length优先)
此时如果result.things没有lengthsize, 就会得到一个error message 如果有length/size, 便会与Have对象里记录的数目作比较, 判断example通过或者失败

如果result无法响应things, 则会在result自身调用lengthsize
之后的判断与上面一样, 返回错误信息或者比较数目是否相等

13.6 Operator Expressions

1
2
3
4
5
6
result.should == 3
result.should =~ /some regexp/
result.should be < 7
result.should be <= 7
result.should be >= 7
result.should be > 7

这些会被Ruby解释成

1
2
3
4
5
6
result.should.==(3)
result.should.=~(/some regexp/)
result.should(be.<(7))
result.should(be.<=(7))
result.should(be.>=(7))
result.should(be.>(7))

RSpec在should()返回的对象上定义了==, =~, 在be()返回对象上定义了<, <=, >=, >

13.7 Generated Descriptions

1
2
3
4
5
6
7
8
9
describe "A new chess board" do
  before(:each) do
    @board = Chess::Board.new
  end

  it "should have 32 pieces" do
    @board.should have(32).pieces
  end
end

因为example运行时输出的内容几乎和example是一样的, 于是上述内容也可以写成

1
2
3
4
describe "A new chess board" do
  before(:each) { @board = Chess::Board.new }
  specify { @board.should have(32).pieces }
end

这2段代码输出的内容都是

1
2
A new chess board
  should have 32 pieces

specify()it()一样, 都是example()的方法别名

13.8 Subjectivity

subject()相当于在before里创建一个当前example的subject(被描述的对象)

1
2
3
4
describe Person do
  subject { Person.new(:birthdate => 19.years.ago) }
  specify { subject.should be_eligible_to_vote }
end

一旦subject被声明了, should()should_not()都会被代理到subject
于是上面的代码还可以写作

1
2
3
4
describe Person do
  subject { Person.new(:birthdate => 19.years.ago) }
  it { should be_eligible_to_vote }
end

如果创建的subject在执行new时不需要参数, subject的声明也可以不写, 直接写作

1
2
3
describe RSpecUser do
  it { should be_happy }
end

=== case equality

严格说来, 这个其实跟另外三个不属于一类.

a === b 相当于 “如果我有一个贴了a标签的抽屉, 那么把b放到这个抽屉里是否可行?”

作为case equality operator, 其首要作用自然是在when/case里, 作为判断进入某个分支的依据

1
2
3
4
5
6
7
8
a = 5

case a
when Integer
  "case Integer"
when 5
  "case 5"
end

这段代码其实是会返回"case Integer"的, 因为Integer类重写了===方法

1
2
2.1.1 :043 > Integer === 5
 => true 

相应的, 重写了===的还有Range, Regexp等等

1
2
3
4
5
6
2.1.1 :054 > (1..5) === 5
 => true 
2.1.1 :055 > (1...5) === 5
 => false 
2.1.1 :058 > /(a|e|i|o|u)/ === "hello"
 => true  

对于Object#===, 事实上等价于Object#==.
但是对Object的子类, ===一般会被重写, 使其在条件表达式里有意义.

这里可以拿<=>方法做个类比

比如我们自己定义了个User类, 如果直接就User.all.sort, 是不可以的,
如果想这么用, 你就需要首先在User里面定义一个<=>方法用于比较2个user对象

稍微有点不同的是, 因为===是定义在Object里的
所以即便对自己定义的类里面不重写===方法, 一样可以对其使用case/when
但是这样会调用Object#===, 可能不会得到自定义类需要的结果.

依然用上面的例子来说 如果Integer里面没有重写===方法, 那么得到的返回值就不再是”case Integer”,而是”case 5”了

==

==是最为常用的用于比较两个对象值是否相等的方法

a == b相当于判断”a的值与b的值相同吗”

==方法经常被Object子类重写以满足其自身需求

equal?

equal?方法用来判断2个对象是否是同一个对象

a.equal? b相当于判断”a就是b吗”

1
2
3
4
5
6
2.1.1 :005 > "a" == "a"
 => true 
2.1.1 :006 > "a".equal? "a"
 => false 
2.1.1 :007 > :a.equal? :a
 => true 

==不同, equal?不应该被子类重写

eql?

==类似, 但是可以看做是更严格的==
Object里, ==eql?是同意, 但是很多子类会重写eql?以提供更严格的比较, 比如:

1
2
3
4
5
6
2.1.1 :010 > 1 == 1
 => true 
2.1.1 :011 > 1 == 1.0
 => true 
2.1.1 :012 > 1.eql? 1.0
 => false