chefspec を使うと chef レシピの単体テストを記述できる。本家ドキュメント からサンプルを持って来るとこんなかんじ。
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 をあてたいとする。
module AwesomeModule
def package
"foo"
end
module_function :package
end
インストールするパッケージ名を AwesomeModule.package から取得するレシピを書いてみる。
package AwesomeModule.package do
action :install
end
chefspec 中で stub をあてて、AwesomeModule.package が返す値をチートしたい。bar
にチートしたので、bar
パッケージがインストールされるはずである。
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 するように書いてみる。
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 されても再解釈されないようにブロックする。
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 しておく (素振りは空のレシピを指定してよい)
require 'chefspec'
# load libraries of cookbook beforehand
ChefSpec::SoloRunner.converge("example::default")
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 されても再解釈されないようにブロックする。
module AwesomeModule
def package
"foo"
end
module_function :package
end unless defined?(AwesomeModule) # protect multiple loadings
これで stub/mock をあててテストが書けるようになった。