summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAaron Ball <nullspoon@oper.io>2017-05-08 00:15:04 -0600
committerAaron Ball <nullspoon@oper.io>2017-05-08 00:16:55 -0600
commit9f002e8dbe93c74a80ad8ca21ffd74cec14bd83c (patch)
tree3ebbcfb12ee49e4765264048545bed56d5a09476 /src
parent0e3ddb259f63579f007bcdc45497f718e2284664 (diff)
downloadoper.io-9f002e8dbe93c74a80ad8ca21ffd74cec14bd83c.tar.gz
oper.io-9f002e8dbe93c74a80ad8ca21ffd74cec14bd83c.tar.xz
Wrote bash:Mock Objects/Commands
This post details how to write a framework that behaves somewhat like mock objects in other more complex languages.
Diffstat (limited to 'src')
-rw-r--r--src/bash_mock_objects_commands.ascii278
-rw-r--r--src/index.ascii2
2 files changed, 280 insertions, 0 deletions
diff --git a/src/bash_mock_objects_commands.ascii b/src/bash_mock_objects_commands.ascii
new file mode 100644
index 0000000..814f569
--- /dev/null
+++ b/src/bash_mock_objects_commands.ascii
@@ -0,0 +1,278 @@
+Bash:Mock Objects/Commands
+==========================
+:author: Aaron Ball
+:email: nullspoon@oper.io
+:revdate: May 08, 2017
+
+== {doctitle}
+
+Bash is be a surprisingly versatile language. For example, I have been writing
+tests for my Bash library code. This is quite useful and is fairly simple,
+unless of course the library code makes use of system utilities such as id,
+getent, getenforce, etc. that require real data and a live system to work right
+(or wrong as the case may be).
+
+It is at junctions like these where it seems Bash might let us down because we
+need to do something that is seemingly far outside the capabilities of a humble
+shell scripting language. But alas, Bash has a surprise for us.
+
+
+Enter Bash Mock Objects
+-----------------------
+
+I understand this isn't exactly a mock object, but it seems the best
+explanation for what we're doing, so we'll just proceed with it.
+
+In this case, we need to execute something that closely mimicks a
+https://en.wikipedia.org/wiki/Unit_testing[unit test]. Unit tests are useful
+because they do not require any existing preconfigured system resources to run
+successfully (eg: a real website to curl, a real directory to list, a real file
+to stat, a real user to enumerate, etc). In unit testing, we create
+https://en.wikipedia.org/wiki/Mock_object[mock objects] to mimic behavior of
+the real object, including all edge cases where it might fail us. This enables
+us to test every imaginable edge case without needing the real McCoy to perform
+the test (also known as integration tests).
+
+Unlike lower level programming languages (especially object oriented
+languages), Bash primarily works with system resources via [sub]shells and
+system binaries, and shell builtins. We don't need to mock a data object, but
+we do need to mock system commands, their output, and return codes to
+adequately test code responses to their different configurations.
+
+
+Use Cases
+---------
+
+When writing Bash scripts, we often make calls to commands that query system
+resources. A few commands that come to mind are getenforce, id, getent, grep,
+etc. For simplicity (and brevity) though, let's just discuss 'id'.
+
+The 'id' command enumerates metadata about the specified user. This is a
+particularly tricky command to test because it requires that a real user exist
+to return output. That output is also unpredictable as the user may have a
+different uid, group membership, etc, depending on which system is running the
+test. This sounds like a perfect use case for mocking commands!
+
+
+Application
+-----------
+
+We can't guarantee that a system is configured with the user we want, with the
+right uid, and with the right groups, so we need to override the 'id' command
+and mock the return text and exit code.
+
+Let's write a quick override function that returns a fake user, and one that
+indicates no user by that name exists. We'll get to how to plug this in in the
+next section.
+
+----
+id_exists_fakeuser() {
+ printf "uid=9999(fakeuser) gid=9999(fakeuser) groups=10(wheel),16(audio)\n"
+ return 0
+}
+
+id_not_exists() {
+ local name=${1}
+ printf "id: ā€˜%sā€™: no such user\n" "${name}" >&2
+ return 1
+}
+----
+
+If we call either of these functions, id_exists_fakeuser or id_not_exists, they
+will return their respective output, which is designed to exactly mimic that of
+the id command for the matching real-world scenario (user does exist, user
+doesn't exist).
+
+To better complete this use case, let's create a sample library function that
+uses the 'id' command. This is a function that might exist in a bash library
+for instance.
+
+----
+user_exists() {
+ local username=${1}
+
+ id ${username} 2>/dev/null 1>/dev/null
+ [ $? -eq 0 ] && return 1
+ return 0
+}
+----
+
+This user_exists function could be very useful to determine if, well, a user
+exists. Unfortunately however, we can't test this function because it requires
+a real, live 'id' command along with a user database like /etc/passwd. This is
+where bash command overriding comes in.
+
+
+Bash Binary Override
+--------------------
+
+Since we can't change our library code to execute a test case (which wouldn't
+be a valid test anyways), we need to find a way to override the call to the
+'id' command and replace it with a call to our fake id override 'mock'
+functions.
+
+At first I thought I could use bash aliases to override a binary call (eg:
+'alias id=id_exists_fakeuser'). However, bash doesn't allow aliases to be
+passed from the parent shell to children shells. This won't work for us at all,
+since calling the function in many cases spawns a subshell, thus unsetting our
+alias.
+
+After a half hour of research, I couldn't find the answer I was looking for, so
+I opted for the best solution I could come up with: a bash wrapper function
+(never underestimate a good wrapper function). Unlike aliases [and associative
+arrays], Bash functions can be passed from the parent shell to children shells.
+This enables us to override a system binary by creating a wrapper function with
+the same name that calls our desired override function instead. Comme cette...
+
+----
+mock_cmd() {
+ local command="${1:-}"
+ local override="${2:-}"
+
+ # Remove target function if one is already set
+ unset ${command}
+ # Create a wrapper function called "${command}"
+ eval "${command}() { ${override} \${@}; }"
+}
+----
+
+This _mock_cmd_ function takes two arguments: the name of the command to
+override and the name of the function that will replace it. Using our earlier
+override function examples, let's demonstrate. Regardez cette.
+
+----
+mock_cmd 'id' 'id_exists_fakeuser'
+----
+
+This will create a wrapper function named 'id', which calls the function
+'id_exists_fakeuser', passing through all arguments that were destined for the
+id command, to the override function.
+
+
+Tying it Together
+-----------------
+
+That was a lot of concepts to explain, so let's put it all together into the
+same place...
+
+Set up the user library (this is the application code piece)
+
+.lib/user.sh
+----
+user_exists() {
+ local username=${1}
+
+ id ${username} 2>/dev/null 1>/dev/null
+ [ $? -eq 0 ] && return 1
+ return 0
+}
+----
+
+
+Create the user_exists mock commands for each scenario we want to test for.
+
+.mocks/user.sh
+----
+id_exists_fakeuser() {
+ printf "uid=9999(fakeuser) gid=9999(fakeuser) groups=10(wheel),16(audio)\n"
+ return 0
+}
+
+id_not_exists() {
+ local name=${1}
+ printf "id: ā€˜%sā€™: no such user\n" "${name}" >&2
+ return 1
+}
+----
+
+Set up the mock test library to the mock_cmd function is available to all
+tests when they source it.
+
+.tests/lib/mock.sh
+----
+mock_cmd() {
+ local command="${1:-}"
+ local override="${2:-}"
+
+ # Remove target function if one is already set
+ unset ${command}
+ # Create a wrapper function called "${command}"
+ eval "${command}() { ${override} \${@}; }"
+}
+----
+
+Set up the user library test script. This will be the entry point for running
+tests. This will bring it all together by sourcing our user library so its
+functions can be called, and sourcing the mock library so we can override the
+user library command calls.
+
+.tests/user.sh
+----
+# Include the mock testing library
+source lib/mock.sh
+
+# Include the user library for testing it
+source ../lib/user.sh
+
+test.user_exists() {
+ mock_cmd 'id' 'id_exists_fakeuser'
+
+ # This will return that fakeuser exists, with a fake metadata string.
+ user_exists 'fakeuser'
+}
+
+test.user_not_exists() {
+ mock_cmd 'id' 'id_not_exists'
+
+ # This will return that fakeuser does not exist
+ user_exists 'fakeuser'
+}
+----
+
+
+This is just a single test set. To scale this up to multiple libraries is
+somewhat more complicated. To put it [somewhat] simply, I personally write a
+run-tests.sh script at the top of the tests directory. This script loads each
+library test set (read from a config file) in order. Each library test set
+overrides some global variables at source time, such as TESTS, which contains
+the names of each test function.
+
+For each test set, iterrate over the TESTS array, executing each function mame
+with an eval statement, and you've got a smoothly running test suite. For bonus
+points, write a few testing libraries beyond libmock, such as libensure for
+easy one-line value checking, liblog for easy standardized test logging output,
+etc.
+
+
+In Conclusion
+-------------
+
+This seems quite complicated at first, but once you've written the basics, it
+all should fall into place fairly easily.
+
+Some will likely argue that if you need to write 'mock' tests or even
+libraries in Bash, you've exceeded the reasonable bounds for bash scripting and
+should probably pick another language. I personally haven't arrived at that
+conclusion yet, since no other scripting language [that isn't a shell] offers
+such seamless integration with system utilities. It's just so easy to do system
+task automation with bash.
+
+On the other hand, readability and maintainability are very important. Chances
+are, as you approach the limits of bash scripting, the pool of shell scripters
+who can read and maintain your code will shrink the further out you go.
+However, the same could be said for a niche testing framework for any other
+language as well.
+
+I don't question the usage of bash for system automation very often. When I do
+though, it's usually when I need complex data scructures (like arrays of arrays
+or hashes, things you can't easily pass to a subshell, etc). Though, if someone
+would come up with a compiler language that closely mimicked C syntax and
+functionality without losing ease of system integration, I would certainly be
+tempted to jump ship a little more often.
+
+
+
+[role="datelastedit"]
+Last edited: {revdate}
+
+// vim: set syntax=asciidoc:
diff --git a/src/index.ascii b/src/index.ascii
index 587002c..e3dfc28 100644
--- a/src/index.ascii
+++ b/src/index.ascii
@@ -8,6 +8,7 @@ Index
New Posts
~~~~~~~~~
+* link:?p=bash_mock_objects_commands[Bash Mock Objects/Commands]
* link:?p=linux_development:detecting_stdout_escape_char_support[Linux Development:Detecting STDOUT Escape Char Support]
* link:?p=linux_desktop:password_management[Linux Desktop:Password Management]
* link:?p=benchmark:hgst_touro_s_1tb_7200rpm[Benchmark:HGST Touro S 1TB 7200 RPM]
@@ -101,6 +102,7 @@ Filesystesm and Storage
Scripting
^^^^^^^^^
+* link:?p=bash_mock_objects_commands[Bash Mock Objects/Commands]
* link:?p=linux_development:detecting_stdout_escape_char_support[Linux Development:Detecting STDOUT Escape Char Support]
* link:?p=understanding_the_bash_fork_bomb[Understanding the Bash Fork Bomb]
* link:?p=DNS_Backup_Script[DNS Backup Script]

Generated by cgit