commit ea4b02497c17e243e2a583550fd213cb2dde5e7d Author: James Dinsdale Date: Mon Sep 5 22:10:10 2016 +0100 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a29667 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/zunit diff --git a/.guardian.yml b/.guardian.yml new file mode 100644 index 0000000..e4eb751 --- /dev/null +++ b/.guardian.yml @@ -0,0 +1,5 @@ +files: ./src/**/* +run: make build && make test +--- +files: ./tests/**/* +run: make test diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..567b62c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 James Dinsdale (molovo.co) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..99bbade --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +build: + cat src/* > zunit + +test: + zunit diff --git a/README.md b/README.md new file mode 100644 index 0000000..6104f85 --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# ZUnit + +ZUnit is a powerful unit testing framework for ZSH + +## Installation + +### [Zulu](https://github.com/zulu-zsh/zulu) + +```sh +zulu install zunit +``` + +### Manual + +```sh +git clone https://github.com/molovo/zunit +cd ./zunit +make +chmod u+x ./zunit +cp ./zunit /usr/local/bin +``` + +> For best results, the utilities [Color](https://github.com/molovo/color) and [Revolver](https://github.com/molovo/revolver) should be installed, and in your `$PATH`. The zulu installation method will install these dependencies for you. + +## Writing Tests + +### Test syntax + +Tests in ZUnit have a simple syntax, which is inspired by the [BATS](https://github.com/sstephenson/bats) framework. + +```sh +#!/usr/bin/env zunit + +@test 'My first test' { + # Test contents here +} +``` + +The body of each test can contain any valid ZSH code. The zunit shebang `#!/usr/bin/env zunit` **MUST** appear at the top of each test file, or ZUnit will not run it. + +### Assertions + +ZUnit comes with a powerful assertion library to aid you in writing tests. The `assert` helper function allows you to access each of the available assertions with a readable syntax. + +The following assertions are available: + +#### equals + +Asserts that two integers are equal to each other. + +```sh +assert 1 equals 1 +``` + +#### not_equal_to + +Asserts that two integers are not equal to each other. + +```sh +assert 1 not_equal_to 0 +``` + +#### same_as + +Asserts that two strings are equal to each other. + +```sh +assert 'test' same_as 'test' +``` + +#### different_to + +Asserts that two strings are not equal to each other. + +```sh +assert 'rainbows' different_to 'unicorns' +``` + +#### is_empty + +Asserts that a string has a length of zero. + +```sh +value='' +assert "$value" is_empty +``` + +#### is_not_empty + +Asserts that a string has a length of greater than zero. + +```sh +value='rainbows' +assert $value is_not_empty +``` + +#### matches + +Asserts that a string matches a regular expression. + +```sh +assert 'unicorns' matches '[a-z]{8}' +``` + +#### does_not_match + +Asserts that a string does not match a regular expression. + +```sh +assert 'rainbows' does_not_match '[0-9]+' +``` + +#### in + +Asserts that a value is included in the comparison array. + +```sh +assert 'a' in 'a' 'b' 'c' +``` + +#### not_in + +Asserts that a value is not included in the comparison array. + +```sh +asserts 'a' not_in 'x' 'y' 'z' +``` + +### Loading scripts + +Each of your tests is run in isolation, meaning that there is no variable or function leakage between tests. The `load` helper function will source a script into the test environment for you, allowing you to set up variables and functions etc. + +You can load any absolute or relative file path, and for files ending in `.zsh` including the extension is optional. + +```sh +# In /mypet.zsh +testing='Tada!' + +# In /tests/myscript.zunit +@test 'Test loading scripts' { + testing='' + + load ../myscript + + assert $testing is_not_empty + assert $testing same_as 'Tada!' +} +``` + +### Running commands + +You can run commands within your tests using the `run` helper, allowing you to make assertions on their exit status and output. + +```sh +@test 'Test command output' { + # Run the command, including arguments + run ls ~/my-dir + + # $state contains the exit status + assert $state equals 0 + + # The command's output is stored in $output + assert $output is_not_empty + + # Each line of the output is also stored in + # the $lines array, allowing you to run assertions + # against individual lines of the output + assert "$lines[3]" equals 'my-third-file' +} +``` + +## Running Tests + +The CLI program `zunit` is used to run tests. + +```sh +# Runs all test files in ./tests +zunit + +# Runs all test files in ./other_tests +zunit other_tests + +# Runs all tests in the file ./tests/a-test-file +zunit tests/a-test-file +``` diff --git a/src/assertions b/src/assertions new file mode 100644 index 0000000..ab15c75 --- /dev/null +++ b/src/assertions @@ -0,0 +1,129 @@ +#!/usr/bin/env zsh + +### +# Assert that two integers are equal +### +function _zunit_assert_equals() { + local value=$1 comparison=$2 + + [[ $value -eq $comparison ]] && return 0 + + echo "'$value' is not equal to '$comparison'" + exit 1 +} + +### +# Assert that two integers are not equal +### +function _zunit_assert_not_equal_to() { + local value=$1 comparison=$2 + + [[ $value -ne $comparison ]] && return 0 + + echo "'$value' is equal to '$comparison'" + exit 1 +} + +### +# Assert that two string are the same +### +function _zunit_assert_same_as() { + local value=$1 comparison=$2 + + [[ $value = $comparison ]] && return 0 + + echo "'$value' is not the same as '$comparison'" + exit 1 +} + +### +# Assert that two string are different +### +function _zunit_assert_different_to() { + local value=$1 comparison=$2 + + [[ $value != $comparison ]] && return 0 + + echo "'$value' is the same as '$comparison'" + exit 1 +} + +### +# Assert that a value is empty +### +function _zunit_assert_is_empty() { + local value=$1 + + [[ -z ${value[@]} ]] && return 0 + + echo "'${value[@]}' is not empty" + exit 1 +} + +### +# Assert that a value is not empty +### +function _zunit_assert_is_not_empty() { + local value=$1 + + [[ -n ${value[@]} ]] && return 0 + + echo "value is empty" + exit 1 +} + +### +# Assert that the value matches a regex pattern +### +function _zunit_assert_matches() { + local value=$1 pattern=$2 + + [[ $value =~ $pattern ]] && return 0 + + echo "'$value' does not match /$pattern/" + exit 1 +} + +### +# Assert that the value does not match a regex pattern +### +function _zunit_assert_does_not_match() { + local value=$1 pattern=$2 + + [[ ! $value =~ $pattern ]] && return 0 + + echo "'$value' matches /$pattern/" + exit 1 +} + +### +# Assert that a value is found in an array +### +function _zunit_assert_in() { + local i found=0 value=$1 array=(${(@)@:2}) + + for i in ${(@f)array}; do + [[ $i = $value ]] && found=1 + done + + [[ $found -eq 1 ]] && return 0 + + echo "'$value' is not in (${(@z)array})" + exit 1 +} + +### +# Assert that a value is not found in an array +### +function _zunit_assert_not_in() { + local i found=0 value=$1 array=(${(@)@:2}) + + for i in ${(@f)array}; do + [[ $i = $value ]] && found=1 + done + + [[ $found -eq 0 ]] && return 0 + + echo "'$value' is in (${(@z)array})" + exit 1 +} diff --git a/src/helpers b/src/helpers new file mode 100644 index 0000000..95b1f70 --- /dev/null +++ b/src/helpers @@ -0,0 +1,76 @@ +#!/usr/bin/env zsh + +### +# Find a file, and load it into the environment +### +function load() { + local name="$1" + local filename + + # If filepath is absolute, then use it as is + if [[ "${name:0:1}" = "/" ]]; then + filename="${name}" + # If it's relative, prepend the test directory + else + filename="$testdir/${name}" + fi + + # Check if the file exists + if [[ -f "$filename" ]]; then + # Source the file and exit if it's found + source "$filename" + return 0 + fi + + # Perform the check again, adding the .zsh extension + if [[ -f "$filename.zsh" ]]; then + # Source the file and exit if it's found + source "$filename.zsh" + return 0 + fi + + # Output an error message to the user + echo "File $filename does not exist" >&2 + exit 1 +} + +### +# Run an external command and capture its output and exit status +### +function run() { + # Stop the shell from exiting on error temporarily + unsetopt ERR_EXIT + + # Preserve current $IFS + local oldIFS=$IFS + + # Store lines of output in an array + IFS=$'\n' lines=($("$@" 2>&1)) + + # Get the process exit state + state="$?" + + # Store the full output in a variable + output=${lines[@]} + + # Restore $IFS + IFS=$oldIFS + + # Restore the exit on error state + setopt ERR_EXIT +} + +### +# Redirect the assertion shorthand to the correct function +### +function assert() { + local value=$1 assertion=$2 comparisons=${(@)@:3} + + if (( ! $+functions[_zunit_assert_${assertion}] )); then + echo "$(color red "Assertion $assertion does not exist")" + fi + + "_zunit_assert_${assertion}" $value $comparisons + + return $? +} diff --git a/src/zunit b/src/zunit new file mode 100755 index 0000000..09f5d15 --- /dev/null +++ b/src/zunit @@ -0,0 +1,220 @@ +#!/usr/bin/env zsh + +local base=$(realpath ${0%/*}) + +### +# Output usage information and exit +### +function _zunit_usage() { + echo "\033[0;33mUsage:\033[0;m" + echo " zunit [options] " + echo + echo "\033[0;33mOptions:\033[0;m" + echo " -h, --help Output help text and exit" + echo " -v, --version Output version information and exit" +} + +### +# Execute a test and store the result +### +_zunit_execute_test() { + local name="$1" body="$2" + + if [[ $body && $name ]]; then + # Update the progress indicator + revolver update "${name}" + sleep 0.1 + + # Make sure we don't already have a function defined + (( $+functions[__zunit_tmp_test_function] )) && \ + unfunction __zunit_tmp_test_function + + # Quietly eval the body into a variable as a first test + output=$(eval $(echo "function __zunit_tmp_test_function() {\n setopt ERR_EXIT;\n integer state;\n local output;\n typeset -a lines;\n${body}") 2>&1) + + # Check the status of the eval, and output any errors + if [[ $? -ne 0 ]]; then + echo "$(color red '✘' ${name})" + echo " $(color red underline "Failed to parse test body")" + echo " $(color red $output)" + return 1 + fi + + # Run the eval again, this time within the current context so that + # the function is registered in the current scope + eval $(echo "function __zunit_tmp_test_function() {\n setopt ERR_EXIT;\n integer state;\n local output;\n typeset -a lines;\n${body}") 2>/dev/null + + # Any errors should have been caught above, but if the function + # does not exist, we can't go any further + if (( ! $+functions[__zunit_tmp_test_function] )); then + echo "$(color red '✘' ${name})" + echo " $(color red underline "Failed to parse test body")" + return 1 + fi + + # Execute the test body, and capture its output + output="$(__zunit_tmp_test_function 2>&1)" + + # Output the result to the user + if [[ $? -eq 0 ]]; then + echo "$(color green '✔') ${name}" + return 0 + else + echo "$(color red '✘' ${name})" + if [[ -n $output ]]; then + echo " $(color red "${output}")" + fi + return 1 + fi + fi +} + +### +# Run all tests within a file +### +function _zunit_run_testfile() { + local testbody testname pattern testfile="$1" testdir="$(dirname "$testfile")" + + # A regex pattern to match test declarations + pattern='^ *@test *([^ ].*) *\{ *(.*)$' + + # Loop through each of the lines in the file + IFS=$'\n' lines=($(cat $testfile)) + for line in $lines[@]; do + # Match current line against pattern + if [[ "$line" =~ $pattern ]]; then + # If the pattern matched, we've reached the next test, so the first thing + # we do is execute the previous one if it exists + _zunit_execute_test "$testname" "$testbody" + + # Now the test has been run, we can reset + # the testname and testbody variables + testbody='' + testname='' + + # Get test name from matches + testname=${line[(( ${line[(i)[\']]}+1 )),(( ${line[(I)[\']]}-1 ))]} + else + [[ -z $testname ]] || testbody+="$line\n" + fi + done + + # Since the loop has finished, we've reached the end of the file, + # but the last test won't have been executed yet, so we do it here + [[ -z $testname ]] || _zunit_execute_test "$testname" "$testbody" + + # Reset the testbody and testname variables ready for the next file + testbody='' + testname='' +} + +### +# Parse a list of arguments +### +function _zunit_parse_argument() { + local argument="$1" + + # If the argument begins with an underscore, then it + # should not be run, so we skip it + if [[ "${argument:0:1}" = "_" || "$(basename $argument | cut -c 1)" = "_" ]]; then + return + fi + + # If the argument is a directory + if [[ -d $argument ]]; then + # Loop through each of the files in the directory + for file in $(find $argument -depth 1); do + # Run it through the parser again + _zunit_parse_argument $file + done + + return + fi + + # If it is a valid file + if [[ -f $argument ]]; then + # Grab the first line of the file + line=$(cat $argument | head -n 1) + + # Check for the zunit shebang + if [[ $line = "#!/usr/bin/env zunit" ]]; then + # Add it to the array + testfiles[(( ${#testfiles} + 1 ))]=($argument) + return + fi + + # The test file does not contain the zunit shebang, therefore + # we can't trust that running it will not be harmful, and throw + # a fatal error + echo $(color red "File '$argument' is not a valid zunit test file") >&2 + echo "Test files must contain the following shebang on the first line" >&2 + echo " #!/usr/bin/env zunit" >&2 + exit 1 + fi + + # The file could not be found, so we throw a fatal error + echo $(color red "Test file '$argument' could not be found") >&2 + exit 1 +} + +### +# Run tests +### +function _zunit_run() { + local arguments=("$@") testfiles=() + + # Start the progress indicator + revolver start 'Loading tests' + + # Source the helper functions before starting + # source "$base/src/helpers" + + # If no arguments are passed, use the current directory + if [[ ${#arguments} -eq 0 ]]; then + arguments=("tests") + fi + + # Loop through each of the passed arguments + local argument + for argument in $arguments; do + # Parse the argument, so that we end up with a list of valid files + _zunit_parse_argument $argument + done + + # Loop through each of the test files and run them + local line + for testfile in $testfiles; do + _zunit_run_testfile $testfile + done + + revolver stop +} + +### +# The main zunit process +### +function _zunit() { + local help version ctx="$1" + + zparseopts -D \ + h=help -help=help \ + v=version -version=version + + # If the help option is passed, + # output usage information and exit + if [[ $help ]]; then + _zunit_usage + exit 0 + fi + + # If the version option is passed, + # output version information and exit + if [[ $version ]]; then + echo '0.1.0' + exit 0 + fi + + _zunit_run "$@" +} + +_zunit "$@" diff --git a/tests/_support/script-with-global-variable.zsh b/tests/_support/script-with-global-variable.zsh new file mode 100644 index 0000000..d03f6b3 --- /dev/null +++ b/tests/_support/script-with-global-variable.zsh @@ -0,0 +1,3 @@ +#!/usr/bin/env zsh + +lost_city='Atlantis' diff --git a/tests/_support/script-with-local-variable.zsh b/tests/_support/script-with-local-variable.zsh new file mode 100644 index 0000000..686c901 --- /dev/null +++ b/tests/_support/script-with-local-variable.zsh @@ -0,0 +1,3 @@ +#!/usr/bin/env zsh + +local lost_city='Atlantis' diff --git a/tests/assertions.zunit b/tests/assertions.zunit new file mode 100644 index 0000000..094e717 --- /dev/null +++ b/tests/assertions.zunit @@ -0,0 +1,137 @@ +#!/usr/bin/env zunit + +@test 'Test _zunit_assert_equals success' { + run assert 1 equals 1; + assert "$state" equals 0; + assert "$output" is_empty; +} + +@test 'Test _zunit_assert_equals failure' { + run assert 1 equals 0; + assert "$state" equals 1; + assert "$output" same_as "'1' is not equal to '0'"; +} + +@test 'Test _zunit_assert_not_equal_to success' { + run assert 1 not_equal_to 0; + assert "$state" equals 0; + assert "$output" is_empty; +} + +@test 'Test _zunit_assert_not_equal_to failure' { + run assert 1 not_equal_to 1; + assert "$state" equals 1; + assert "$output" same_as "'1' is equal to '1'"; +} + +@test 'Test _zunit_assert_same_as success' { + run assert 'test' same_as 'test'; + assert "$state" equals 0; + assert "$output" is_empty; +} + +@test 'Test _zunit_assert_same_as failure' { + run assert 'test' same_as 'wrong'; + assert "$state" equals 1; + assert "$output" same_as "'test' is not the same as 'wrong'"; +} + +@test 'Test _zunit_assert_different_to success' { + run assert 'test' different_to 'wrong'; + assert "$state" equals 0; + assert "$output" is_empty; +} + +@test 'Test _zunit_assert_different_to failure' { + run assert 'test' different_to 'test'; + assert "$state" equals 1; + assert "$output" same_as "'test' is the same as 'test'"; +} + +@test 'Test _zunit_assert_is_empty success' { + run assert '' is_empty; + assert "$state" equals 0; + assert "$output" is_empty; +} + +@test 'Test _zunit_assert_is_empty failure' { + run assert 'notempty' is_empty; + assert "$state" equals 1; + assert "$output" same_as "'notempty' is not empty"; +} + +@test 'Test _zunit_assert_is_not_empty success' { + run assert 'notempty' is_not_empty; + assert "$state" equals 0; + assert "$output" is_empty; +} + +@test 'Test _zunit_assert_is_not_empty failure' { + run assert '' is_not_empty; + assert "$state" equals 1; + assert "$output" same_as "value is empty"; +} + +@test 'Test _zunit_assert_matches success' { + run assert 'test' matches '[a-z]{4}'; + assert "$state" equals 0; + assert "$output" is_empty; + + run assert 123 matches '[0-9]+'; + assert "$state" equals 0; + assert "$output" is_empty; +} + +@test 'Test _zunit_assert_matches failure' { + run assert 'test' matches '[0-9]{4}'; + assert "$state" equals 1; + assert "$output" same_as "'test' does not match /[0-9]{4}/"; + + run assert 123 matches '[a-z]+'; + assert "$state" equals 1; + assert "$output" same_as "'123' does not match /[a-z]+/"; +} + +@test 'Test _zunit_assert_does_not_match success' { + run assert 'test' does_not_match '[0-9]+'; + assert "$state" equals 0; + assert "$output" is_empty; + + run assert 123 does_not_match '[a-z]{4}'; + assert "$state" equals 0; + assert "$output" is_empty; +} + +@test 'Test _zunit_assert_does_not_match failure' { + run assert 'test' does_not_match '[a-z]{4}'; + assert "$state" equals 1; + assert "$output" same_as "'test' matches /[a-z]{4}/"; + + run assert 123 does_not_match '[0-9]+'; + assert "$state" equals 1; + assert "$output" same_as "'123' matches /[0-9]+/"; +} + +@test 'Test _zunit_assert_in success' { + run assert 'a' in 'a' 'b' 'c'; + assert "$state" equals 0; + assert "$output" is_empty; +} + +@test 'Test _zunit_assert_in failure' { + run assert 'a' in 'x' 'y' 'z'; + assert "$state" equals 1; + assert "$output" same_as "'a' is not in (x ; y ; z)"; +} + +@test 'Test _zunit_assert_not_in success' { + run assert 'a' not_in 'x' 'y' 'z'; + assert "$state" equals 0; + assert "$output" is_empty; +} + +@test 'Test _zunit_assert_not_in failure' { + run assert 'a' not_in 'a' 'b' 'c'; + assert "$state" equals 1; + assert "$output" same_as "'a' is in (a ; b ; c)"; +} diff --git a/tests/load.zunit b/tests/load.zunit new file mode 100644 index 0000000..66f7706 --- /dev/null +++ b/tests/load.zunit @@ -0,0 +1,20 @@ +#!/usr/bin/env zunit + +@test 'Test loading script with global variable sets the variable' { + load ./_support/script-with-global-variable; + + assert "$lost_city" equals 'Atlantis'; +} + +@test 'Test loading script with local variable does not set the variable' { + load ./_support/script-with-local-variable; + + assert "$lost_city" is_empty; +} + +@test 'Test loading non-existent file throws error' { + run load ./non-existent-script; + + assert "$state" equals 1; + assert "$output" same_as 'File tests/./non-existent-script does not exist'; +}