First commit

This commit is contained in:
James Dinsdale 2016-09-05 22:10:10 +01:00
commit ea4b02497c
12 changed files with 805 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/zunit

5
.guardian.yml Normal file
View file

@ -0,0 +1,5 @@
files: ./src/**/*
run: make build && make test
---
files: ./tests/**/*
run: make test

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 James Dinsdale <hi@molovo.co> (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.

5
Makefile Normal file
View file

@ -0,0 +1,5 @@
build:
cat src/* > zunit
test:
zunit

185
README.md Normal file
View file

@ -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
```

129
src/assertions Normal file
View file

@ -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
}

76
src/helpers Normal file
View file

@ -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 $?
}

220
src/zunit Executable file
View file

@ -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] <tests...>"
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 "$@"

View file

@ -0,0 +1,3 @@
#!/usr/bin/env zsh
lost_city='Atlantis'

View file

@ -0,0 +1,3 @@
#!/usr/bin/env zsh
local lost_city='Atlantis'

137
tests/assertions.zunit Normal file
View file

@ -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)";
}

20
tests/load.zunit Normal file
View file

@ -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';
}