Nic Lin's Blog

喜歡在地上滾的工程師

Rspec 中 let / let!(驚嘆號) / Instance variables / subject 的用法與差異

通常我們在寫測試的時候,有遇到重複需要的參數,會把他拉到 let 出來寫,避免每個 example 寫了一堆事前的參數準備。

那麼 letlet! 有什麼區別呢?

Instance variables 在測試裡又可以如何運用呢?

這裡先簡單回答

  • let 只在被呼叫時觸發參數賦值
  • let! 等同於放在 before 內,不需要等到真正呼叫的時候才生成

那麼接下來就是比較透徹釐清的部分了。

關於 let

官方的例子是這樣的

$count = 0
describe "let" do
  let(:count) { $count += 1 }

  it "memoizes the value" do
    count.should == 1
    count.should == 1
  end

  it "is not cached across examples" do
    count.should == 2
  end
end

所以當 count"memoizes the value" 這個 example 被呼叫兩次時的行為分別如下

  • 第一次:執行 { $count += 1 } 並指向 count
  • 第二次:直接使用 count 的 cache 結果,而不重複執行

其實這邊很簡單,把他想成如下的概念就行

count ||= $count += 1

但是 cache 的值不會跨 example,所以在第二個 "is not cached across examples" 時,他還是會重新呼叫並賦值。

查看原始碼可以找到

def let(name, &block)
  ...
  if block.arity == 1
    define_method(name) { __memoized.fetch_or_store(name) { super(RSpec.current_example, &nil) } }
  else
    define_method(name) { __memoized.fetch_or_store(name) { super(&nil) } }
  end
  ...
end

簡單來說,我們傳遞 name&blocklet,結果會返回一個 define_method

那這樣就可以完全理解 let 做的事情了,等於是幫你在一個 example block 裡面建立一個 method 可以使用,並且呼叫過後會 cache 住,這樣也能解釋為什麼跨 example 不能共用了。

注意: let 有 lazy load 的特性,只有呼叫時才執行,如果你寫了一堆 let 但 example 裡沒使用,那麼就不會被賦值。

關於 let! (驚嘆號)

我覺得網路上有各種解釋,不如直接看原始碼還比較好懂

def let!(name, &block)
  let(name, &block)
  before { __send__(name) }
end

其實 let! 多一個驚嘆號的差別在於,他會先在 before 就幫你做了,而不會等到你真正呼叫的時候才做。

關於 Instance variables

在某些情況下,拿 instance variables 換掉 let 可以換來執行的速度提升

假設場景會在所有的 example 裡面用到 user,然而這個 user 可以是同一個沒關係

那麼你可以這樣用

before(:all) do
 @user = create(:user)
end

如此一來,當每個 example 調用 @user 時,都可以拿到已經賦值好的參數,而不會在每個 example 重複創建。

注意這邊用的是 before(:all),如果你沒有加上 :all 那麼預設是 :each 等於每個 example 都會重新賦值,這樣下來還是會慢喔。

所以在這種場景下,用 instance variables 會比 let 快上許多,避免重複創建。

但 instance variables 也不能濫用,我認為他還是有一些問題存在

  1. 如果調用時拼寫錯誤,那麼他的初始值會是 nil,可能會在不知情的情況下有難以追蹤的 bug
  2. 沒有 lazy load 的特性,放在 before 裡面就算沒用到也會執行賦值

關於 subject

一樣直接看官方原始碼

def subject(name=nil, &block)
  if name
    let(name, &block)
    alias_method :subject, name

    self::NamedSubjectPreventSuper.__send__(:define_method, name) do
        raise NotImplementedError, "`super` in named subjects is not supported"
    end
  else
    let(:subject, &block)
  end
end

重點在於呼叫 letalias_method :subject, name

所以 subject 本身就是 let,系出同源,從語言角度來看就是做 delegation

那為什麼要有 subject 存在呢?

其實他跟 should 是一組的

官方原始碼裡面有說明

# When `should` is called with no explicit receiver, the call is
# delegated to the object returned by `subject`. Combined with an
# implicit subject this supports very concise expressions.

subject 可以拿來做隱式調用 (參考 ruby china 上的帖子)

# 不用 subject
describe "Checking Account initialization" do
  it "should have balance with $50" do
    account = CheckingAccount.new(Money.new(50, :USD))
    account.should have_a_balance_of(Money.new(50, :USD))  # should_have_a_balance 是自定义 matcher
  end
end

# 使用 subject
describe CheckingAccount, "with $50" do
# 直接用的 Class Name,若此时没有显式定义 subject,那么默认的 subject 就是 CheckingAccount.new,可通过在代码中输出 subject 获知
  subject { CheckingAccount.new(Money.new(50, :USD)) }
  it { should have_a_balance_of(Money.new(50, :USD)) }
end

所以說如果是測 model,就會知道為什麼可以用 should 這種簡短的寫法了

describe Post do
  # 這裡沒有定義 subject 的話,預設就是 Post.new(因為他會直接拿 describe.class) 
  it { should belong_to(:user) }
  it { should validate_presence_of(:title) }
end

這種隱式調用的寫法容易帶來困擾,所以現在 rspec 都希望採用主動式的 expectation,所以也禁用這種 should 的寫法了(單行敘述不在此限)。

小結

  • 重複的參數用 let, 有 lazy load 特性
  • 需要跑在 before each 的 let 要加驚嘆號變成 let!
  • 跨 example 重複可以使用的參數可以塞 instance variables 會比用 let 快上許多
  • subject 其實和 should 是一組的,但為了更好讀請用 expectation 取代

參考資源

comments powered by Disqus