chefspec で libraries で定義した module_function に stub をあてる

chefspec を使うと chef レシピの単体テストを記述できる。[本家ドキュメント](http://sethvargo.github.io/chefspec/) からサンプルを持って来るとこんなかんじ。

```rb:spec/recipes/default_spec.rb
require 'chefspec'

describe 'example::default' do
  let(:chef_run) { ChefSpec::SoloRunner.converge(described_recipe) }

  it 'installs foo' do
    expect(chef_run).to install_package('foo')
  end
end
```

この中で、cookbook の libraries で定義したモジュールに stub をあてようとしてもあたらず、ハマってたのでメモ。

## 問題

cookbook の libraries ディレクトリ以下に独自のモジュールを定義していて、テスト中でそのライブラリへの stub をあてたいとする。

```rb:libraries/awesome_module.rb
module AwesomeModule
  def package
    "foo"
  end
  module_function :package
end
```

インストールするパッケージ名を AwesomeModule.package から取得するレシピを書いてみる。

```rb:recipes/default.rb
package AwesomeModule.package do
  action :install
end
```

chefspec 中で stub をあてて、AwesomeModule.package が返す値をチートしたい。`bar` にチートしたので、`bar` パッケージがインストールされるはずである。


```rb:spec/recipes/default_spec.rb
require 'chefspec'

describe 'example::default' do
  let(:chef_run) { ChefSpec::SoloRunner.converge(described_recipe) }

  it 'installs bar' do
    allow(AwesomeModule).to receive(:package) { 'bar' }
    expect(chef_run).to install_package('bar')
  end
end
```

これが利かない。まずは以下のようなエラーが出る。

```ruby
  1) example::default installs foo
     Failure/Error: allow(AwesomeModule).to receive(:package) { 'bar' }
     NameError:
       uninitialized constant AwesomeModule
     # ./spec/recipes/default_spec.rb:9:in `block (2 levels) in <top (required)>'
```

ならばと以下のように、前もってcookbook を load するように書いてみる。

```rb:spec/recipes/default_spec.rb
require 'chefspec'
# 先に１回素振りして cookbook を load してもらっておく
ChefSpec::SoloRunner.converge("example::default")

describe 'example::default' do
  let(:chef_run) { ChefSpec::SoloRunner.converge(described_recipe) }

  it 'installs bar' do
    allow(AwesomeModule).to receive(:package) { 'bar' }
    expect(chef_run).to install_package('bar')
  end
end
```

が、次のようにテストが失敗する。`foo` のままになっており stub が利いてない。

```ruby
 1) example::default installs foo
     Failure/Error: expect(chef_run).to install_package('bar')
       expected "package[bar]" with action :install to be in Chef run. Other package resources:

         package[foo]
```

## 原因

コードを追って解説

ChefSpec::SoloRunner.converge が呼ばれると

```rb
From: /opt/chef/embedded/lib/ruby/gems/2.1.0/gems/chefspec-4.2.0/lib/chefspec/solo_runner.rb @ line 106 ChefSpec::SoloRunner#converge:

    105: def converge(*recipe_names)
    106:   node.run_list.reset!
    107:   recipe_names.each { |recipe_name| node.run_list.add(recipe_name) }
    108:
    109:   return self if dry_run?
    110:
    111:   # Expand the run_list
    112:   expand_run_list!
    113:
    114:   # Setup the run_context
 => 115:   @run_context = client.setup_run_context
    116:
    117:   # Allow stubbing/mocking after the cookbook has been compiled but before the converge
    118:   yield if block_given?
    119:
    120:   @converging = true    121:   @client.converge(@run_context)
    122:   self
    123: end
```

`Chef::Client#setup_run_context` が呼ばれ、紆余曲折あって、`Chef::RunContext::CookbookCompiler#load_libraries_from_cookbook` の中で、libraries 内のファイルが `Kernel.load` される。

```ruby
187       def load_libraries_from_cookbook(cookbook_name)
188         files_in_cookbook_by_segment(cookbook_name, :libraries).each do |filename|
189           begin
190             Chef::Log.debug("Loading cookbook #{cookbook_name}'s library file: #{filename}")
191             Kernel.load(filename)
192             @events.library_file_loaded(filename)
193           rescue Exception => e
194             @events.library_file_load_failed(filename, e)
195             raise
196           end
197         end
198       end
```

問題は `require` ではなく、`load` である点で、`load` されると libraries 内のファイルが再度解釈されるため、stub を当てたメソッドが、再度 libraries のファイルの内容で上書きされて、効果が消えてしまうというわけだ。

## 対処法

libraries が複数回 load されても再解釈されないようにブロックする。

```rb:libraries/awesome_module.rb
module AwesomeModule
  def package
    "foo"
  end
  module_function :package
end unless defined?(AwesomeModule) # protect multiple loadings
```

これで stub が利くようになる。

PS. 本当は chefspec 側で対処したかった(プルリクエストを送りたかった)のだが、`load` しているのは chefspec ではなく、chef なので対処しようがなかった。chef にとっても chefspec 側の問題でしかないので、そのための対応が入る事はないだろう。

## まとめ

chefspec で libraries で定義したモジュールに stub をあてるためには以下の２つを実施する。

１. spec の中で SoloRunner を前もって素振りし、libraries を load しておく (素振りは空のレシピを指定してよい)


```ruby:spec/spec_helper.rb
require 'chefspec'

# load libraries of cookbook beforehand
ChefSpec::SoloRunner.converge("example::default")
```

```ruby:spec/recipes/default_spec.rb
require_relative 'spec_helper'

describe 'example::default' do
  let(:chef_run) { ChefSpec::SoloRunner.converge(described_recipe) }

  it 'installs bar' do
    allow(AwesomeModule).to receive(:package) { 'bar' }
    expect(chef_run).to install_package('bar')
  end
end
```

２. libraries で `unless defined?` を付けて、複数回 load されても再解釈されないようにブロックする。

```rb:libraries/awesome_module.rb
module AwesomeModule
  def package
    "foo"
  end
  module_function :package
end unless defined?(AwesomeModule) # protect multiple loadings
```

これで stub/mock をあててテストが書けるようになった。