diff options
author | Aaron Ball <nullspoon@oper.io> | 2017-05-08 00:15:04 -0600 |
---|---|---|
committer | Aaron Ball <nullspoon@oper.io> | 2017-05-08 00:16:55 -0600 |
commit | 9f002e8dbe93c74a80ad8ca21ffd74cec14bd83c (patch) | |
tree | 3ebbcfb12ee49e4765264048545bed56d5a09476 /src | |
parent | 0e3ddb259f63579f007bcdc45497f718e2284664 (diff) | |
download | oper.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.ascii | 278 | ||||
-rw-r--r-- | src/index.ascii | 2 |
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] |