LoginSignup
3
3

More than 5 years have passed since last update.

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

Last updated at Posted at 2015-01-24

chefspec を使うと chef レシピの単体テストを記述できる。本家ドキュメント からサンプルを持って来るとこんなかんじ。

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 をあてたいとする。

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

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

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

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

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

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

  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 するように書いてみる。

spec/recipes/default_spec.rb
require 'chefspec'
# 先に1回素振りして 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 が利いてない。

 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 が呼ばれると

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 される。

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 されても再解釈されないようにブロックする。

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 をあてるためには以下の2つを実施する。

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

spec/spec_helper.rb
require 'chefspec'

# load libraries of cookbook beforehand
ChefSpec::SoloRunner.converge("example::default")
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

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

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

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

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3