Jex’s Note

Rails 測試

介紹

  • Rspec-rails, 是一套 Rails 的 BDD Testing framework, 非常強大
  • Factory Girl, 產生測試資料
  • Capybara, 用來搭配 Rspec 的測試工具, 可以模擬使用者操作瀏覽器
  • (??) watchr (存檔馬上測) / rcov(測試涵蓋度) / shoulda (matcher)

Gemfile

group :development, :test do
  gem "rspec"           # 可以不需要,如果要用原來的 rspec 才需引入
  gem 'rspec-rails'     # rspec 與 rails 整合的套件
  gem "factory_girl_rails"
end

安裝 rspec : rails generate rspec:install

Rspec

關於 BDD

BDD 是基於 TDD 發展出來的,不同 TDD 的地方在於 BDD 寫出系統行為的規格,好處是可以盡量避免細節的遺漏、更容易理解及維護。

觀念

  • 台灣公司普遍不重視測試,甚至大多數人沒寫過,網站也活得好好的,但不代表可以不寫
  • 測試是一種非常划算的投資,先花時間,但後面會省更多時間
  • 最容易出錯或重要的部分優先

指令

  • 執行所有測試 : rspec
  • 只跑 controllers 的測試 : rspec spec/controllers

.rspec 指令參數

當安裝完 rspec 時, `.rspec` 參數檔會自動被建立在網站根目錄
  • --color : 在執行測試時的訊息會有顏色方便閱讀
  • --require spec_helper : 載入 spec/spec_helper.rb 這個檔案
  • --warning : 為了避免干擾,移除它可以避免 rspec 與其他 gem 不相容的警告 warning: loading in progress, circular require considered harmful
  • -fs : 輸出 specdoc 文件
  • -fh : 輸出 html 文件

目錄結構

  • spec
    • rails_helper.rb (自動建立的設定檔)
    • spec_helper.rb (自動建立的設定檔)
    • models (手動建立的,用來測試 models)
    • views (手動建立的,用來測試 views)
    • controllers (手動建立的,用來測試 controllers)
      • posts_spec.rb (手動建立的,用來測試 Post controller)

語法

測試檔大致上的架構會是這樣

require 'rails_helper'              # 一定要先加上,用來讀取 rails rspec 的設定檔

# 用 describe 來包 it
describe '描述大方向要做的事情' do
  it '要做的事情的細項' do
    # rspec 的語法
  end
end

describe (= RSpec.describe) / it

describe '描述大方向要做的事情' do
describe PostController, type: :controller do

# describe 也可以 包 describe
describe 'posts/edit.html.erb' do
  # 一個 it 最好只有一種測試目的
  it "#index"               # 用 # 代表 instance method
  it ".index"               # 用 . 代表 class method
  it "render partial"       # 當錯誤發生時,會顯示 posts/edit.html.erb render partial ,所以 describe / it 命名好可以很快知道哪裡沒通過測試

  # 也可以再包一個 describe
  describe ' ... '
end

before / after

before(:all) : runs the block one time before all of the examples are run; sets the instance variables one time before all of the "it" blocks are run.
before(:each) : runs the block one time before each of your specs in the file; resets the instance variables in the before block every time an "it" block is run.

expect / to

expect(1+1).to eq(2)                                                        # 相等
expect{ post :create, post: @post_params }.to change{Post.all.size}.by(1)   # Model 新增成功 ; 判斷 Update 是否成功就直接用 eq 比值
expect{ post :destroy, id: 1 }.to change{Post.all.count}.by(-1)             # Model 刪除成功
expect(columns).not_to include('nickname')                                  # hash 是否有 'nickname'
expect(response).not_to have_http_status(302)                               # http = 200
expect(response).to render_template(:new)                                   # render 的 view
expect(response).to redirect_to(posts_path)                                 # redirect 的頁面
expect(Post.create!).to eq(Post.last)                                       # 驗證是否成功新增一筆資料
expect { ... }.to rqise_error(NotPaidError)                                 # 例外

# should 是舊語法,盡量改用 expect
tweet.status.length.should be <= 140

mock 模仿真的物件

stub 回傳設定好的值

# 用意是讓 Post 這個 model 執行 save 時都一律回傳 false, 以便測試到失敗的例子
allow_any_instance_of(Post).to receive(:save).and_return(:false)

@user = stub("user")
@user.stub(:name).and_return("Apple")                       # @user.name = "Apple"
@client = stub("client")
@client.stub_chain(:foo, :bar, :baz).and_return("blah")     # @client.foo.bar.baz = "blah"
User.stub(:find).and_return("Apple")
receive(:ping).once
receive(:ping).twice
receive(:ping).exactly(3).times
receive(:ping).at_most(3).times
receive(:ping).at_least(3).times
receive(:transfer).and_raise(TransferError)

get / post

get :index
get :edit, id: @post[:id]                           # 傳入 get 參數
post :create, post: @post_params                    # 傳入 post 的參數
post :update, post: @post_params, id: @post[:id]    # 傳入 post 及 get 的參數

pending

describe Post do
  it "has a name"     #沒有do / end
  xit "has a name"
  pending "add some examples to (or delete) #{__FILE__}"
end

Matcher

be_treu / be_afalse / be_nil / be_empty / be_blank / be_admin (target.admin?)
be_a_kind_of(Array) = be_an_instance_of(Array)
have_key(:foo) / include(4) / have(3).items (target.items.length = 3)

實際撰寫的邏輯

controller

# 一般情況 : action 是否正常
it "#index" do
    get :index
    expect(response).to have_http_status(200)
    expect(response).to render_template(:index)
end

# 將需要成功/失敗的先用 stub 處理
it "create / update 失敗" do
    先用 stub 讓 .save 失敗再做其他判斷
end

view

# 一般情況的 render
it "can render" do
    @post = Post.create(:title => "Big Title", :content => "content")
    @posts = Array.new(2, @post)
    render
    expect(rendered).to include("Big Title")
end

# render partial
it "render partial" do
    @post = Post.create(:title => "Big Title", :content => "content")
    render
    expect(response).to render_template(partial: "_form")
end

# 忽略 view helper
it "renders content when @post has content" do
    allow(view).to receive(:render_content).and_return("Stub Content")          # view 有個 render_content 的 helper, 讓它強制回傳 "Stub Content"
    render
    expect(rendered).to include("Stub Content")
end

model

# 一般情況 create
it "is accessable" do
    post = Post.create!(title: "title")
    expect(post).to eq(Post.last)
end

# validate 不通過
it "validates title" do
    expect(Post.new).not_to be_valid
end

# 關聯 many / belongs_to  # (有 gem 可以直接做這件事)
it "has_many comments" do
    post = Post.create(:title => "title", :content => "content")
    comment = Comment.create(:content => "content", :post_id => post.id)
    expect(post.comments).to include(comment)
end

routing

# 一般 route
it "#index" do
    expect(get: '/posts/1').to route_to(
        controller: 'posts',
        action: 'show',
        id: 1
    )
    可以簡化為 :
    expect(get: '/posts').to route_to("posts#index")
end

整合測試 (MVC一連串的行為測試,不再單獨測功能)

先建立 spec/requests/posts_spec.rb

# 實際模擬
describe "posts", type: :request do
    before(:all) do
        @post = Post.create(title: 'post from request spec')
    end

    it "#index" do
        get "/posts"
        expect(response).to have_http_status(200)
        expect(response).to render_template(:index)
        expect(response.body).to include('post from request spec')
    end

    it "#create" do
        params = {title: 'create title', content: 'create content'}
        post "/posts", post: params
        expect(response).to have_http_status(302)       # 會轉址
        expect(Post.last.title).to eq(params[:title])
    end
end

Factory Girl

介紹

在開始或測試一定會需要測試資料, 例如模擬真實上線時頁面的樣子,

或需要搭配 Rspec 測試 models 的 validation,

需要大量又隨機的資料自己手動新增也太辛苦, 可以藉由 Factory Girl 幫你產生

Getting started 未完成, 請勿參考…努力中:(

Factory Girl 建議是一個 model 對應一個 Factory Girl 檔案, 例如 models/user.rb 對應到 sepc/factiries/user.rb

spec/factories/user.rb

FactoryGirl.define do
  factory :user do
    name "foo"
    email "bar@foo.com"
    password "12341234"
    birthday "1982-03-30"
  end
end

未完成, 請勿參考…努力中:(

db/seeds.rb

require 'factory_girl_rails'

puts 'Creating Roles.'
%w(guest user admin super-admin super-duper-admin).each do |role|
  Role.find_or_create_by(name: role)
end

spec/factories/roles.rb:

FactoryGirl.define do
  factory :role do
    name { "role_#{rand(9999)}" }

    factory :guest_role, parent: :role do
      name 'guest'
    end

    factory :user_role, parent: :role do
      name 'user'
    end

    factory :admin_role, parent: :role do
      name 'admin'
    end
  end
end

spec/factories/users.rb:

FactoryGirl.define do
  factory :user do
    email 'user@example.com'

    factory :admin_user, parent: :user do
      email 'admin@example.com'
      after(:create) {|user| user.add_role(:admin)}
    end
  end
end

ref :

Comments