Part III RSpec

Chapter 12 Code Examples

12.1 Describe It!

名词解释

  • subject code 以RSpec描述出其行为的代码
  • expectation 等同于Assertion(断言)
  • code example 等同于Test method,用来展示 subject code 的作用,并通过 expectation 表现其行为
  • example group 等同于Test case, 一组 code example
  • spec spec文件, 包含一个或多个 example group

例如:

1
2
3
4
5
6
describe "A new Account" do
  it "should have a balance of 0" do
    account = Account.new
    account.balance.should == Money.new(0, :USD)
  end
end

describe方法定义了一个 example group
describe传入的字符串代表我们要描述的系统的facet(一个新账户)
it方法定义 code example , 传入的字符串用来描述我们所关心的facet的行为(余额应为0)
在传入it的block中使用了 expectation (account.balance.should == Money.new(0, :USD))

describe 方法

参数有以下几种形式:

1
2
3
4
5
6
7
8
9
10
11
describe "A User" { ... }
=> A User

describe User { ... }
=> User

describe User, "with no roles assigned" { ... }
=> User with no roles assigned

describe User, "should require password length between 5 and 40" { ... }
=> User should require password length between 5 and 40

其中第一个参数可以是class\module或字符串
若为class\module并且ExampleGroup被包含在module里
则output会连module名一起输出

1
2
module Authentication
  describe User, "with no roles assigned" do

会输出Authentication::User with no roles assigned

第二个参数为字符串, 可不填

我们也可以嵌套example groups

例如:

1
2
3
describe User do
  describe "with no roles assigned" do
    it "is not allowed to view protected content" do

会输出:

1
2
3
User
  with no roles assigned
    is not allowed to view protected content

context方法

context是describe方法的别名,一般倾向于用describe描述事物, 用context表述背景(条件)
于是上面的例子一般来说会写作:

1
2
3
describe User do
  context "with no roles assigned" do
    it "is not allowed to view protected content" do

it 方法

与describe方法类似, it可接收的参数包括一个单独的字符串,一个可选的hash和一个可选的block
其字符串参数若为以’it’开头的一句话, 则代表这句话的详情会通过block里的代码表示出来.

例子:

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
26
27
28
describe Stack do
  before(:each) do
    @stack = Stack.new
    @stack.push :item
  end

  describe "#peek" do
    it "should return the top element" do
      @stack.peek.should == :item
    end

    it "should not remove the top element" do
      @stack.peek
      @stack.size.should == 1
    end
  end

  describe "#pop" do
    it "should return the top element" do
      @stack.pop.should == :item
    end

    it "should remove the top element" do
      @stack.pop
      @stack.size.should == 0
    end
  end
end

以上代码通过example group的嵌套, 将peak()pop()2个group分隔开
如果运行时加上--format documentation参数的, 会得到如下输出:

1
2
3
4
5
6
7
8
9
10
Stack
  #peek
    should return the top element
    should not remove the top element
  #pop
    should return the top element
    should remove the top element

Finished in 0.00154 seconds
4 examples, 0 failures

12.2 Pending Examples

  1. 用于代码还未实现的

    it方法里不传代码块, 则此code example会被当做pending的example

    describe Newspaper do
      it "should be read all over"
    end
    

    运行RSpec时, 会在output里有:

    Newspaper
      should be read all over (PENDING: Not Yet Implemented)  
    
    Pending:
      Newspaper should be read all over
        # Not Yet Implemented
        # ./newspaper_spec.rb:17
    

    可用于列出还未实现的功能

  2. 用于已有代码但是需要修改的

    加入pending声明, 不追加代码块, 则声明后的代码不会被执行

    describe "onion rings" do
      it "should not be mixed with french fries" do
        pending "cleaning out the fryer"
        fryer_with(:onion_rings).should_not include(:french_fry)
      end
    end
    

    可以使测试依然通过而不必将原先内容注释掉

  3. 用于bug report

    如果此bug当前并不想修改
    可以把有问题的代码置于pending下, 避免其被执行(类似上面2.的情形)

    传入pending的代码块会被执行, 若其不通过或者报错, 则会像普通pending
    否则RSpec会报PendingExampleFixedError, 提醒你此处无缘无故pending了
    然后即可将pending移除, 因为这些代码已经可以通过测试

    describe "an empty array" do
      it "should be empty" do
        pending("bug report 18976") do
          [].should be_empty
        end
      end
    end
    

    以上代码执行后会有如下输出:

    F
    
    Failures:
      1) an empty array should be empty FIXED
        Expected pending 'bug report 18976' to fail. No Error was raised.
        # ./pending_fixed.rb:4
    
    Finished in 0.00088 seconds
    1 example, 1 failure
    

12.3 Hooks: Before, After, and Around

  1. before(:each)
    对于example group中的每个example, 都会重新运行一遍

  2. before(:all)
    只在自身对象的实例中运行一次(This gets run once and only once in its own instance of Object),
    但在其中的实例变量会被copy到每个example下

    需要注意before(:all)有可能造成不同group之间的状态共享,
    所以除非特殊情况(如需打开网络连接), 尽量都用before(:each)

  3. after(:each)
    在其中的代码一定会执行, 即使examples或着其他hooks里的代码无法通过甚至报错
    after(:each)可用于恢复全局变量状态

    before(:each) do
      @original_global_value = $some_global_value
      $some_global_value = temporary_value
    end
    
    after(:each) do
      $some_global_value = @original_global_value
    end
    
  4. after(:all)
    不常用, 可用于最后关闭诸如DB连接等

  5. around(:each)
    会把当前运行的example当做代码块传入around, 然后可运行example.run
    可用于database transactions:

    around do |example|
      DB.transaction { example.run }
    end
    

    也可以把example作为代码块直接传给around里的方法, 如:

    around do |example|
      ansaction &example
    end
    

    除此之外也可以用于类似下面这种情况:

    around do |example|
      begin
        # do something
        example.run
      ensure 
        # do something else
      end
    end
    

    但是上面这种情况会降低代码可读性, 所以如遇以上情况还是使用before/after为好:

    before { do_some_stuff_before }
    after { do_some_stuff_after } 
    

Describing Features

将特性(features)整理成若干用户故事(User Stories), 可以采用role + action的形式作为故事的标题.
故事里不需要包含太多细节, 详细内容可以在选定好选取哪些故事用作发布以及哪次迭代时发布后, 再行考虑.

  • Code-breaker starts game The code-breaker opens a shell, types a command, and sees a welcome message and a prompt to enter the first guess.
  • Code-breaker submits guess The code-breaker enters a guess, and the system replies by marking the guess according to the marking algorithm.

关于User Stroies, 可以看下iHover大大的User Stories (1) 什麼是 User Story?

User Story需要拥有以下特性:

  • Have business value
  • Be testable
  • Be small enough to implement in one iteration

在项目里添加一个features目录,然后在teatures下添加support目录,
support目录里添加env.rb文件(其实*.rb便可), 这样cucumber会知道我们正在用ruby

在里features面创建一个codebreaker_submits_guess.feature文件

Scenario Example - codebreaker_submits_guess.feature
1
2
3
4
5
6
7
8
9
Feature: code-breaker submits guess
  As a code-breaker
  I want to submit a guess
  So that I can try to break the code

  Scenario: all exact matches
    Given the secret code is "1234"
    When I guess "1234"
    Then the mark should be "++++"
Scenario Outline Example - codebreaker_submits_guess.feature
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Feature: code-breaker submits guess
  As a code-breaker
  I want to submit a guess
  So that I can try to break the code

  Scenario Outline: submit guess
    Given the secret code is "<code>"
    When I guess "<guess>"
    Then the mark should be "<mark>"

  Scenarios: all numbers correct
    | code | guess | mark |
    | 1234 | 1234  | ++++ |
    | 1234 | 1243  | ++-- |
    | 1234 | 1423  | +--- |
    | 1234 | 4321  | ---- | 

Automating Features with Cucumber

features目录下创建step_definitions目录, 然后再里面添加一个codebreaker_steps.rb文件

Step Definition Methods

  • Given() 给出背景条件(context)  
  • When() 执行动作  
  • Then() 校验结果  
  • And()与But() 与上一个Given(),When(),或Then()意义相同, 只为使整个描述看起来更近似自然语言.

Describing Code with RSpec

项目下创建spec/codebreaker/, 然后在里面添加game_spec.rb
原则是每个source文件要对应一个spec文件

game_spec.rb
1
2
3
4
5
6
7
8
9
10
require 'spec_helper'

module Codebreaker
  describe Game do
    describe "#start" do
      it "sends a welcome message"
      it "prompts for the first guess"
    end
  end
end

spec/codebreaker/下添加一个spec_helper.rb

spec_helper.rb`
1
require 'codebreaker'

it()方法如果不传入代码块, 会被当做pending的方法

可以用double("xxx")方法得到一个test double(测试替身)
double("xxx").as_null_object会让替身只关心指定给它的被期待的消息, 而忽略其他消息

game_spec.rb
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
require 'spec_helper'

module Codebreaker
  describe Game do
    describe "#start" do
      it "sends a welcome message" do
        output = double('output').as_null_object
        game = Game.new(output)

        output.should_receive(:puts).with('Welcome to Codebreaker!')

        game.start
      end

      it "prompts for the first guess" do
        output = double('output').as_null_object
        game = Game.new(output)

        output.should_receive(:puts).with('Enter guess:')

        game.start
      end
    end
  end
end

before(:each) {}
传入block里的内容在每个example的顶部执行
可用这个方法创建实例变量并赋值

1
2
3
4
before(:each) do
  @output = double('output').as_null_object
  @game = Game.new(@output)
end

let(:method) {}
传入的symbol作为方法名, 传入的block被当做方法体

1
2
let(:output) { double('output').as_null_object }
let(:game) { Game.new(output) }

其实这个坑经开了有些日子, 测试一直都是自己弱项.
最初在云清扬时就很少写, 后来开始搞爱豆网人力有限加之是摸索中需求不断变化的初创, 索性一行测试代码都没有.
现在在做对日外包, 日方那里直接要求的就是完全肉测然后上测试式样书, 更是用不着写测试.
适逢DHH大神前不久的TDD is dead. Long live testing., 引发关于测试的各种大讨论

但是无论大神观点如何, 毕竟自己只是一枚小小的程序猿, 而TDD作为一种很成熟开发方式, 在很多情况下依然会是行之有效的.
我一直信奉”存在即合理”这种观点.

碰巧这段时间开发进度不是很忙, 又快赶上5.1的三天假期, 应该可以把手头的啃掉.
然后把chat_demo改以书中所倡导的BDD的方式改善下, 通过实践来加深理解.

Part I Getting Started with RSpec and Cucumber

什么是TDD

TDD(Test-Driven Development)这个词并不陌生, 曾经开发亲情网时整个team也煞有介事的有过一小段时间的BDD尝试.
但是归根结底, TDD其重点应该落在Development上, 是一种包含需求分析,设计,测试,编码于一体的开发方法, 而并不仅仅是一种写Test的手段.

TDD要求我们在开发时先写出一个简单的测试, 这时运行测试一定是无法通过的, 因为还没开始编码; 然后编写最低限度的代码, 使测试通过.
一旦测试通过后, 需要重新审视我们的设计并重构代码去除冗余. 此时我们手头的代码, 毫无疑问地太过简单而无法处理全部需求.
相对于直接添加代码, 我们此时该做的是在测试里增加新的特性让测试失败, 然后再编写最低限度通过测试的代码, 回顾设计, 重构…
如此反复, 直到我们完成整个功能.

整个这个循环往复的过程, 又被称为红绿重构(red/green/refactor).

有些时候, 我们即是开发者又是测试者. 如果遇到这种情况, 把test与TDD的情境区分开依然是有帮助的: 作为TDDer时, 把注意力集中在红绿重构,设计,规范要求上; 而作为tester时, 则需要尽可能考诸如虑如何设置边界条件,如何发掘隐藏的bug等等.

那么BDD又是什么

我们测试一个对象内部结构的问题在于: 我们只是测试了这个对象是什么,而没有关心它可以做些什么. 而后者无疑远比前者更为重要.

BDD(Behaviour-Driven Development)则把目光着眼于行为(做什么)而非结构(是什么).
它将程序以更近似自然语言的方式, 描述为一个个场景(scenario): Given some context, When some event occurs, Then I expect some outcome. 这样做可以大幅降低沟通成本.

通过Given, When, Then三位一体的方式, 可以很容易的描述出程序的行为以及对象的行为. 并且这种描述, 分析人员,测试人员,卡发人员都能很好地理解.

BDD需要些什么

RSpec
1
rspec [options] [files or directories]
cucumber
1
cucumber [options] [ [FILE|DIR|URL][:LINE[:LINE]*] ]+

在rails项目里添加RSpec和cucumber

添加进Gemfile
1
2
3
4
5
6
7
8
9
group :development, :test do
  gem 'rspec'
  gem 'rspec-rails'
  gem 'cucumber'
  gem 'cucumber-rails'
  gem 'database_cleaner'
  gem 'webrat'
  gem 'selenium-client'
end

运行script/rails generate rspec:install
会在项目跟目录下生成 spec/spec_helper.rb.rspec
.rspec文件为RSpec的配置文件, 可以放在项目根目录下, 或放在主目录/home/xxx/下

.rspec
1
2
3
--color
--format doc
--backtrace

Part II Behaviour-Driven Development

The Principles of BDD

Enough is enough 过犹不及, 计划/分析/设计仅仅足够开始即可
Deliver stakeholder value 不做不产生价值的事 It’s all behavior RSpec描述程序行为, cucumber描述用户行为

What’s in a Story?

A title
我们可以通过title知道我们在讨论哪个故事

A narrative
可以采用三段式的故事描述:

as a [stakeholder],
I want [feature]
so that [benefit].

或者:

in order to [benefit],   a [stakeholder]
wants to [feature].

更突出行为的目的角度看, 后者更佳

Acceptance criteria
据此评判我们何时算是干完了
acceptance criteria包含一系列由独立steps组成的scenarios