├── .gitignore ├── Gemfile ├── README.md ├── Rakefile ├── hieradata ├── dev.yaml └── prod.yaml └── modules ├── foo ├── Rakefile ├── lib │ └── puppet │ │ └── parser │ │ └── functions │ │ └── does_something.rb ├── manifests │ ├── bar.pp │ └── baz.pp ├── spec │ ├── classes │ │ └── bar_spec.rb │ ├── defines │ │ └── baz_spec.rb │ ├── fixtures │ │ ├── manifests │ │ │ └── site.pp │ │ └── modules │ │ │ └── foo │ │ │ ├── lib │ │ │ ├── manifests │ │ │ └── templates │ ├── functions │ │ └── does_something_spec.rb │ ├── spec_helper.rb │ └── templates │ │ └── tempy_spec.rb └── templates │ └── tempy.erb └── hieradata └── spec ├── fixtures └── hieradata ├── hieradata_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | Gemfile.lock 4 | .ruby-version 5 | .ruby-gemset 6 | build.gradle 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org/' 2 | 3 | group :development do 4 | gem 'puppet' 5 | gem 'rspec-puppet' 6 | gem 'rspec-puppet-utils', '~> 2.0.1' 7 | gem 'puppetlabs_spec_helper', '~> 0.4.1' 8 | end 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unit Testing with rspec-puppet 2 | 3 | ### Update: 4 | 5 | I've created a gem for this - [rspec-puppet-utils](https://github.com/Accuity/rspec-puppet-utils) - that includes a `MockFunction` class, a `TemplateHarness` class for testing templates, and a `HierData::Validator` class for checking yaml files. I've refactored this sample project to use `rspec-puppet-utils` 6 | 7 | ## The Problem 8 | 9 | Take a look at the `foo::bar` class ([modules/foo/manifests/bar.pp](modules/foo/manifests/bar.pp)), if you want to unit test this class there are a few dependencies that ideally we'd like to mock: 10 | 11 | ##### `does_something` function 12 | This function is defined within the foo module, but we don't want to test its functionality in the tests for `foo::bar`, we would write a separate spec for this function 13 | 14 | ##### `hiera` function 15 | There are a few ways of handling Hiera with `rspec-puppet` and [`rspec-hiera-puppet`](https://github.com/amfranz/rspec-hiera-puppet), really `hiera` is just another function but we still need to get some values out of it. 16 | 17 | ##### functions from other modules 18 | I haven't included one here, but a prime example would be using a function from `stdlib`. There are ways (like librarian puppet) of bringing down other modules during your tests, but again, ideally we only want to test this specific class and not any functions that the class depends on. 19 | 20 | ##### `foo::baz` defined type ([modules/foo/manifests/baz.pp](modules/foo/manifests/baz.pp)) 21 | This is defined within the `foo` module so there's not a problem with it being missing, but `baz` references a class from another module. This isn't an ideal thing to do, but I think that sometimes it's necessary!? 22 | 23 | Regardless of whether `foo::baz` contains classes from another module, classes from the same module, or no other classes at all, we still don't want to be testing `foo::baz` in the spec for `foo::bar`. 24 | 25 | ##### `foo::dependency` class 26 | In this case `foo::dependency` is also in the `foo` module, but it's something that needs to be in the catalogue otherwise Puppet will throw an error. `foo::dependency` could also easily be `other::dependency` (again, not ideal, but possible). 27 | 28 | ## The Solution 29 | 30 | See the spec for the `bar` class ([modules/foo/spec/classes/bar_spec.rb](modules/foo/spec/classes/bar_spec.rb)). This has examples of all of the following, but there are two key parts: 31 | 32 | ##### 1. `let(:pre_condition)` 33 | 34 | This is where we mock out the other classes or defined types 35 | - `define foo::baz ($param1, $param2) {}` creates a mock `foo::baz` defined type and overrides the existing one from the module 36 | - `define foo::dependency {}` creates a mock `foo::dependency` class 37 | - `foo::dependency { "need me": }` adds a new "instance" of `foo::dependency` to the catalogue to satisfy the `require` relationship 38 | 39 | This is nothing new, there are examples of `let(:pre_condition)` all over the place, I included them here to create a complete example 40 | 41 | ##### 2. `MockFunction` 42 | 43 | The `MockFunction` class comes from [rspec-puppet-utils](https://github.com/Accuity/rspec-puppet-utils). Internally it calls `Puppet::Parser::Functions.newfunction()` If it looks familiar, that's because this is how you write custom functions for puppet. The difference is that all the new function does is call the `call` method on your `MockFunction` object and return the result. 44 | 45 | So for example, if the class you're testing calls `my_func('a_string', 3)` and expects to get `'penguin'` in return (it's a weird function I know but just run with it!) then you can mock this by doing: 46 | 47 | ```ruby 48 | MockFunction.new('my_func') { |f| 49 | f.stubs(:call).with(['a_string', 3]).returns('penguin') 50 | } 51 | ``` 52 | 53 | Note that all mock functions take one parameter, which is an array of values, like an array of args funnily enough! 54 | 55 | ## Mocking Hiera 56 | 57 | `hiera` is just another function so mock it like so: 58 | 59 | ```ruby 60 | MockFunction.new('hiera') { |f| 61 | f.stubs(:call).raises(Puppet::ParseError.new('Key not found')) 62 | f.stubs(:call).with(['my-key']).returns('badger') 63 | } 64 | ``` 65 | 66 | The block is optional but allows you to setup default behavior, like throwing an error for a key you're not expecting. Note that the error message isn't exactly the same as the one that the real `hiera` would thrown! 67 | 68 | ## Testing Custom Functions 69 | 70 | The spec for `does_something` [modules/foo/spec/functions/does_something_spec.rb](modules/foo/spec/functions/does_something_spec.rb) has a few examples of getting hold of return values, mocking internal function calls, and mocking `lookupvar()` for getting facts 71 | 72 | ## Setup 73 | 74 | To get this running for another module: 75 | - add `puppetlabs_spec_helper` to your Gemfile (or gem install) 76 | - add `rspec-puppet-utils` to your Gemfile (or gem install) 77 | - run `rspec-puppet-init` in the module root as you would normally 78 | - replace the `spec_helper.rb` file with the one from `foo` 79 | - replace the module's `Rakefile` file with the one from `foo` 80 | - copy the `Rakefile` from the root of this project (if you want to use it) 81 | 82 | I think that's it!? [puppetlabs_spec_helper](http://rubygems.org/gems/puppetlabs_spec_helper) provides a few things: 83 | - one if its dependencies is `mocha` which provides the `stubs().with().returns()` stuff 84 | - it has some nice inbuilt `rake` tasks like `help`, `spec_prep`, `spec_clean`, etc which I'll probably make more use of as my tests become more complex 85 | - provides a `scope` object that you can hook into for testing functions (example coming soon) 86 | 87 | ## Proof 88 | 89 | Almost forgot, you can run this if you want, just clone the repo, `cd` into the `foo` directory, and run `rake rspec` (or just `rake` as `rspec` is the default task). If you play around with it and manage to break it let me know, this is all new so I haven't had a chance to properly test it against loads of scenarios or the rspec-puppet matchers (the `should` things, whatever they're called). 90 | 91 | **Edit:** I did say to run `rake spec` above, but really you should run `rake rspec`. The `spec` task is provided by `puppetlabs_spec_helper/rake_tasks` along with a couple of others, by default it cleans up your fixtures dir, which can be useful (and you can use the `spec_prep` and `spec_clean` yourself if you want), but it also deletes your site.pp file which breaks these tests! 92 | 93 | ## Testing All Modules 94 | 95 | I've also put a Rakefile in what would be the root of the puppet directory (i.e. it's at the same level as the modules directory). You can run the tests for all modules by running `rake rspec` from the project's root directory (again `rspec` is the default task, so just running `rake` will work too). You can run all the specs for a specific module by running `rake rspec:[module]` e.g. `rake rspec:foo`. Running `rake help` (comes from `puppetlabs_spec_helper`) will show the full list of module tasks. 96 | 97 | **Caveat 1:** Running `rake rspec` is like `cd`ing into each module directory and running `rake rspec`, except that it isn't, so there might be some weird things to look out for!? 98 | 99 | **Caveat 2:** If you run `rake rspec` and the task for one of the modules fails, no subsequent tasks in the list will run. Ideally the tasks for all the modules should run even if one (or all) of them fail. If someone could just fix it, that would be great :) 100 | 101 | ## The End 102 | 103 | You can use this same setup to mock classes, defined types, and functions within specs for classes, defined types or functions 104 | 105 | I'm new to rspec and rspec-puppet and still relatively new to ruby so there are probably nicer/better ways to do some of this stuff but it works for me so far. 106 | 107 | Comments, questions and constructive criticism are all welcome! 108 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rspec/core/rake_task' 3 | require 'puppetlabs_spec_helper/rake_tasks' 4 | 5 | # stdlib is a good example of a module you might have a copy of in your modules dir, 6 | # but you wouldn't want to run the tests for stdlib 7 | # as you hope PuppetLabs did that before they released it! 8 | # stdlib isn't actually "installed" in this project, but it won't cause an error 9 | EXCLUDE_MODULES = ['stdlib'] 10 | 11 | MODULES = (Dir.entries('modules') - ['.', '..'] - EXCLUDE_MODULES).select {|e| File.directory?("modules/#{e}/spec") } 12 | 13 | module RSpec 14 | module Core 15 | class ModuleTask < ::RSpec::Core::RakeTask 16 | attr_accessor :module_root 17 | private 18 | def files_to_run 19 | Dir.chdir module_root do FileList[ pattern ].sort.map { |f| shellescape(f) } end 20 | end 21 | end 22 | end 23 | end 24 | 25 | desc 'Run all RSpec code examples' 26 | task :rspec do 27 | MODULES.each do |puppet_module| 28 | Rake::Task["rspec:#{puppet_module}"].reenable 29 | Rake::Task["rspec:#{puppet_module}"].invoke 30 | end 31 | end 32 | task :default => :rspec 33 | 34 | namespace :rspec do 35 | MODULES.each do |puppet_module| 36 | desc "Run #{puppet_module} RSpec code examples" 37 | RSpec::Core::ModuleTask.new(puppet_module) do |t| 38 | module_root = "modules/#{puppet_module}" 39 | t.module_root = module_root 40 | t.pattern = 'spec/**/*_spec.rb' 41 | t.rspec_opts = File.exists?("#{module_root}/spec/spec.opts") ? File.read("#{module_root}/spec/spec.opts").chomp : '' 42 | t.ruby_opts = "-C#{module_root}" 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /hieradata/dev.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | db-password : 'password' -------------------------------------------------------------------------------- /hieradata/prod.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | db-password : 'secret' -------------------------------------------------------------------------------- /modules/foo/Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rspec/core/rake_task' 3 | require 'puppetlabs_spec_helper/rake_tasks' 4 | 5 | desc "Run all RSpec code examples" 6 | RSpec::Core::RakeTask.new(:rspec) do |t| 7 | t.rspec_opts = File.exists?('spec/spec.opts') ? File.read('spec/spec.opts').chomp : '' 8 | end 9 | task :default => :rspec 10 | 11 | SPEC_SUITES = (Dir.entries('spec') - ['.', '..','fixtures']).select {|e| File.directory? "spec/#{e}" } 12 | namespace :rspec do 13 | SPEC_SUITES.each do |suite| 14 | desc "Run #{suite} RSpec code examples" 15 | RSpec::Core::RakeTask.new(suite) do |t| 16 | t.pattern = "spec/#{suite}/**/*_spec.rb" 17 | t.rspec_opts = File.exists?('spec/spec.opts') ? File.read('spec/spec.opts').chomp : '' 18 | end 19 | end 20 | end 21 | 22 | begin 23 | if Gem::Specification::find_by_name('puppet-lint') 24 | require 'puppet-lint/tasks/puppet-lint' 25 | PuppetLint.configuration.ignore_paths = ["spec/**/*.pp", "vendor/**/*.pp"] 26 | task :default => [:rspec, :lint] 27 | end 28 | rescue Gem::LoadError 29 | end 30 | -------------------------------------------------------------------------------- /modules/foo/lib/puppet/parser/functions/does_something.rb: -------------------------------------------------------------------------------- 1 | module Puppet::Parser::Functions 2 | newfunction(:does_something, :type => :rvalue) do |args| 3 | 4 | things = args[0] 5 | 6 | fqdn = lookupvar('fqdn') 7 | hostname = function_get_hostname([fqdn]) 8 | 9 | # Other really complex stuff happens here, honest! 10 | 11 | return things 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /modules/foo/manifests/bar.pp: -------------------------------------------------------------------------------- 1 | class foo::bar { 2 | 3 | $my_var = does_something('blah') 4 | $hiera_var = hiera('key') 5 | 6 | if ($my_var == 'condition') { 7 | 8 | # do stuff, maybe another 'if' or two! 9 | 10 | } 11 | 12 | foo::baz { 'bazzy bazzy baz baz': 13 | param1 => 'one', 14 | param2 => 'two', 15 | require => Foo::Dependency['need me'], 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /modules/foo/manifests/baz.pp: -------------------------------------------------------------------------------- 1 | define foo::baz ( 2 | $param1, 3 | $param2 4 | ) { 5 | 6 | other_module::thing { 'foreign thing': } 7 | 8 | } -------------------------------------------------------------------------------- /modules/foo/spec/classes/bar_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'foo::bar' do 4 | 5 | let(:pre_condition) { [ 6 | 'define foo::baz ($param1, $param2) {}', 7 | 'define foo::dependency {}', 8 | 'foo::dependency { "need me": }' 9 | ] } 10 | 11 | # Creates a function for puppet to find, and returns an object for attaching mock calls. 12 | # Note the let!() not let() 13 | let!(:does_something) { MockFunction.new('does_something') { |f| 14 | # The function will return 'I can do the thing' for any arguments passed in. 15 | f.stubs(:call).returns('I can do the thing') 16 | # The function will return 'blah blah' when 'blah' is passed in 17 | f.stubs(:call).with(['blah']).returns('blah blah') 18 | } 19 | } 20 | 21 | before(:each) do 22 | # We can mock hiera the same way we mock any other function 23 | MockFunction.new('hiera') { |f| 24 | # Sets up some mock data in hiera 25 | f.stubs(:call).with(['key']).returns('value') 26 | } 27 | end 28 | 29 | it do 30 | # For this specific test the function will return 'diff diff' when 'blah' is passed in 31 | does_something.stubs(:call).with(['blah']).returns('diff diff') 32 | should contain_foo__baz('bazzy bazzy baz baz') 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /modules/foo/spec/defines/baz_spec.rb: -------------------------------------------------------------------------------- 1 | # Imagine something that looks like spec/classes/bar_spec.rb -------------------------------------------------------------------------------- /modules/foo/spec/fixtures/manifests/site.pp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomPoulton/rspec-puppet-unit-testing/70d80eb34cb874f1508bd07a261a954ea84f322c/modules/foo/spec/fixtures/manifests/site.pp -------------------------------------------------------------------------------- /modules/foo/spec/fixtures/modules/foo/lib: -------------------------------------------------------------------------------- 1 | ../../../../lib -------------------------------------------------------------------------------- /modules/foo/spec/fixtures/modules/foo/manifests: -------------------------------------------------------------------------------- 1 | ../../../../manifests -------------------------------------------------------------------------------- /modules/foo/spec/fixtures/modules/foo/templates: -------------------------------------------------------------------------------- 1 | ../../../../templates -------------------------------------------------------------------------------- /modules/foo/spec/functions/does_something_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'does_something' do 4 | 5 | # Hook up the scope object so we can reference it later 6 | let(:scope) { PuppetlabsSpec::PuppetInternals.scope } 7 | 8 | let(:things) { ['penguin', 'badger', 'spaniel'] } 9 | 10 | before(:each) do 11 | # Functions that does_something calls internally can be mocked the same way 12 | MockFunction.new('get_hostname') { |f| f.stubs(:call).with(['host.foo.com']).returns('host') } 13 | 14 | # Mock lookupvar normally as it's a function of the scope object 15 | scope.stubs(:lookupvar).with('fqdn').returns('host.foo.com') 16 | end 17 | 18 | it 'should helpfully return whatever gets passed in' do 19 | result = scope.function_does_something [things] 20 | expect(result).to be_an Array 21 | expect(result.size).to eq things.size 22 | expect(result[0]).to eq things[0] 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /modules/foo/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec-puppet' 2 | require 'rspec-puppet-utils' 3 | require 'puppetlabs_spec_helper/module_spec_helper' 4 | 5 | fixture_path = File.expand_path(File.join(__FILE__, '..', 'fixtures')) 6 | 7 | RSpec.configure do |c| 8 | c.config = '/doesnotexist' 9 | c.module_path = File.join(fixture_path, 'modules') 10 | c.manifest_dir = File.join(fixture_path, 'manifests') 11 | c.color = true 12 | end 13 | -------------------------------------------------------------------------------- /modules/foo/spec/templates/tempy_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'tempy.erb' do 4 | 5 | let(:template) { TemplateHarness.new('spec/fixtures/modules/foo/templates/tempy.erb') } 6 | 7 | it 'should create credentials when supplied' do 8 | template.set '@class_var', 'booooooo' 9 | result = template.run 10 | expect(result).to match /This came from the class: bo/ 11 | end 12 | 13 | end 14 | -------------------------------------------------------------------------------- /modules/foo/templates/tempy.erb: -------------------------------------------------------------------------------- 1 | <% 2 | class_var = @class_var 3 | -%> 4 | This came from the class: <%= class_var %> 5 | -------------------------------------------------------------------------------- /modules/hieradata/spec/fixtures/hieradata: -------------------------------------------------------------------------------- 1 | ../../../../hieradata -------------------------------------------------------------------------------- /modules/hieradata/spec/hieradata_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'YAML hieradata' do 4 | 5 | validator = HieraData::YamlValidator.new('spec/fixtures/hieradata') 6 | validator.load_data :ignore_empty 7 | 8 | it 'passwords should be strings' do 9 | validator.validate(/-password/, [:prod]) { |v| 10 | expect(v).to be_a String 11 | } 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /modules/hieradata/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec-puppet' 2 | require 'rspec-puppet-utils' 3 | require 'puppetlabs_spec_helper/module_spec_helper' 4 | 5 | fixture_path = File.expand_path(File.join(__FILE__, '..', 'fixtures')) 6 | 7 | RSpec.configure do |c| 8 | c.config = '/doesnotexist' 9 | c.module_path = File.join(fixture_path, 'modules') 10 | c.manifest_dir = File.join(fixture_path, 'manifests') 11 | c.color = true 12 | end 13 | --------------------------------------------------------------------------------