From 1b884f7c2300c726208d9dd36b34cacdc18aea3f Mon Sep 17 00:00:00 2001 From: Aaron McHale Date: Sun, 29 Mar 2026 22:02:30 +0100 Subject: [PATCH 1/6] Added test runner --- .github/workflows/test.yml | 2 +- README.md | 2 + docs/CLI.md | 2 + docs/Tests.md | 3 + tests/run-tests.sh | 64 +++++++++++++++ tests/test-cli.sh | 164 +++++++++++++++---------------------- 6 files changed, 140 insertions(+), 97 deletions(-) create mode 100644 docs/Tests.md create mode 100755 tests/run-tests.sh mode change 100755 => 100644 tests/test-cli.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 54d0e2a..b5b041c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,4 +21,4 @@ jobs: - name: Run tests run: | cd tests - ./test-cli.sh + ./run-tests.sh diff --git a/README.md b/README.md index 7bc2e78..229ef15 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,12 @@ So this could be useful for any project which wants to ship a single entrypoint * Formatting variables for `RESET` `RED` `GREEN` `YELLOW` `BLUE` are provided. * Helper functions for printing error, warning and success messages. * The `cli.sh` script supports being run from anywhere in the system. For example, this means it can be symlinked to `/usr/bin` and it will work. +* A simple test runner. ## Docs [Read the CLI docs for more information on how commands works.](docs/CLI.md) +[Read the Tests docs for more information on how to write tests.](docs/Tests.md) ## Using this template in your project diff --git a/docs/CLI.md b/docs/CLI.md index 453f6d8..31aaf6a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -4,6 +4,8 @@ Create a new `.sh` script in the `commands` directory, the name of the script is the name of the command. +Note that these scripts do not need to be executable, they only need to end in `.sh`. + Running `cli.sh` or `cli.sh list` will then show the newly created command. When running `cli.sh` followed by the name of the command, the shell script for the command will be sourced as is. This means that commands do not need to use any specific functions to run. diff --git a/docs/Tests.md b/docs/Tests.md new file mode 100644 index 0000000..b1b5e2d --- /dev/null +++ b/docs/Tests.md @@ -0,0 +1,3 @@ +# Tests + + diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 0000000..24f3158 --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -u + +# Run all test_ functions in test-*.sh scripts + +# Cd to the directory of this script, so it can be run from anywhere +realpath="$(realpath "$0")" +cd "${realpath%/*}" + +# Save the current directory if tests need a absolute path reference +TEST_DIR="$PWD" + +# Run a test file. +# Uses a subshell so that each test file is isolated. +# Usage: run_tests "test-file.sh" +run_tests() {( + local test_file="$1" + echo -e "Running tests in $test_file...\n" + source "$test_file" + + # As we're running in a subshell, we can use trap to run teardown and print message on exit. + exit_trap() { + # If a teardown function is defined, set it to run on EXIT + if [[ $(declare -F teardown) ]]; then + echo "Running teardown for $test_file..." + teardown + fi + echo -e "Finished running tests in $test_file.\n" + } + trap exit_trap EXIT + + # If a setup function is defined, run it before the tests + if [[ $(declare -F setup) ]]; then + echo -e "Running setup for $test_file...\n" + setup + fi + + # Discover and run all test_ functions in one loop + while read -r _ _ funcname; do + if [[ $funcname == test_* ]]; then + echo "Running $funcname..." + $funcname + if [[ $? -ne 0 ]]; then + echo -e "FAIL: $funcname returned a non-zero exit code.\n" + # We are in subshell, so can exit with 1, and trap will run teardown if defined + exit 1 + fi + echo -e "OK\n" + fi + done < <(declare -F) +)} + +TEST_FILES=(test-*.sh) +for test_file in "${TEST_FILES[@]}"; do + # Only source if file exists and is not empty + if [[ -f "$test_file" && -s "$test_file" ]]; then + run_tests "$test_file" + if [[ $? -ne 0 ]]; then + exit 1 + fi + fi +done + +echo "All tests passed." diff --git a/tests/test-cli.sh b/tests/test-cli.sh old mode 100755 new mode 100644 index 9e7889c..c71d961 --- a/tests/test-cli.sh +++ b/tests/test-cli.sh @@ -1,108 +1,80 @@ #!/usr/bin/env bash -set -eu -# Cd to the directory of this script -cd "${0%/*}" - -# Test variables -TEST_CMD="test-$(( RANDOM % 900000 + 100000 ))" -TEST_CMD_PATH="commands/$TEST_CMD.sh" -HELP_TXT_PATH="commands/$TEST_CMD.help.txt" - -PROJECT_ROOT="$(dirname "$(dirname "$(realpath "$0")")")" -cleanup() { - rm "$PROJECT_ROOT/$TEST_CMD_PATH" || true - rm "$PROJECT_ROOT/$HELP_TXT_PATH" || true - rm "$PROJECT_ROOT/tests/cli-symlink.sh" || true +teardown() { + rm "$TEST_DIR/../commands/test-cmd.sh" || true + rm "$TEST_DIR/../commands/test-cmd.help.txt" || true + rm "$TEST_DIR/cli-symlink.sh" || true } -trap cleanup EXIT - -# Create test command script -echo "Create test command at $TEST_CMD_PATH..." -echo 'echo "Test command executed"' > "../$TEST_CMD_PATH" -echo "OK" -echo - -# Run tests -echo "Testing cli.sh output for our test command..." -cd .. -OUT=$(./cli.sh) -if [[ "$OUT" != *"$TEST_CMD"* ]]; then - echo "FAIL: Test command $TEST_CMD not found in cli.sh output" - exit 1 -fi -echo "OK" -echo +setup() { + echo 'echo "Test command executed"' > "$TEST_DIR/../commands/test-cmd.sh" + echo "this is a test command" > "$TEST_DIR/../commands/test-cmd.help.txt" + echo "this line should not show on the command list" >> "$TEST_DIR/../commands/test-cmd.help.txt" + ln -s ../cli.sh cli-symlink.sh || true +} -echo "Testing running cli.sh from tests directory, to proove that it can be run from anywhere..." -cd tests -OUT=$(./../cli.sh) -if [[ "$OUT" != *"$TEST_CMD"* ]]; then - echo "FAIL: Test command $TEST_CMD not found when running from tests dir" - exit 1 -fi -cd .. -echo "OK" -echo +test_cli_output_for_test_cmd() { + echo "Testing cli.sh output for our test command" + cd .. + if [[ "$(./cli.sh)" != *"test-cmd"* ]]; then + echo "FAIL: Test command test-cmd not found in cli.sh output." + exit 1 + fi + cd tests +} -echo "Testing cli.sh as a symlink from tests directory, to proove that it can be run as a symlink from anywhere..." -cd tests -ln -s ../cli.sh ./cli-symlink.sh -OUT=$(./cli-symlink.sh) -if [[ "$OUT" != *"$TEST_CMD"* ]]; then - echo "FAIL: Test command $TEST_CMD not found when running cli.sh as a symlink from tests dir" - exit 1 -fi -cd .. -echo "OK" -echo +test_cli_from_tests_dir() { + echo "Testing running cli.sh from tests directory, to proove that it can be run from anywhere" + if [[ "$(./../cli.sh)" != *"test-cmd"* ]]; then + echo "FAIL: Test command test-cmd not found when running from tests dir." + exit 1 + fi +} -echo "Testing cli.sh list sub-command to check output..." -OUT=$(./cli.sh list) -if [[ "$OUT" != *"$TEST_CMD"* ]]; then - echo "FAIL: Test command $TEST_CMD not found in cli.sh list output" - exit 1 -fi -echo "OK" -echo +test_cli_symlink_from_tests_dir() { + echo "Testing cli.sh as a symlink from tests directory, to prove that it can be run as a symlink from anywhere" + if [[ "$(./cli-symlink.sh)" != *"test-cmd"* ]]; then + echo "FAIL: Test command test-cmd not found when running cli.sh as a symlink from tests dir" + exit 1 + fi +} -echo "Create $HELP_TXT_PATH file for test command..." -echo "this is a test command" > "$HELP_TXT_PATH" -OUT=$(./cli.sh list) -if [[ "$OUT" != *"this is a test command"* ]]; then - echo "FAIL: $HELP_TXT_PATH content not found in cli.sh list output" - exit 1 -fi -echo "OK" -echo +test_cli_list_output() { + echo "Testing list sub-command to check output" + if [[ "$(./../cli.sh list)" != *"test-cmd"* ]]; then + echo "FAIL: Test command test-cmd not found in cli.sh list output" + exit 1 + fi +} -echo "Test that a second line in the help file does not show on command list..." -echo "this line should not show on the command list" >> "$HELP_TXT_PATH" -OUT=$(./cli.sh list) -if [[ "$OUT" = *"this line should not show on the command list"* ]]; then - echo "FAIL: $HELP_TXT_PATH content not found in cli.sh list output" - exit 1 -fi -echo "OK" -echo +test_list_output_for_test_cmd_help() { + echo "Testing help text file file for test command" + if [[ "$(./../cli.sh list)" != *"this is a test command"* ]]; then + echo "FAIL: content not found in cli.sh list output" + exit 1 + fi +} -echo "But that it does show when running help $TEST_CMD..." -OUT=$(./cli.sh help $TEST_CMD) -if [[ "$OUT" != *"this line should not show on the command list"* ]]; then - echo "FAIL: $HELP_TXT_PATH content not found in cli.sh list output" - exit 1 -fi -echo "OK" -echo +test_help_output_for_test_cmd() { + echo "Testing help output for test command" + if [[ "$(./../cli.sh help test-cmd)" != *"this is a test command"* ]]; then + echo "FAIL: content not found in cli.sh help output" + exit 1 + fi +} -echo "Testing cli.sh help $TEST_CMD to check output..." -OUT=$(./cli.sh help $TEST_CMD) -if [[ "$OUT" != *"this is a test command"* ]]; then - echo "FAIL: cli.sh help did not show $HELP_TXT_PATH content" - exit 1 -fi -echo "OK" -echo +test_list_only_shows_first_line_of_help_file() { + echo "Test that a second line in the help file does not show on command list" + if [[ "$(./../cli.sh list)" = *"this line should not show on the command list"* ]]; then + echo "FAIL: content not found in cli.sh list output" + exit 1 + fi +} -echo "All tests passed!" +test_help_shows_all_lines_of_help_file() { + echo "Test that second line of help file shows when running help test-cmd" + if [[ "$(./../cli.sh help test-cmd)" != *"this line should not show on the command list"* ]]; then + echo "FAIL: content not found in cli.sh help output" + exit 1 + fi +} From c19673b11c4041024a0985fe34453afb1851692b Mon Sep 17 00:00:00 2001 From: Aaron McHale Date: Fri, 3 Apr 2026 17:02:40 +0100 Subject: [PATCH 2/6] Shellcheck and other fixes --- tests/run-tests.sh | 15 ++++++++------- tests/test-cli.sh | 16 ++++++++-------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 24f3158..98b7faf 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -4,10 +4,11 @@ set -u # Run all test_ functions in test-*.sh scripts # Cd to the directory of this script, so it can be run from anywhere -realpath="$(realpath "$0")" -cd "${realpath%/*}" +realpath="$(realpath "$0")" || { echo "Failed to get realpath"; exit 1; } +cd "${realpath%/*}" || { echo "Failed to cd to script directory"; exit 1; } # Save the current directory if tests need a absolute path reference +# shellcheck disable=SC2034 TEST_DIR="$PWD" # Run a test file. @@ -16,9 +17,11 @@ TEST_DIR="$PWD" run_tests() {( local test_file="$1" echo -e "Running tests in $test_file...\n" + # shellcheck disable=SC1090 source "$test_file" # As we're running in a subshell, we can use trap to run teardown and print message on exit. + # shellcheck disable=SC2329 exit_trap() { # If a teardown function is defined, set it to run on EXIT if [[ $(declare -F teardown) ]]; then @@ -39,8 +42,7 @@ run_tests() {( while read -r _ _ funcname; do if [[ $funcname == test_* ]]; then echo "Running $funcname..." - $funcname - if [[ $? -ne 0 ]]; then + if ! $funcname; then echo -e "FAIL: $funcname returned a non-zero exit code.\n" # We are in subshell, so can exit with 1, and trap will run teardown if defined exit 1 @@ -52,10 +54,9 @@ run_tests() {( TEST_FILES=(test-*.sh) for test_file in "${TEST_FILES[@]}"; do - # Only source if file exists and is not empty + # Confirm that test_file is a file and is not empty before trying to run it if [[ -f "$test_file" && -s "$test_file" ]]; then - run_tests "$test_file" - if [[ $? -ne 0 ]]; then + if ! run_tests "$test_file"; then exit 1 fi fi diff --git a/tests/test-cli.sh b/tests/test-cli.sh index c71d961..d06bb4c 100644 --- a/tests/test-cli.sh +++ b/tests/test-cli.sh @@ -18,7 +18,7 @@ test_cli_output_for_test_cmd() { cd .. if [[ "$(./cli.sh)" != *"test-cmd"* ]]; then echo "FAIL: Test command test-cmd not found in cli.sh output." - exit 1 + return 1 fi cd tests } @@ -27,7 +27,7 @@ test_cli_from_tests_dir() { echo "Testing running cli.sh from tests directory, to proove that it can be run from anywhere" if [[ "$(./../cli.sh)" != *"test-cmd"* ]]; then echo "FAIL: Test command test-cmd not found when running from tests dir." - exit 1 + return 1 fi } @@ -35,7 +35,7 @@ test_cli_symlink_from_tests_dir() { echo "Testing cli.sh as a symlink from tests directory, to prove that it can be run as a symlink from anywhere" if [[ "$(./cli-symlink.sh)" != *"test-cmd"* ]]; then echo "FAIL: Test command test-cmd not found when running cli.sh as a symlink from tests dir" - exit 1 + return 1 fi } @@ -43,7 +43,7 @@ test_cli_list_output() { echo "Testing list sub-command to check output" if [[ "$(./../cli.sh list)" != *"test-cmd"* ]]; then echo "FAIL: Test command test-cmd not found in cli.sh list output" - exit 1 + return 1 fi } @@ -51,7 +51,7 @@ test_list_output_for_test_cmd_help() { echo "Testing help text file file for test command" if [[ "$(./../cli.sh list)" != *"this is a test command"* ]]; then echo "FAIL: content not found in cli.sh list output" - exit 1 + return 1 fi } @@ -59,7 +59,7 @@ test_help_output_for_test_cmd() { echo "Testing help output for test command" if [[ "$(./../cli.sh help test-cmd)" != *"this is a test command"* ]]; then echo "FAIL: content not found in cli.sh help output" - exit 1 + return 1 fi } @@ -67,7 +67,7 @@ test_list_only_shows_first_line_of_help_file() { echo "Test that a second line in the help file does not show on command list" if [[ "$(./../cli.sh list)" = *"this line should not show on the command list"* ]]; then echo "FAIL: content not found in cli.sh list output" - exit 1 + return 1 fi } @@ -75,6 +75,6 @@ test_help_shows_all_lines_of_help_file() { echo "Test that second line of help file shows when running help test-cmd" if [[ "$(./../cli.sh help test-cmd)" != *"this line should not show on the command list"* ]]; then echo "FAIL: content not found in cli.sh help output" - exit 1 + return 1 fi } From 878f494922e4b1be8d04d2ee549a28766029ad2f Mon Sep 17 00:00:00 2001 From: Aaron McHale Date: Fri, 3 Apr 2026 17:04:55 +0100 Subject: [PATCH 3/6] Shellcheck --- tests/test-cli.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test-cli.sh b/tests/test-cli.sh index d06bb4c..81a1612 100644 --- a/tests/test-cli.sh +++ b/tests/test-cli.sh @@ -20,7 +20,7 @@ test_cli_output_for_test_cmd() { echo "FAIL: Test command test-cmd not found in cli.sh output." return 1 fi - cd tests + cd tests || { echo "Failed to cd back to tests directory"; return 1; } } test_cli_from_tests_dir() { From 0f59d258e78d97cceee769327fff9b4abe900226 Mon Sep 17 00:00:00 2001 From: Aaron McHale Date: Sun, 19 Apr 2026 14:33:38 +0100 Subject: [PATCH 4/6] More docs and tests for helper functions --- docs/Tests.md | 52 ++++++++++++++++++++++++ tests/test-cli-helper-functions.sh | 65 ++++++++++++++++++++++++++++++ tests/test-cli.sh | 2 +- 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 tests/test-cli-helper-functions.sh diff --git a/docs/Tests.md b/docs/Tests.md index b1b5e2d..01c9090 100644 --- a/docs/Tests.md +++ b/docs/Tests.md @@ -1,3 +1,55 @@ # Tests +This project includes a simple test runner for writing and running tests in Bash. +## Running tests + +To run all tests, run `./tests/run-tests.sh`, this is known as the test runner. + +The test runner finds all shell scripts in `./tests` which start with `test-` and end in `.sh`. Each script is sourced and all functions starting `test_` are run. + +Each test file is sourced in a sub-shell, this means that variables and functions defined in test files should not leak out of the test file. + +## Structure of a test file + +A test file may contian: + +1. A `setup` function which is responsible for setting up the environment, this could include creating files and folders that are needed for the tests to run. If `setup` exists, the test runner will run it before running any tests, this means test functions do not need to call `setup`. +2. One or more test functions, each test function must start with `test_`. +3. A `teardown` function which is responsible for cleaning up the environment, if `setup` or any test functions create files, directories or make any other changes, `teardown` must undo those and restore the environment. If `teardown` exists, the test runner will run it after all tests complete, or after any tests fail, this means test functions do not need to call `teardown`. Teardown functions should try to always true. + +## Writing test functions + +Tests functions should be written as follows: +* Test functions must be named starting with `test_`, they will be run automatically by the test runner. +* Use the `setup` function for setting up the environment, doing things like creating files and directories used by tests. +* Use the `teardown` function for restoring the environment, deleting anything created in the `setup` function. +* Test functions must never call `setup` and `teardown`, the test runner will call these. +* Test functions should return `0` if the test is successful, or `1` if the test fails. +* Test functions must never use `exit`, they should always use `return` instead, this ensures control is properly handed back to the test runner. Tests should `return 1` for fail, and `return 0` for success. +* The variable `$TEST_DIR` contains the absolute path to the `tests` directory, this is defined by the test runner. + +## Examples + +```Bash +# Example setup function +setup() { + echo 'echo "Test command executed"' > "$TEST_DIR/../commands/test-cmd.sh" +} + +# Example test function +test_cli_output_for_test_cmd() { + echo "Testing cli.sh output for our test command" + cd .. + if [[ "$(./cli.sh)" != *"test-cmd"* ]]; then + echo "FAIL: Test command test-cmd not found in cli.sh output." + return 1 + fi + cd "$TEST_DIR" || { echo "Failed to cd back to tests directory"; return 1; } +} + +# Example teardown function +teardown() { + rm "$TEST_DIR/../commands/test-cmd.sh" || true +} +``` diff --git a/tests/test-cli-helper-functions.sh b/tests/test-cli-helper-functions.sh new file mode 100644 index 0000000..bd747a0 --- /dev/null +++ b/tests/test-cli-helper-functions.sh @@ -0,0 +1,65 @@ +# Tests for warning, error, and success output formatting + +setup() { + # Use Bash builtin printf to write the command script + printf '%s\n' '#!/usr/bin/env bash' \ + 'command_exists "formatting-test" && echo "formatting-test exists"' \ + 'warning This is a warning' \ + 'error This is an error' \ + 'success This is a success' > "$TEST_DIR/../commands/formatting-test.sh" + + # Run the command and store output in a variable for tests + TEST_FORMATTING_OUTPUT=$("$TEST_DIR/../cli.sh" formatting-test 2>&1) +} + +test_command_exists() { + local expected + expected="formatting-test exists" + if [[ "$TEST_FORMATTING_OUTPUT" != *"$expected"* ]]; then + echo "Output of formatting-test command does not contian expected output." + echo "Expected: $expected" + echo "Full output: $TEST_FORMATTING_OUTPUT" + return 1 + fi + return 0 +} + +test_warning_output() { + local expected + expected="This is a warning" + if [[ "$TEST_FORMATTING_OUTPUT" != *"$expected"* ]]; then + echo "Output of formatting-test command does not contian expected output." + echo "Expected: $expected" + echo "Full output: $TEST_FORMATTING_OUTPUT" + return 1 + fi + return 0 +} + +test_error_output() { + local expected + expected="This is an error" + if [[ "$TEST_FORMATTING_OUTPUT" != *"$expected"* ]]; then + echo "Output of formatting-test command does not contian expected output." + echo "Expected: $expected" + echo "Full output: $TEST_FORMATTING_OUTPUT" + return 1 + fi + return 0 +} + +test_success_output() { + local expected + expected="This is a success" + if [[ "$TEST_FORMATTING_OUTPUT" != *"$expected"* ]]; then + echo "Output of formatting-test command does not contian expected output." + echo "Expected: $expected" + echo "Full output: $TEST_FORMATTING_OUTPUT" + return 1 + fi + return 0 +} + +teardown() { + rm -f "$TEST_DIR/../commands/formatting-test.sh" || true +} diff --git a/tests/test-cli.sh b/tests/test-cli.sh index 81a1612..fba02c9 100644 --- a/tests/test-cli.sh +++ b/tests/test-cli.sh @@ -20,7 +20,7 @@ test_cli_output_for_test_cmd() { echo "FAIL: Test command test-cmd not found in cli.sh output." return 1 fi - cd tests || { echo "Failed to cd back to tests directory"; return 1; } + cd "$TEST_DIR" || { echo "Failed to cd back to tests directory"; return 1; } } test_cli_from_tests_dir() { From 9f88c233021ef91666e3b367243d25ce5d938190 Mon Sep 17 00:00:00 2001 From: Aaron McHale Date: Sun, 19 Apr 2026 14:35:20 +0100 Subject: [PATCH 5/6] Linting --- tests/test-cli-helper-functions.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test-cli-helper-functions.sh b/tests/test-cli-helper-functions.sh index bd747a0..bab593b 100644 --- a/tests/test-cli-helper-functions.sh +++ b/tests/test-cli-helper-functions.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + # Tests for warning, error, and success output formatting setup() { From dcc3ef8fa26299ebd319d313a3774fd0b5b64fe4 Mon Sep 17 00:00:00 2001 From: Aaron McHale Date: Sun, 19 Apr 2026 14:37:43 +0100 Subject: [PATCH 6/6] Run test-cli first --- tests/{test-cli.sh => test-cli-0.sh} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test-cli.sh => test-cli-0.sh} (100%) diff --git a/tests/test-cli.sh b/tests/test-cli-0.sh similarity index 100% rename from tests/test-cli.sh rename to tests/test-cli-0.sh