Skip to content

Commit af0b7f0

Browse files
committed
(maint) Add an rspec suite to test common.sh
* Adds a little BashRspec library for simple unit testing of functions in the files/common.sh library * Adds a set of unit tests under spec/unit/bash/common_sh_spec.rb * Adds some helper context for the tests to spec/lib/contexts.rb
1 parent 4f1f649 commit af0b7f0

File tree

4 files changed

+696
-0
lines changed

4 files changed

+696
-0
lines changed

spec/bash_spec_helper.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# frozen_string_literal: true
2+
3+
require 'tmpdir'
4+
require 'rspec'
5+
require 'lib/bash_rspec'
6+
require 'lib/contexts'
7+
8+
RSpec.configure do |c|
9+
c.include BashRspec
10+
end

spec/lib/bash_rspec.rb

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
# frozen_string_literal: true
2+
3+
require 'mkmf'
4+
require 'open3'
5+
6+
# Simple harness for testing libraries of BASH shell functions within
7+
# the RSpec framework.
8+
#
9+
# This module provides a simple DSL for mocking system commands
10+
# and functions in a given script. It also provides a way to
11+
# generate a test script that sources the given script and
12+
# then executes a command for evaluation.
13+
#
14+
# The module assumes that the the file under test is located within
15+
# the repository, may be sourced as a library of bash functions
16+
# without side-effects, and that the file under test has been set via:
17+
#
18+
# subject { 'path/to/file.sh' }
19+
#
20+
# # Usage
21+
#
22+
# ## Testing a function
23+
#
24+
# The test() function returns Open3.capture2e() output and status:
25+
#
26+
# it 'tests execution of something()' do
27+
# output, status = test("something")
28+
# expect(status.success?).to be(true)
29+
# expect(output).to include('something exciting')
30+
# end
31+
#
32+
# ## Mocking a command
33+
#
34+
# The receive_command() method may be used to mock a command:
35+
#
36+
# it 'tests execution of something() but without boom' do
37+
# allow_script.to receive_command(:boom).and_exec(<<~"EOF")
38+
# echo "pretend we did something destructive instead"
39+
# EOF
40+
# output, status = test("something_that_goes_boom_inside")
41+
# expect(status.success?).to be(true)
42+
# expect(output).to include('something exciting without as much boom')
43+
# end
44+
#
45+
# ## Redeclaring a BASH function
46+
#
47+
# The redeclare() method may be used to replace a BASH function while
48+
# allowing you to call the original function:
49+
#
50+
# it 'tests execution of something() but with modified other()' do
51+
# allow_script.to redeclare(:other).and_exec(<<~"EOF")
52+
# echo "do something first"
53+
# original_other $@
54+
# EOF
55+
# output, status = test("something")
56+
# expect(status.success?).to be(true)
57+
# expect(output).to include('something exciting')
58+
# end
59+
#
60+
# ## Setting environment variables
61+
#
62+
# The set_env() method may be used to set environment variables that
63+
# the script may need for execution:
64+
#
65+
# it 'tests execution of something() with a custom env var' do
66+
# allow_script.to set_env('SOME_VAR', 'some_value')
67+
# output, status = test("echo $SOME_VAR")
68+
# expect(status.success?).to be(true)
69+
# expect(output).to eq('some_value')
70+
# end
71+
#
72+
# All of the condition behavior defined above are keyed by the name of
73+
# the command, function or variable. Multiple calls with the same key
74+
# only redefine the behavior to the last call.
75+
module BashRspec
76+
# Test whether a command is available on the system.
77+
def self.found(command)
78+
# from stdlib mkmf gem
79+
find_executable(command.to_s)
80+
end
81+
82+
# Encapsulates replaced behavior for 'receive_command'.
83+
class CommandMock
84+
# The command to be mocked.
85+
attr_reader :command
86+
# The behavior to be executed in place of the command.
87+
attr_reader :behavior
88+
89+
def initialize(command)
90+
@command = command
91+
end
92+
93+
def and_exec(behavior)
94+
@behavior = behavior
95+
self
96+
end
97+
98+
def indented_behavior
99+
behavior.split("\n").map do |line|
100+
line.sub(%r{^}, ' ')
101+
end.join("\n")
102+
end
103+
104+
def generate
105+
<<~"EOF"
106+
function #{command}() {
107+
#{indented_behavior}
108+
}
109+
EOF
110+
end
111+
end
112+
113+
# Encapsulates replaced behavior for 'redeclare' of a BASH function.
114+
class FunctionMock < CommandMock
115+
alias as and_exec
116+
117+
def generate
118+
<<~"EOF"
119+
eval "original_$(declare -f #{command})"
120+
121+
#{super}
122+
EOF
123+
end
124+
end
125+
126+
# Simple class to encapsulate environment variables that should
127+
# be set prior to sourcing the script under test.
128+
class EnvVar
129+
attr_reader :name, :value
130+
131+
def initialize(name, value)
132+
@name = name
133+
@value = value
134+
end
135+
136+
def generate
137+
%(#{name}="#{value}")
138+
end
139+
end
140+
141+
# Keeps track of the set of mocks for a given script.
142+
class ScriptMocker
143+
# The script file to be mocked.
144+
attr_reader :script
145+
# The set of CommandMock instances for the script.
146+
attr_reader :mocks
147+
# The set of EnvVar instances to set for the script.
148+
attr_reader :env_vars
149+
150+
def initialize(script)
151+
@script = script
152+
@mocks = {}
153+
@env_vars = {}
154+
end
155+
156+
def to(condition)
157+
case condition
158+
when CommandMock
159+
mocks[condition.command.to_sym] = condition
160+
when EnvVar
161+
env_vars[condition.name.to_sym] = condition
162+
else
163+
raise(ArgumentError, "BashRspec doesn't know what to do with condition: #{condition}")
164+
end
165+
self
166+
end
167+
168+
# Concatenates the set of CommandMock#behavior for the given script.
169+
def generate(type)
170+
header = case type
171+
when :mocks
172+
'mock commands'
173+
when :env_vars
174+
'environment variables'
175+
else
176+
raise(ArgumentError, "BashRspec doesn't know how to generate #{type}")
177+
end
178+
conditions = send(type).values
179+
code = conditions.map(&:generate).join("\n")
180+
<<~EOS
181+
# #{header}
182+
#{code}
183+
EOS
184+
end
185+
end
186+
187+
# Returns (and creates and caches) a ScriptMocker instance
188+
# for the given script within the current Rspec test context.
189+
def lookup(script)
190+
@scripts ||= {}
191+
@scripts[script] ||= ScriptMocker.new(script)
192+
end
193+
194+
def module_root
195+
File.expand_path(File.join(__dir__, '..', '..'))
196+
end
197+
198+
# Mock a system command.
199+
def receive_command(command)
200+
CommandMock.new(command)
201+
end
202+
203+
# Redeclare a Bash function as original_${function}()
204+
# in order to replace it with a mock that can access
205+
# it's original behavior.
206+
def redeclare(function)
207+
FunctionMock.new(function)
208+
end
209+
210+
# Set an environment variable for the script being tested.
211+
def set_env(name, value)
212+
EnvVar.new(name, value)
213+
end
214+
215+
# Begins an rspec-mocks style interface for mocking commands
216+
# in a given script file.
217+
def allow_script(script = subject)
218+
lookup(script)
219+
end
220+
221+
# Assembly of everything needed to run a test.
222+
# (Also useful for debugging.)
223+
def script_harness(script = subject)
224+
sm = lookup(script)
225+
<<~SCRIPT
226+
#{sm.generate(:env_vars)}
227+
228+
# source under test
229+
source "${MODULE_ROOT}/#{script}"
230+
231+
#{sm.generate(:mocks)}
232+
SCRIPT
233+
end
234+
235+
# Run a command scriptlet in the context of the script under test.
236+
def test(command, script: subject)
237+
test_script = <<~SCRIPT
238+
#{script_harness(script)}
239+
240+
# test code
241+
#{command}
242+
SCRIPT
243+
Open3.capture2e({ 'MODULE_ROOT' => module_root }, 'bash', '-c', test_script)
244+
end
245+
end

spec/lib/contexts.rb

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# frozen_string_literal: true
2+
3+
require 'json'
4+
5+
# Mirrors the output from puppetlabs-facts.
6+
module OBRspecFacts
7+
UBUNTU_2404 = {
8+
os: {
9+
name: 'Ubuntu',
10+
distro: {
11+
codename: 'noble',
12+
},
13+
release: {
14+
full: '24.04',
15+
major: '24',
16+
minor: '04',
17+
},
18+
family: 'Debian',
19+
},
20+
}.freeze
21+
22+
DEBIAN_13 = {
23+
os: {
24+
name: 'Debian',
25+
distro: {
26+
codename: 'trixie',
27+
},
28+
release: {
29+
full: 'n/a',
30+
major: 'n/a',
31+
minor: '',
32+
},
33+
family: 'Debian',
34+
},
35+
}.freeze
36+
37+
# This is a placeholder for an OS that openvox_bootstrap doesn't
38+
# know about yet, for testing failure cases.
39+
UNKNOWN = {
40+
os: {
41+
name: 'Unknown',
42+
distro: {
43+
codename: 'Mysterious Onions',
44+
},
45+
release: {
46+
full: '1000.99',
47+
major: '1000',
48+
minor: '99',
49+
},
50+
family: 'Unknown',
51+
},
52+
}.freeze
53+
54+
FACTS = {
55+
ubuntu2404: UBUNTU_2404,
56+
debian13: DEBIAN_13,
57+
unknown: UNKNOWN,
58+
}.freeze
59+
60+
def self.for(os)
61+
FACTS[os] || raise("Unknown OS: #{os}")
62+
end
63+
end
64+
65+
RSpec.shared_context 'bash_prep' do
66+
let(:tmpdir) { Dir.mktmpdir }
67+
let(:bash_rspec_commands_that_do_not_exist) { [] }
68+
69+
around do |example|
70+
example.run
71+
ensure
72+
FileUtils.remove_entry_secure tmpdir
73+
end
74+
75+
def behave_as_if_command_does_not_exist(*commands)
76+
bash_rspec_commands_that_do_not_exist.concat(commands)
77+
# write, or overwrite definition with new set of commands
78+
allow_script.to(
79+
redeclare(:exists).as(
80+
<<~"EOF"
81+
case $1 in
82+
#{bash_rspec_commands_that_do_not_exist.join('|')})
83+
return 1
84+
;;
85+
*)
86+
;;
87+
esac
88+
original_exists $1
89+
EOF
90+
)
91+
)
92+
end
93+
94+
# The facts/tasks/bash.sh from puppetlabs-facts is
95+
# sourced by the common.sh script, rooted from PT__installdir
96+
# as passed by Bolt.
97+
def mock_facts_task_bash_sh(os)
98+
allow_script.to set_env('PT__installdir', tmpdir)
99+
mocked_script_path = "#{tmpdir}/facts/tasks"
100+
facts = OBRspecFacts.for(os)
101+
FileUtils.mkdir_p(mocked_script_path)
102+
File.write("#{mocked_script_path}/bash.sh", <<~EOF)
103+
case $1 in
104+
platform)
105+
echo '#{facts.dig(:os, :name)}'
106+
;;
107+
release)
108+
echo '#{facts.dig(:os, :release, :full)}'
109+
;;
110+
*)
111+
echo '#{JSON.pretty_generate(facts)}'
112+
;;
113+
esac
114+
EOF
115+
end
116+
end

0 commit comments

Comments
 (0)