1 Bash:Mock Objects/Commands
2 ==========================
3 :author: Aaron Ball
4 :email: nullspoon@oper.io
5 :revdate: May 08, 2017
6
7 == {doctitle}
8
9 Bash is be a surprisingly versatile language. For example, I have been writing
10 tests for my Bash library code. This is quite useful and is fairly simple,
11 unless of course the library code makes use of system utilities such as id,
12 getent, getenforce, etc. that require real data and a live system to work right
13 (or wrong as the case may be).
14
15 It is at junctions like these where it seems Bash might let us down because we
16 need to do something that is seemingly far outside the capabilities of a humble
17 shell scripting language. But alas, Bash has a surprise for us.
18
19
20 Enter Bash Mock Objects
21 -----------------------
22
23 I understand this isn't exactly a mock object, but it seems the best
24 explanation for what we're doing, so we'll just proceed with it.
25
26 In this case, we need to execute something that closely mimicks a
27 https://en.wikipedia.org/wiki/Unit_testing[unit test]. Unit tests are useful
28 because they do not require any existing preconfigured system resources to run
29 successfully (eg: a real website to curl, a real directory to list, a real file
30 to stat, a real user to enumerate, etc). In unit testing, we create
31 https://en.wikipedia.org/wiki/Mock_object[mock objects] to mimic behavior of
32 the real object, including all edge cases where it might fail us. This enables
33 us to test every imaginable edge case without needing the real McCoy to perform
34 the test (also known as integration tests).
35
36 Unlike lower level programming languages (especially object oriented
37 languages), Bash primarily works with system resources via [sub]shells and
38 system binaries, and shell builtins. We don't need to mock a data object, but
39 we do need to mock system commands, their output, and return codes to
40 adequately test code responses to their different configurations.
41
42
43 Use Cases
44 ---------
45
46 When writing Bash scripts, we often make calls to commands that query system
47 resources. A few commands that come to mind are getenforce, id, getent, grep,
48 etc. For simplicity (and brevity) though, let's just discuss 'id'.
49
50 The 'id' command enumerates metadata about the specified user. This is a
51 particularly tricky command to test because it requires that a real user exist
52 to return output. That output is also unpredictable as the user may have a
53 different uid, group membership, etc, depending on which system is running the
54 test. This sounds like a perfect use case for mocking commands!
55
56
57 Application
58 -----------
59
60 We can't guarantee that a system is configured with the user we want, with the
61 right uid, and with the right groups, so we need to override the 'id' command
62 and mock the return text and exit code.
63
64 Let's write a quick override function that returns a fake user, and one that
65 indicates no user by that name exists. We'll get to how to plug this in in the
66 next section.
67
68 ----
69 id_exists_fakeuser() {
70 printf "uid=9999(fakeuser) gid=9999(fakeuser) groups=10(wheel),16(audio)\n"
71 return 0
72 }
73
74 id_not_exists() {
75 local name=${1}
76 printf "id: ‘%s’: no such user\n" "${name}" >&2
77 return 1
78 }
79 ----
80
81 If we call either of these functions, id_exists_fakeuser or id_not_exists, they
82 will return their respective output, which is designed to exactly mimic that of
83 the id command for the matching real-world scenario (user does exist, user
84 doesn't exist).
85
86 To better complete this use case, let's create a sample library function that
87 uses the 'id' command. This is a function that might exist in a bash library
88 for instance.
89
90 ----
91 user_exists() {
92 local username=${1}
93
94 id ${username} 2>/dev/null 1>/dev/null
95 [ $? -eq 0 ] && return 1
96 return 0
97 }
98 ----
99
100 This user_exists function could be very useful to determine if, well, a user
101 exists. Unfortunately however, we can't test this function because it requires
102 a real, live 'id' command along with a user database like /etc/passwd. This is
103 where bash command overriding comes in.
104
105
106 Bash Binary Override
107 --------------------
108
109 Since we can't change our library code to execute a test case (which wouldn't
110 be a valid test anyways), we need to find a way to override the call to the
111 'id' command and replace it with a call to our fake id override 'mock'
112 functions.
113
114 At first I thought I could use bash aliases to override a binary call (eg:
115 'alias id=id_exists_fakeuser'). However, bash doesn't allow aliases to be
116 passed from the parent shell to children shells. This won't work for us at all,
117 since calling the function in many cases spawns a subshell, thus unsetting our
118 alias.
119
120 After a half hour of research, I couldn't find the answer I was looking for, so
121 I opted for the best solution I could come up with: a bash wrapper function
122 (never underestimate a good wrapper function). Unlike aliases [and associative
123 arrays], Bash functions can be passed from the parent shell to children shells.
124 This enables us to override a system binary by creating a wrapper function with
125 the same name that calls our desired override function instead. Comme cette...
126
127 ----
128 mock_cmd() {
129 local command="${1:-}"
130 local override="${2:-}"
131
132 # Remove target function if one is already set
133 unset ${command}
134 # Create a wrapper function called "${command}"
135 eval "${command}() { ${override} \${@}; }"
136 }
137 ----
138
139 This _mock_cmd_ function takes two arguments: the name of the command to
140 override and the name of the function that will replace it. Using our earlier
141 override function examples, let's demonstrate. Regardez cette.
142
143 ----
144 mock_cmd 'id' 'id_exists_fakeuser'
145 ----
146
147 This will create a wrapper function named 'id', which calls the function
148 'id_exists_fakeuser', passing through all arguments that were destined for the
149 id command, to the override function.
150
151
152 Tying it Together
153 -----------------
154
155 That was a lot of concepts to explain, so let's put it all together into the
156 same place...
157
158 Set up the user library (this is the application code piece)
159
160 .lib/user.sh
161 ----
162 user_exists() {
163 local username=${1}
164
165 id ${username} 2>/dev/null 1>/dev/null
166 [ $? -eq 0 ] && return 1
167 return 0
168 }
169 ----
170
171
172 Create the user_exists mock commands for each scenario we want to test for.
173
174 .mocks/user.sh
175 ----
176 id_exists_fakeuser() {
177 printf "uid=9999(fakeuser) gid=9999(fakeuser) groups=10(wheel),16(audio)\n"
178 return 0
179 }
180
181 id_not_exists() {
182 local name=${1}
183 printf "id: ‘%s’: no such user\n" "${name}" >&2
184 return 1
185 }
186 ----
187
188 Set up the mock test library to the mock_cmd function is available to all
189 tests when they source it.
190
191 .tests/lib/mock.sh
192 ----
193 mock_cmd() {
194 local command="${1:-}"
195 local override="${2:-}"
196
197 # Remove target function if one is already set
198 unset ${command}
199 # Create a wrapper function called "${command}"
200 eval "${command}() { ${override} \${@}; }"
201 }
202 ----
203
204 Set up the user library test script. This will be the entry point for running
205 tests. This will bring it all together by sourcing our user library so its
206 functions can be called, and sourcing the mock library so we can override the
207 user library command calls.
208
209 .tests/user.sh
210 ----
211 # Include the mock testing library
212 source lib/mock.sh
213
214 # Include the user library for testing it
215 source ../lib/user.sh
216
217 test.user_exists() {
218 mock_cmd 'id' 'id_exists_fakeuser'
219
220 # This will return that fakeuser exists, with a fake metadata string.
221 user_exists 'fakeuser'
222 }
223
224 test.user_not_exists() {
225 mock_cmd 'id' 'id_not_exists'
226
227 # This will return that fakeuser does not exist
228 user_exists 'fakeuser'
229 }
230 ----
231
232
233 This is just a single test set. To scale this up to multiple libraries is
234 somewhat more complicated. To put it [somewhat] simply, I personally write a
235 run-tests.sh script at the top of the tests directory. This script loads each
236 library test set (read from a config file) in order. Each library test set
237 overrides some global variables at source time, such as TESTS, which contains
238 the names of each test function.
239
240 For each test set, iterrate over the TESTS array, executing each function mame
241 with an eval statement, and you've got a smoothly running test suite. For bonus
242 points, write a few testing libraries beyond libmock, such as libensure for
243 easy one-line value checking, liblog for easy standardized test logging output,
244 etc.
245
246
247 In Conclusion
248 -------------
249
250 This seems quite complicated at first, but once you've written the basics, it
251 all should fall into place fairly easily.
252
253 Some will likely argue that if you need to write 'mock' tests or even
254 libraries in Bash, you've exceeded the reasonable bounds for bash scripting and
255 should probably pick another language. I personally haven't arrived at that
256 conclusion yet, since no other scripting language [that isn't a shell] offers
257 such seamless integration with system utilities. It's just so easy to do system
258 task automation with bash.
259
260 On the other hand, readability and maintainability are very important. Chances
261 are, as you approach the limits of bash scripting, the pool of shell scripters
262 who can read and maintain your code will shrink the further out you go.
263 However, the same could be said for a niche testing framework for any other
264 language as well.
265
266 I don't question the usage of bash for system automation very often. When I do
267 though, it's usually when I need complex data scructures (like arrays of arrays
268 or hashes, things you can't easily pass to a subshell, etc). Though, if someone
269 would come up with a compiler language that closely mimicked C syntax and
270 functionality without losing ease of system integration, I would certainly be
271 tempted to jump ship a little more often.
272
273
274
275 [role="datelastedit"]
276 Last edited: {revdate}
277
278 // vim: set syntax=asciidoc:
|