Merge pull request #49 from molovo/version-0.6.x

v0.6.0
This commit is contained in:
James Dinsdale 2017-03-01 20:59:51 +00:00 committed by GitHub
commit 4be0cacdfa
15 changed files with 1607 additions and 1432 deletions

4
.gitignore vendored
View file

@ -1,2 +1,6 @@
# Ignore test output, but keep the directory
/tests/_output/*
!/tests/_output/.gitkeep
# Ignore the compiled program
/zunit

View file

@ -1,2 +1,5 @@
files: ./tests/**/*
run: zunit
files: ./tests/**/*.zunit
run: ./zunit
---
files: ./src/**/*.zsh
run: ./build.zsh && ./zunit

View file

@ -24,6 +24,7 @@ before_script:
- chmod u+x .bin/{color,revolver,zvm}
- export PATH="$HOME/.zvm/bin:$PWD/.bin:$PATH"
- zvm use ${ZVM_VERSION}
- ./build.zsh
script: ./zunit
notifications:
email: false

View file

@ -14,24 +14,25 @@ ZUnit is a powerful unit testing framework for ZSH
zulu install zunit
```
### zplug
> **NOTE:** In versions of Zulu prior to `1.2.0`, there is an additional step required after install:
```sh
zplug "molovo/zunit", \
as:command, \
use:zunit
```
```sh
cd ~/.zulu/packages/zunit
./build.zsh
zulu link zunit
```
### Manual
```sh
git clone https://github.com/molovo/zunit
cd ./zunit
./build.zsh
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.
> ZUnit requires the utilities [Color](https://github.com/molovo/color) and [Revolver](https://github.com/molovo/revolver) to be installed, and in your `$PATH`. The zulu installation method will install these dependencies for you.
## Writing Tests
@ -380,8 +381,12 @@ 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
# Runs all tests in the file ./tests/a-test-file.zunit
zunit tests/a-test-file.zunit
# Runs a single test named 'The name of the test' in the file
# ./tests/a-test-file.zunit
zunit tests/a-test-file.zunit@'The name of the test'
# Runs all tests, and exists immediately after the first failure
zunit --fail-fast

23
build.zsh Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env zsh
# Clear the file to start with
cat /dev/null > zunit
# Start with the shebang
echo "#!/usr/bin/env zsh\n" >> zunit
# We need to do some fancy globbing
setopt EXTENDED_GLOB
# Print each of the source files into the target, removing any comments
# and blank lines from the compiled executable
cat src/**/(^zunit).zsh | grep -v -E '^(\s*#.*[^"]|\s*)$' >> zunit
# Print the main command last
cat src/zunit.zsh | grep -v -E '^(\s*#.*[^"]|\s*)$' >> zunit
# Make sure the file is executable
chmod u+x zunit
# Let the user know we're finished
echo "\033[0;32m✔\033[0;m ZUnit built successfully"

341
src/assertions.zsh Normal file
View file

@ -0,0 +1,341 @@
################################
# Internal assertion functions #
################################
###
# 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
local -a array
array=(${(@)@:2})
for i in ${(@f)array}; do
[[ $i = $value ]] && found=1
done
[[ $found -eq 1 ]] && return 0
echo "'$value' is not in (${(@f)array})"
exit 1
}
###
# Assert that a value is not found in an array
###
function _zunit_assert_not_in() {
local i found=0 value=$1
local -a array
array=(${(@)@:2})
for i in ${(@f)array}; do
[[ $i = $value ]] && found=1
done
[[ $found -eq 0 ]] && return 0
echo "'$value' is in (${(@f)array})"
exit 1
}
###
# Assert that a value is a key in a hash
###
function _zunit_assert_is_key_in() {
local i found=0 value=$1
local -A hash
hash=(${(@)@:2})
for k v in ${(@kv)hash}; do
[[ $k = $value ]] && found=1
done
[[ $found -eq 1 ]] && return 0
echo "'$value' is not a key in (${(@kv)hash})"
exit 1
}
###
# Assert that a value is not a key in a hash
###
function _zunit_assert_is_not_key_in() {
local i found=0 value=$1
local -A hash
hash=(${(@)@:2})
for k v in ${(@kv)hash}; do
[[ $k = $value ]] && found=1
done
[[ $found -eq 0 ]] && return 0
echo "'$value' is a key in (${(@kv)hash})"
exit 1
}
###
# Assert that a value is a value in a hash
###
function _zunit_assert_is_value_in() {
local i found=0 value=$1
local -A hash
hash=(${(@)@:2})
for k v in ${(@kv)hash}; do
[[ $v = $value ]] && found=1
done
[[ $found -eq 1 ]] && return 0
echo "'$value' is not a value in (${(@kv)hash})"
exit 1
}
###
# Assert that a value is not a value in a hash
###
function _zunit_assert_is_not_value_in() {
local i found=0 value=$1
local -A hash
hash=(${(@)@:2})
for k v in ${(@kv)hash}; do
[[ $v = $value ]] && found=1
done
[[ $found -eq 0 ]] && return 0
echo "'$value' is a value in (${(@kv)hash})"
exit 1
}
###
# Assert the a path exists
###
function _zunit_assert_exists() {
local pathname=$1 filepath
# If filepath is relative, prepend the test directory
if [[ "${pathname:0:1}" != "/" ]]; then
filepath="$testdir/${pathname}"
else
filepath="$pathname"
fi
[[ -e "$filepath" ]] && return 0
echo "'$pathname' does not exist"
exit 1
}
###
# Assert the a path exists and is a file
###
function _zunit_assert_is_file() {
local pathname=$1 filepath
# If filepath is relative, prepend the test directory
if [[ "${pathname:0:1}" != "/" ]]; then
filepath="$testdir/${pathname}"
else
filepath="$pathname"
fi
[[ -f "$filepath" ]] && return 0
echo "'$pathname' does not exist or is not a file"
exit 1
}
###
# Assert the a path exists and is a directory
###
function _zunit_assert_is_dir() {
local pathname=$1 filepath
# If filepath is relative, prepend the test directory
if [[ "${pathname:0:1}" != "/" ]]; then
filepath="$testdir/$pathname"
else
filepath="$pathname"
fi
[[ -d "$filepath" ]] && return 0
echo "'$pathname' does not exist or is not a directory"
exit 1
}
###
# Assert the a path exists and is a symbolic link
###
function _zunit_assert_is_link() {
local pathname=$1 filepath
# If filepath is relative, prepend the test directory
if [[ "${pathname:0:1}" != "/" ]]; then
filepath="$testdir/${pathname}"
else
filepath="$pathname"
fi
[[ -h "$filepath" ]] && return 0
echo "'$pathname' does not exist or is not a symbolic link"
exit 1
}
###
# Assert the a path exists and is readable
###
function _zunit_assert_is_readable() {
local pathname=$1 filepath
# If filepath is relative, prepend the test directory
if [[ "${pathname:0:1}" != "/" ]]; then
filepath="$testdir/${pathname}"
else
filepath="$pathname"
fi
[[ -r "$filepath" ]] && return 0
echo "'$pathname' does not exist or is not readable"
exit 1
}
###
# Assert the a path exists and is writable
###
function _zunit_assert_is_writable() {
local pathname=$1 filepath
# If filepath is relative, prepend the test directory
if [[ "${pathname:0:1}" != "/" ]]; then
filepath="$testdir/${pathname}"
else
filepath="$pathname"
fi
[[ -w "$filepath" ]] && return 0
echo "'$pathname' does not exist or is not writable"
exit 1
}
###
# Assert the a path exists and is executable
###
function _zunit_assert_is_executable() {
local pathname=$1 filepath
# If filepath is relative, prepend the test directory
if [[ "${pathname:0:1}" != "/" ]]; then
filepath="$testdir/${pathname}"
else
filepath="$pathname"
fi
[[ -x "$filepath" ]] && return 0
echo "'$pathname' does not exist or is not executable"
exit 1
}

115
src/commands/init.zsh Normal file
View file

@ -0,0 +1,115 @@
############################
# The 'zunit init' command #
############################
###
# Output usage information and exit
###
function _zunit_init_usage() {
echo "$(color yellow 'Usage:')"
echo " zunit init [options]"
echo
echo "$(color yellow 'Options:')"
echo " -h, --help Output help text and exit"
echo " -v, --version Output version information and exit"
echo " -t, --travis Generate .travis.yml in project"
}
###
# Parse a YAML config file
# Based on https://gist.github.com/pkuczynski/8665367
###
function _zunit_parse_yaml() {
local s w fs prefix=$2
s='[[:space:]]*'
w='[a-zA-Z0-9_]*'
fs="$(echo @|tr @ '\034')"
sed -ne "s|^\(${s}\)\(${w}\)${s}:${s}\"\(.*\)\"${s}\$|\1${fs}\2${fs}\3|p" \
-e "s|^\(${s}\)\(${w}\)${s}[:-]${s}\(.*\)${s}\$|\1${fs}\2${fs}\3|p" "$1" |
awk -F"${fs}" '{
indent = length($1)/2;
vname[indent] = $2;
for (i in vname) {if (i > indent) {delete vname[i]}}
if (length($3) > 0) {
vn=""; for (i=0; i<indent; i++) {vn=(vn)(vname[i])("_")}
printf("%s%s%s=(\"%s\")\n", "'"$prefix"'",vn, $2, $3);
}
}' | sed 's/_=/+=/g'
}
function _zunit_init() {
local with_travis
zparseopts -D t=with_travis -travis=with_travis
# The contents of .zunit.yml
local yaml="tap: false
directories:
tests: tests
output: tests/_output
support: tests/_support"
# An example test file
local example="#!/usr/bin/env zunit
@test 'Example' {
assert "'"true"'" same_as "'"false"'"
}"
# An empty bootstrap script
local bootstrap="#!/usr/bin/env zsh
# Write your bootstrap code here"
# An example .travis.yml config
local travis_yml='addons:
apt:
packages:
zsh
before_script:
- mkdir .bin
- curl -L https://raw.githubusercontent.com/molovo/revolver/master/revolver > .bin/revolver
- curl -L https://raw.githubusercontent.com/molovo/color/master/color.zsh > .bin/color
- curl -L https://raw.githubusercontent.com/molovo/zunit/master/zunit > .bin/zunit
- chmod u+x .bin/{color,revolver,zunit}
- export PATH="$PWD/.bin:$PATH"
script: zunit'
# Check that a config file doesn't already exist so that
# we don't overwrite it
if [[ -f "$PWD/.zunit.yml" ]]; then
echo $(color red "Zunit config file already exists at $PWD/.zunit.yml") >&2
exit 1
else
# Write the contents to the config file
echo "$yaml" > "$PWD/.zunit.yml"
fi
# Check that the tests directory doesn't already exist so that
# we don't overwrite it
if [[ -d "$PWD/tests" ]]; then
echo $(color red "Directory already exists at $PWD/tests") >&2
exit 1
else
# Create the directory structure for tests
mkdir -p tests/_{output,support}
touch tests/_{output,support}/.gitkeep
# Save the bootstrap script and example test
echo "$bootstrap" > "$PWD/tests/_support/bootstrap"
echo "$example" > "$PWD/tests/example.zunit"
fi
# If travis config has been requested
if [[ -n $with_travis ]]; then
# Check that a travis config doesn't already exist so that
# we don't overwrite it
if [[ -f "$PWD/.travis.yml" ]]; then
echo $(color red "Travis config already exists at $PWD/.travis.yml") >&2
exit 1
else
# Write the contents to the config file
echo "$travis_yml" > "$PWD/.travis.yml"
fi
fi
}

558
src/commands/run.zsh Normal file
View file

@ -0,0 +1,558 @@
###########################
# The 'zunit run' command #
###########################
###
# Output usage information and exit
###
function _zunit_run_usage() {
echo "$(color yellow 'Usage:')"
echo " zunit run [options] [tests...]"
echo
echo "$(color yellow 'Options:')"
echo " -h, --help Output help text and exit"
echo " -v, --version Output version information and exit"
echo " -f, --fail-fast Stop the test runner immediately after the first failure"
echo " -t, --tap Output results in a TAP compatible format"
echo " --output-text Print results to a text log, in TAP compatible format"
echo " --output-html Print results to a HTML page"
echo " --allow-risky Supress warnings generated for risky tests"
}
###
# Format a ms timestamp in a human-readable format
###
function _zunit_human_time() {
local ms=$1
local tmp=$(( $1 / 1000 ))
local days=$(( tmp / 60 / 60 / 24 ))
local hours=$(( tmp / 60 / 60 % 24 ))
local minutes=$(( tmp / 60 % 60 ))
local seconds=$(( tmp % 60 ))
(( $days > 0 )) && print -n "${days}d "
(( $hours > 0 )) && print -n "${hours}h "
(( $minutes > 0 )) && print -n "${minutes}m "
(( $seconds > 5 )) && print -n "${seconds}s "
(( $seconds < 30 )) && (( $seconds > 5 )) && print -n "$(( ms - $((seconds*1000)) ))ms"
(( $tmp <= 5 )) && print -n "${1}ms"
}
###
# Output test results
###
function _zunit_output_results() {
integer elapsed=$(( end_time - start_time ))
echo
echo "$total tests run in $(_zunit_human_time $elapsed)"
echo
echo "$(color yellow underline 'Results') "
echo "$(color green '✔') Passed $passed "
echo "$(color red '✘') Failed $failed "
echo "$(color red '‼') Errors $errors "
echo "$(color magenta '●') Skipped $skipped "
echo "$(color yellow '‼') Warnings $warnings "
echo
[[ -n $output_text ]] && echo "TAP report written at $PWD/$logfile_text"
[[ -n $output_html ]] && echo "HTML report written at $PWD/$logfile_html"
}
###
# Execute a test and store the result
###
function _zunit_execute_test() {
local name="$1" body="$2"
if [[ -n $body ]] && [[ -n $name ]]; then
# Update the progress indicator
[[ -z $tap ]] && revolver update "${name}"
# Make sure we don't already have a function defined
(( $+functions[__zunit_tmp_test_function] )) && \
unfunction __zunit_tmp_test_function
# Create a wrapper function with our test body inside it
func="function __zunit_tmp_test_function() {
# Exit on errors. We do this so that execution stops immediately,
# and the error will be reported back to the test runner
setopt ERR_EXIT
# Add an exit handler which calls the teardown function if it is
# defined and the test exits early
if (( \$+functions[__zunit_test_teardown] )); then
zshexit() {
__zunit_test_teardown >/dev/null 2>&1
}
fi
# Create some local variables to store test state in
integer _zunit_assertion_count=0
integer state
local output
typeset -a lines
# If a setup function is defined, run it now
if (( \$+functions[__zunit_test_setup] )); then
__zunit_test_setup >/dev/null 2>&1
fi
# The test body is printed here, so when we eval the wrapper
# function it will be read as part of the body of this function
${body}
# If a teardown function is defined, run it now
if (( \$+functions[__zunit_test_teardown] )); then
__zunit_test_teardown >/dev/null 2>&1
fi
# Remove the error handler
zshexit() {}
# Check the assertion count, and if it is 0, return
# the warning exit code
[[ \$_zunit_assertion_count -gt 0 ]] || return 248
}"
# Quietly eval the body into a variable as a first test
output=$(eval "$(echo "$func")" 2>&1)
# Increment the test count
total=$(( total + 1 ))
# Check the status of the eval, and output any errors
if [[ $? -ne 0 ]]; then
_zunit_error 'Failed to parse test body' $output
return 126
fi
# Run the eval again, this time within the current context so that
# the function is registered in the current scope
eval "$(echo "$func")" 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
_zunit_error 'Failed to parse test body'
return 126
fi
# Check if a time limit has been specified. We only do this if
# the ZSH version is at least 5.1.0, since older versions of ZSH
# are unable to handle asynchronous processes in the way we need
autoload is-at-least
if is-at-least 5.1.0 && [[ -n $zunit_config_time_limit ]]; then
# Create another wrapper function around the test
__zunit_async_test_wrapper() {
local pid
# Get the current timestamp, and the time limit, and use those to
# work out the kill time for the sub process
integer time_limit=$(( ${zunit_config_time_limit:-30} * 1000 ))
integer time=$(( EPOCHREALTIME * 1000 ))
integer kill_time=$(( $time + $time_limit ))
# Launch the test function asynchronously and store its PID
__zunit_tmp_test_function &
pid=$!
# While the child process is still running
while kill -0 $pid >/dev/null 2>&1; do
# Check that the kill time has not yet been reached
time=$(( EPOCHREALTIME * 1000 ))
if [[ $time -gt $kill_time ]]; then
# The kill time has been reached, kill the child process,
# and exit the wrapper function
kill -9 $pid >/dev/null 2>&1
exit 78
fi
done
# Use wait to get the exit code from the background process,
# and return that so that the test result can be deduced
wait $pid
return $?
}
# Launch the async wrapper, and capture the output in a variable
output="$(__zunit_async_test_wrapper 2>&1)"
else
# Launch the test, and capture the output in a variable
output="$(__zunit_tmp_test_function 2>&1)"
fi
# Output the result to the user
state=$?
if [[ $state -eq 48 ]]; then
_zunit_skip $output
return
elif [[ $state -eq 78 ]]; then
_zunit_error "Test took too long to run. Terminated after ${zunit_config_time_limit:-30} seconds" $output
return
elif [[ -z $allow_risky && $state -eq 248 ]]; then
_zunit_warn 'No assertions were run, test is risky'
return
elif [[ -n $allow_risky && $state -eq 248 ]] || [[ $state -eq 0 ]]; then
_zunit_success
return
else
_zunit_failure $output
return 1
fi
fi
}
###
# Encode test name into a value which can be used as a hash key
###
function _zunit_encode_test_name() {
echo "$1" | tr A-Z a-z \
| tr _ ' ' \
| tr - ' ' \
| tr -s ' ' \
| sed 's/\- /-/' \
| sed 's/ \-/-/' \
| tr ' ' "-"
}
###
# Run all tests within a file
###
function _zunit_run_testfile() {
local testbody testname pattern \
setup teardown
local -a bits; bits=("${(s/@/)1}")
local testfile="${bits[1]}" test_to_run="${bits[2]}" testdir="$(dirname "$testfile")"
local -a lines tests test_names
tests=()
test_names=()
# Update status message
[[ -z $tap ]] && revolver update "Loading tests from $testfile"
# A regex pattern to match test declarations
pattern='^ *@test *([^ ].*) *\{ *(.*)$'
# Loop through each of the lines in the file
local oldIFS=$IFS
IFS=$'\n' lines=($(cat $testfile))
IFS=$oldIFS
for line in $lines[@]; do
# Match current line against pattern
if [[ "$line" =~ $pattern ]]; then
# Get test name from matches
testname="${line[(( ${line[(i)[\']]}+1 )),(( ${line[(I)[\']]}-1 ))]}"
# If a test name has been passed to the CLI, don't parse this test
# unless it matches the name passed
if [[ -n $test_to_run && $testname != $test_to_run ]]; then
testname=''
continue
fi
# Store the test name and body in the arrays so we have somewhere to
# store the test body
test_names=($test_names $testname)
tests[${#test_names}]=''
elif [[ "$line" =~ '^@setup([ ])?\{$' ]]; then
setup=''
parsing_setup=true
elif [[ "$line" =~ '^@teardown([ ])?\{$' ]]; then
teardown=''
parsing_teardown=true
elif [[ "$line" = '}' ]]; then
# We've hit a closing brace as the only character on a line,
# therefore we are at the end of either a test or a setup or teardown
# function. We'll just clear all three here rather than work out which.
testname=''
parsing_setup=''
parsing_teardown=''
else
# A test name is set, so we are parsing a test. Add the
# current line to the function body.
if [[ -n $testname ]]; then
tests[${#test_names}]+="$line"$'\n'
continue
fi
# Add the current line to the body of the setup function
if [[ -n $parsing_setup ]]; then
setup+="$line"$'\n'
continue
fi
# Add the current line to the body of the teardown function
if [[ -n $parsing_teardown ]]; then
teardown+="$line"$'\n'
continue
fi
fi
done
# A setup function has been defined
if [[ -n $setup ]]; then
# Print the body into a function declaration
setupfunc="function __zunit_test_setup() {
${setup}
}"
# Quietly eval the body into a variable as a first test
output=$(eval "$(echo "$setupfunc")" 2>&1)
# Check the status of the eval, and output any errors
if [[ $? -ne 0 ]]; then
_zunit_error "Failed to parse setup method" $output
return 126
fi
# Run the eval again, this time within the current context so that
# the function is registered in the current scope
eval "$(echo "$setupfunc")" 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_test_setup] )); then
_zunit_error "Failed to parse setup method"
return 126
fi
fi
# A teardown function has been defined
if [[ -n $teardown ]]; then
# Print the body into a function declaration
teardownfunc="function __zunit_test_teardown() {
${teardown}
}"
# Quietly eval the body into a variable as a first test
output=$(eval "$(echo "$teardownfunc")" 2>&1)
# Check the status of the eval, and output any errors
if [[ $? -ne 0 ]]; then
_zunit_error "Failed to parse teardown method" $output
return 126
fi
# Run the eval again, this time within the current context so that
# the function is registered in the current scope
eval "$(echo "$teardownfunc")" 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_test_teardown] )); then
_zunit_error "Failed to parse teardown method"
return 126
fi
fi
# Loop through each of the tests and execute it
integer i=1
local name body
for name in "${test_names[@]}"; do
body="${tests[$i]}"
_zunit_execute_test "$name" "$body"
i=$(( i + 1 ))
done
# Remove the temporary functions
(( $+functions[__zunit_test_setup] )) && unfunction __zunit_test_setup
(( $+functions[__zunit_test_teardown] )) && unfunction __zunit_test_teardown
(( $+functions[__zunit_tmp_test_function] )) && unfunction __zunit_tmp_test_function
}
###
# Parse a list of arguments
###
function _zunit_parse_argument() {
local -a bits; bits=("${(s/@/)1}")
local argument="$bits[1]" test_name="$bits[2]"
# 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 -mindepth 1 -maxdepth 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${test_name+"@$test_name"}")
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 126
fi
# The file could not be found, so we throw a fatal error
echo $(color red "Test file or directory '$argument' could not be found") >&2
exit 126
}
###
# Run tests
###
function _zunit_run() {
local -a arguments testfiles
local fail_fast tap allow_risky
local output_text logfile_text output_html logfile_html
# Load the datetime module, and record the start time
zmodload zsh/datetime
local start_time=$((EPOCHREALTIME*1000)) end_time
zparseopts -D -E \
h=help -help=help \
v=version -version=version \
f=fail_fast -fail-fast=fail_fast \
t=tap -tap=tap \
-output-text=output_text \
-output-html=output_html \
-allow-risky=allow_risky
# TAP output is enabled
if [[ -n $tap ]] || [[ "$zunit_config_tap" = "true" ]]; then
# Set the $tap variable, so we can check it later
tap=1
# Print the TAP header
echo 'TAP version 13'
fi
# TAP output is disabled
if [[ -z $tap ]]; then
# Print version information
echo $(color yellow 'Launching ZUnit')
echo "ZUnit: $(_zunit_version)"
echo "ZSH: $(zsh --version)"
echo
fi
# Text output has been requested
if [[ -n $output_text || -n $output_html ]]; then
# Make sure we have a config file, otherwise we can't determine
# which directory to write logs to
if [[ $missing_config -eq 1 ]]; then
echo $(color red '.zunit.yml could not be found. Run `zulu init`')
exit 1
fi
# If the output directory still isn't defined, it must not
# be defined in the config file
if [[ -z $zunit_config_directories_output ]]; then
echo $(color red 'Output directory must be specified in .zunit.yml')
exit 1
fi
fi
if [[ -n $output_text ]]; then
# Set the log filepath
logfile_text="$zunit_config_directories_output/output.txt"
# Print the header to the logfile
echo 'TAP version 13' > $logfile_text
fi
if [[ -n $output_html ]]; then
# Set the log filepath
logfile_html="$zunit_config_directories_output/output.html"
# Print the header to the logfile
_zunit_html_header > $logfile_html
fi
if [[ -n $zunit_config_directories_support ]]; then
# Check that the support directory exists
local support="$zunit_config_directories_support"
if [[ ! -d $support ]]; then
echo $(color red "Support directory at $support is missing")
exit 1
fi
# Look for a bootstrap script in the support directory,
# and run it if it is available
if [[ -f "$support/bootstrap" ]]; then
source "$support/bootstrap"
echo "$(color green '✔') Sourced bootstrap script $support/bootstrap"
fi
fi
arguments=("$@")
testfiles=()
# Start the progress indicator
[[ -z $tap ]] && revolver start 'Loading tests'
# If no arguments are passed, try to work out where the tests are
if [[ ${#arguments} -eq 0 ]]; then
# Check for a path defined in .zunit.yml
if [[ -n $zunit_config_directories_tests ]]; then
arguments=("$zunit_config_directories_tests")
# Fall back to the directory 'tests' by default
else
arguments=("tests")
fi
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
local total=0 passed=0 failed=0 errors=0 warnings=0 skipped=0
for testfile in $testfiles; do
_zunit_run_testfile $testfile
done
end_time=$((EPOCHREALTIME*1000))
# Print report footers
[[ -n $tap ]] && echo "1..$total"
[[ -n $output_text ]] && echo "1..$total" >> $logfile_text
[[ -n $output_html ]] && _zunit_html_footer >> $logfile_html
# Output results to screen and kill the progress indicator
[[ -z $tap ]] && _zunit_output_results && revolver stop
# If the total of ($passed + $skipped) is not equal to the
# total, then there must have been failures, errors or warnings,
# in which case this assertion will return the correct exit code
# for the test run as a whole
[[ $(( $passed + $skipped )) -eq $total ]]
}

139
src/events.zsh Normal file
View file

@ -0,0 +1,139 @@
##########################################
# Functions for handling internal events #
##########################################
###
# Shutdown testing early. Called if --fail-fast is specified
# or if a fatal error occurred during testing
###
function _zunit_fail_shutdown() {
# Kill the revolver process
[[ -z $tap ]] && revolver stop
# Print a message to screen
echo $(color red bold 'Execution halted after failure')
# Record the time at which testing ended
end_time=$((EPOCHREALTIME*1000))
# If we're not printing TAP output, then print the
# results table to screen
[[ -z $tap ]] && _zunit_output_results
# If a HTML report has been requested, then print
# the end of the HTML report
if [[ -n $output_html ]]; then
name='Execution halted after failure'
_zunit_html_error >> $logfile_html
_zunit_html_footer >> $logfile_html
fi
# Return a error exit code
exit 1
}
###
# Output a success message
###
function _zunit_success() {
# Write to reports
[[ -n $output_text ]] && _zunit_tap_success "$@" >> $logfile_text
[[ -n $output_html ]] && _zunit_html_success "$@" >> $logfile_html
passed=$(( passed + 1 ))
if [[ -n $tap ]]; then
_zunit_tap_success "$@"
return
fi
echo "$(color green '✔') ${name}"
}
###
# Output a failure message
###
function _zunit_failure() {
local message="$1" output="${(@)@:2}"
failed=$(( failed + 1 ))
# Write to reports
[[ -n $output_text ]] && _zunit_tap_failure "$@" >> $logfile_text
[[ -n $output_html ]] && _zunit_html_failure "$@" >> $logfile_html
if [[ -n $tap ]]; then
_zunit_tap_failure "$@"
else
echo "$(color red '✘' ${name})"
echo " $(color red underline ${message})"
echo " $(color red ${output})"
fi
[[ -n $fail_fast ]] && _zunit_fail_shutdown
}
###
# Output a error message
###
function _zunit_error() {
local message="$1" output="${(@)@:2}"
errors=$(( errors + 1 ))
# Write to reports
[[ -n $output_text ]] && _zunit_tap_error "$@" >> $logfile_text
[[ -n $output_html ]] && _zunit_html_error "$@" >> $logfile_html
if [[ -n $tap ]]; then
_zunit_tap_error "$@"
else
echo "$(color red '‼' ${name})"
echo " $(color red underline ${message})"
echo " $(color red ${output})"
fi
[[ -n $fail_fast ]] && _zunit_fail_shutdown
}
###
# Output a warning message
###
function _zunit_warn() {
local message="$@"
warnings=$(( warnings + 1 ))
# Write to reports
[[ -n $output_text ]] && _zunit_tap_warn "$@" >> $logfile_text
[[ -n $output_html ]] && _zunit_html_warn "$@" >> $logfile_html
if [[ -n $tap ]]; then
_zunit_tap_warn "$@"
return
fi
echo "$(color yellow '‼') ${name}"
echo " $(color yellow underline ${message})"
}
###
# Output a skipped test message
###
function _zunit_skip() {
local message="$@"
skipped=$(( skipped + 1 ))
# Write to reports
[[ -n $output_text ]] && _zunit_tap_skip "$@" >> $logfile_text
[[ -n $output_html ]] && _zunit_html_skip "$@" >> $logfile_html
if [[ -n $tap ]]; then
_zunit_tap_skip "$@"
return
fi
echo "$(color magenta '●') Skipped: ${name}"
echo " \033[0;38;5;242m# ${message}\033[0;m"
}

137
src/helpers.zsh Normal file
View file

@ -0,0 +1,137 @@
################################
# Helpers for use within tests #
################################
###
# 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
# We couldn't find the file, so output an error message to the user
# and fail the test
echo "File $filename does not exist" >&2
exit 1
}
###
# Run an external command and capture its output and exit status
###
function run() {
# Within tests, the shell is set to exit immediately when errors
# occur. Since we want to capture the exit code of the command
# we're running, we stop the shell from exiting on error temporarily
unsetopt ERR_EXIT
# Preserve current $IFS
local oldIFS=$IFS name
local -a cmd
# Store each word of the command in an array, and grab the first
# argument which is the command name
cmd=(${@[@]})
name="${cmd[1]}"
# If the command is not an existing command or file,
# then prepend the test directory to the path
type $name > /dev/null
if [[ $? -ne 0 && ! -f $name && -f "$testdir/${name}" ]]; then
cmd[1]="$testdir/${name}"
fi
# Store full output in a variable
output=$("${cmd[@]}" 2>&1)
# Get the process exit state
state="$?"
# Store individual lines of output in an array
IFS=$'\n'
lines=(${(@f)output})
# 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
local -a comparisons
# Preserve current $IFS
local oldIFS=$IFS
IFS=$'\n'
# Store all comparison values in an array
comparisons=(${(@)@:3})
# If no assertion is passed, then use the first value, as it
# could be that the value is simply empty
if [[ -z $assertion ]]; then
assertion=$value
value=""
fi
# Check that the requested assertion method exists
if (( ! $+functions[_zunit_assert_${assertion}] )); then
echo "$(color red "Assertion $assertion does not exist")"
exit 127
fi
# Increment the assertion count
_zunit_assertion_count=$(( _zunit_assertion_count + 1 ))
# Run the assertion
"_zunit_assert_${assertion}" $value ${(@f)comparisons[@]}
local state=$?
# If the assertion failed, then return that exit code to the
# test, which will stop its execution and mark it as failed
if [[ $state -ne 0 ]]; then
exit $state
fi
# Reset $IFS
IFS=$oldIFS
}
###
# Mark the current test as skipped
###
function skip() {
# Exit code 48 will skip the test, so all we have to do
# to mark the test as skipped is exit.
# The reason for skipping is echoed to stdout first, so that
# it can be picked up by the error handler
echo "$@"
exit 48
}

78
src/reports/html.zsh Normal file

File diff suppressed because one or more lines are too long

66
src/reports/tap.zsh Normal file
View file

@ -0,0 +1,66 @@
########################################
# Functions for handling TAP reporting #
########################################
###
# Output a TAP compatible success message
###
function _zunit_tap_success() {
echo "ok ${total} - ${name}"
}
###
# Output a TAP compatible failure message
###
function _zunit_tap_failure() {
local message="$@"
echo "not ok ${total} - Failure: ${name}"
echo " ---"
echo " message: ${message}"
echo " severity: fail"
echo " ..."
[[ -n $fail_fast ]] && echo "Bail out!"
}
###
# Output a TAP compatible error message
###
function _zunit_tap_error() {
local message="$@"
echo "not ok ${total} - Error: ${name}"
echo " ---"
echo " message: ${message}"
echo " severity: fail"
echo " ..."
[[ -n $fail_fast ]] && echo "Bail out!"
}
###
# Output a TAP compatible warning message
###
function _zunit_tap_warn() {
local message="$@"
echo "ok ${total} - Warning: ${name}"
echo " ---"
echo " message: ${message}"
echo " severity: comment"
echo " ..."
}
###
# Output a TAP compatible skipped test message
###
function _zunit_tap_skip() {
local message="$@"
echo "ok ${total} - # SKIP ${name}"
echo " ---"
echo " message: ${message}"
echo " severity: comment"
echo " ..."
}

125
src/zunit.zsh Executable file
View file

@ -0,0 +1,125 @@
#!/usr/bin/env zsh
######################
# Main zunit process #
######################
###
# Output usage information and exit
###
function _zunit_usage() {
echo "$(color yellow 'Usage:')"
echo " zunit [options] [command] [tests...]"
echo
echo "$(color yellow 'Commands:')"
echo " init Bootstrap zunit in a new project"
echo " run [tests...] Run tests"
echo
echo "$(color yellow 'Options:')"
echo " -h, --help Output help text and exit"
echo " -v, --version Output version information and exit"
echo " -f, --fail-fast Stop the test runner immediately after the first failure"
echo " -t, --tap Output results in a TAP compatible format"
echo " --output-text Print results to a text log, in TAP compatible format"
echo " --output-html Print results to a HTML page"
echo " --allow-risky Supress warnings generated for risky tests"
}
###
# Output the version number
###
function _zunit_version() {
echo '0.6.0'
}
###
# The main zunit process
###
function _zunit() {
local help version ctx="$1" missing_dependencies=0 missing_config=1
if [[ -f .zunit.yml ]]; then
# Try and parse the config file within a subprocess,
# to avoid killing the main thread
$(eval $(_zunit_parse_yaml .zunit.yml 'zunit_config_') >/dev/null 2>&1)
if [[ $? -eq 0 ]]; then
# The config file was parsed successfully, so we Perform the parse
# again, but this time on the main thread so that the config vars are
# loaded into the enviroment
eval $(_zunit_parse_yaml .zunit.yml 'zunit_config_') >/dev/null 2>&1
missing_config=0
else
# The config file failed to parse, so we report this to the user and exit
echo "\033[0;31mFailed to parse config file\033[0;m" >&2
exit 1
fi
fi
# Check for the 'color' dependency
$(type color >/dev/null 2>&1)
if [[ $? -ne 0 ]]; then
# 'color' could not be found, so print an error message
missing_dependencies=$(( missing_dependencies + 1 ))
echo "\033[0;31mMissing required dependency: Color - https://github.com/molovo/color\033[0;m" >&2
fi
# Check for the 'revolver' dependency
$(type revolver >/dev/null 2>&1)
if [[ $? -ne 0 ]]; then
# 'revolver' could not be found, so print an error message
missing_dependencies=$(( missing_dependencies + 1 ))
echo "\033[0;31mMissing required dependency: Revolver - https://github.com/molovo/revolver\033[0;m" >&2
fi
# If any missing dependencies have been found, we can't run tests, so we exit
[[ $missing_dependencies -gt 0 ]] && exit 1
zparseopts -D -E \
h=help -help=help \
v=version -version=version
# If the version option is passed,
# output version information and exit
if [[ -n $version ]]; then
_zunit_version
exit 0
fi
# Check which command has been passed, and run it. If the command
# is not recognised, then we'll assume it's a test file and pass
# it to `zunit run`, since that will catch it if it's not a valid file
case "$ctx" in
init )
# If the help option is passed,
# output usage information and exit
if [[ -n $help ]]; then
_zunit_init_usage
exit 0
fi
_zunit_init "${(@)@:2}"
;;
run )
# If the help option is passed,
# output usage information and exit
if [[ -n $help ]]; then
_zunit_run_usage
exit 0
fi
_zunit_run "${(@)@:2}"
;;
* )
# If the help option is passed,
# output usage information and exit
if [[ -n $help ]]; then
_zunit_usage
exit 0
fi
_zunit_run "$@"
;;
esac
}
_zunit "$@"

View file

@ -1,13 +1,5 @@
#!/usr/bin/env zunit
@setup {
echo 'Testing setup method'
}
@teardown {
echo 'Testing teardown method'
}
@test 'Test successful command' {
run return 0
@ -24,5 +16,5 @@
run a-non-existent-command
assert $state equals 127
assert $output same_as 'run:20: command not found: a-non-existent-command'
assert $output matches 'run:[0-9]+: command not found: a-non-existent-command'
}

1412
zunit

File diff suppressed because one or more lines are too long