#!/bin/sh # Unit testing framework for shell scripts # Invoke with the path to a tests/ subdir of a package and the # package name as arguments. Or source it with the following # variables set: # * results_dir - second argument to the test script set -e # Colors :D RED="\e[31m" GREEN="\e[32m" YELLOW="\e[33m" BLUE="\e[34m" BOLDBLUE="\e[1;34m" RESET="\e[0m" testlib_path="$(realpath "$0")" _test_assert_failed="" _test_testn=1 _test_passedn=0 _test_failedn=0 _test_current_test="" _test_statefile="$(mktemp)" _test_original_pwd="" _test_temp_root="" # Set by each test results_dir="${results_dir:-}" test_log() { # ash supports echo -e # shellcheck disable=SC3037 echo -e "$*" >&2 } test_info() { # shellcheck disable=SC3037 echo -e "${BLUE}$*${RESET}" >&2 } test_debug() { # shellcheck disable=SC3037 echo -e "${YELLOW}$*${RESET}" >&2 } create_test_root() { _test_original_pwd="$(pwd)" _test_temp_root="$(mktemp -d)" cd "$_test_temp_root" } cleanup_test_root() { if [ -n "$_test_original_pwd" ]; then cd "$_test_original_pwd" fi if [ -n "$_test_temp_root" ] && [ -d "$_test_temp_root" ]; then rm -rf "$_test_temp_root" fi _test_original_pwd="" _test_temp_root="" } assert_strequal() { # $1: first string # $2: second string if [ -z "$1" ] || [ -z "$2" ]; then test_log "ERROR: assert_equal: not enough arguments given" exit 1 fi if [ "$1" != "$2" ]; then _test_assert_failed="$1 != $2" test_log " ❌ $_test_assert_failed" return fi test_log " ✅ $1 == $2" } assert_equal() { # $1: name of variable # $2: value to compare if [ -z "$1" ] || [ -z "$2" ]; then test_log "ERROR: assert_equal: not enough arguments given" exit 1 fi val="$(eval echo "\$$1")" if [ "$val" != "$2" ]; then _test_assert_failed="\$$1 != $2 (got $val)" test_log " ❌ $_test_assert_failed" return fi test_log " ✅ \$$1 == $2" } assert_unset() { if [ -z "$1" ]; then test_log "ERROR: assert_unset: not enough arguments given" exit 1 fi val="$(eval echo \$"$1")" if [ -n "$val" ]; then _test_assert_failed="\$$1 should be unset (has value '$val')" test_log " ❌ $_test_assert_failed" return fi test_log " ✅ \$$1 is unset" } assert_exists() { # $1: filename to test if [ -z "$1" ]; then test_log "ERROR: assert_exists: not enough arguments given" exit 1 fi if ! [ -e "$1" ]; then _test_assert_failed="\$$1 should exist but does not" test_log " ❌ $_test_assert_failed" return fi test_log " ✅ \$$1 exists" } assert_not_exists() { if [ -z "$1" ]; then test_log "ERROR: assert_not_exists: not enough arguments given" exit 1 fi if [ -e "$1" ]; then _test_assert_failed="\$$1 should NOT exist but does" test_log " ❌ $_test_assert_failed" return fi test_log " ✅ \$$1 does not exist" } assert_contains() { # $1: string to search in # $2: substring to find if [ -z "$1" ] || [ -z "$2" ]; then test_log "ERROR: assert_contains: not enough arguments given" exit 1 fi case "$1" in *"$2"*) test_log " ✅ '$2' found in '$1'" ;; *) _test_assert_failed="'$2' not found in '$1'" test_log " ❌ $_test_assert_failed" return ;; esac } assert_not_contains() { # $1: string to search in # $2: substring that should not be found if [ -z "$1" ] || [ -z "$2" ]; then test_log "ERROR: assert_not_contains: not enough arguments given" exit 1 fi case "$1" in *"$2"*) _test_assert_failed="'$2' found in '$1' but should not be" test_log " ❌ $_test_assert_failed" return ;; *) test_log " ✅ '$2' not found in '$1'" ;; esac } # Log some expression and evaluate it logeval() { eval "$*" test_log "${YELLOW}+ $*${RESET}" } # Start a test. Shell scripts involved in the test should be sourced AFTER calling this function! start_test() { if [ -n "$_test_current_test" ]; then test_log "ERROR: multiple calls to start_test without calling end_test first!" test_log "Current test: $_test_current_test" exit 1 fi test_log "==> Test $_test_testn: $1" _test_current_test="$1" set > "$_test_statefile" } # End a test and reset the environment. end_test() { local msg local save_assert_failed # Restore environment save_assert_failed="$_test_assert_failed" # Splitting is intentional # shellcheck disable=SC2046 unset $(set | grep "='" | cut -d= -f1 | grep -vE "(PATH|IFS|TERM|LANG|HOME|PWD|SHELL|USER|save_assert_failed|_test_statefile)" ) # shellcheck disable=SC1090 . "$_test_statefile" _test_assert_failed="$save_assert_failed" if [ -n "$_test_assert_failed" ]; then msg="${RED}Failed! ❌ ${_test_assert_failed}${RESET}" echo "Test $_test_testn: $_test_assert_failed" >> "$results_dir/failed" _test_failedn=$((_test_failedn+1)) echo "$_test_failedn" > "$results_dir/failedn" unset _test_assert_failed else msg="${GREEN}Passed!${RESET}" _test_passedn=$((_test_passedn+1)) echo "$_test_passedn" > "$results_dir/passedn" fi test_log "<== Test $_test_testn: $msg $_test_current_test\n" _test_testn=$((_test_testn+1)) _test_current_test="" } # Call at the end of each testsuite shell script end_testsuite() { if [ -n "$_test_current_test" ]; then test_log "${RED}ERROR: end_testsuite called without calling end_test first!${RESET}" test_log "${RED}Current test: $_test_current_test${RESET}" exit 1 fi if [ -f "$results_dir/failed" ]; then exit 1 fi exit 0 } run_tests() { local failed test_results_base local test name total passedn failedn t_passed t_failed test_results_base="$(mktemp -d)" test_log "Running tests for $2" # FIXME: yeah this isn't quite the best way to iterate the files # shellcheck disable=SC2010 for name in $(ls "$1" | grep "\-testlib\.sh$"); do test="$1/$name" mkdir "$test_results_base/$name" echo 0 > "$test_results_base/$name/passedn" echo 0 > "$test_results_base/$name/failedn" test_log test_log "${BOLDBLUE}# Test suite ${name}${RESET}" $test "$testlib_path" "$test_results_base/$name" || failed="$failed $name" t_passed=$(cat "$test_results_base/$name/passedn") t_failed=$(cat "$test_results_base/$name/failedn") passedn=$((passedn+t_passed)) failedn=$((failedn+t_failed)) total=$((total+t_passed+t_failed)) done if [ -n "$failed" ]; then test_log test_log "Some tests failed:" for test in $failed; do test_log "❌ $test" test_log " ${RED}$(cat "$test_results_base/$test/failed")${RESET}" done fi if [ $passedn -lt $total ]; then test_log "[${RED}$passedn/$total tests passed${RESET}]" else test_log "[${GREEN}$passedn/$total tests passed!${RESET}]" fi test_log if [ -n "$failed" ]; then exit 1; else exit 0; fi } # FIXME: probably not the best way to determine if this was sourced if [ $# -gt 0 ]; then run_tests "$1" "$2" fi