From a668cd49ea1bdad5e17eecdd064f616f1ebe1ed4 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 19 Apr 2026 17:41:11 +0200 Subject: [PATCH 1/2] feat(assert): test interactive prompt commands via assert_exec (#301) Add --stdin, --stdout-contains, --stdout-not-contains, --stderr-contains and --stderr-not-contains flags to assert_exec so interactive commands that read from stdin can be exercised from tests and output can be asserted via substring match. Closes #301 --- CHANGELOG.md | 1 + docs/assertions.md | 25 +++- src/assert.sh | 82 ++++++++++- ...it_should_display_all_assert_docs.snapshot | 8 +- tests/unit/assert_advanced_test.sh | 127 ++++++++++++++++++ 5 files changed, 239 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59430f9f..a58060c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Assert functions accept an optional trailing label to override the failure title (#77) - `--fail-on-risky` flag and `BASHUNIT_FAIL_ON_RISKY` env var treat no-assertion tests as failures (#115) - `--log-gha ` flag and `BASHUNIT_LOG_GHA` env var emit GitHub Actions workflow commands so failed, risky and incomplete tests show up as inline PR annotations (#280) +- `assert_exec` accepts `--stdin`, `--stdout-contains`, `--stdout-not-contains`, `--stderr-contains` and `--stderr-not-contains` flags to test interactive prompt commands and substring output (#301) ### Changed - Parallel test execution is now enabled on Alpine Linux (#370) diff --git a/docs/assertions.md b/docs/assertions.md index f3062e6a..5d227266 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -513,12 +513,18 @@ function test_failure() { ::: ## assert_exec -> `assert_exec "command" [--exit ] [--stdout "text"] [--stderr "text"]` +> `assert_exec "command" [--exit ] [--stdout "text"] [--stderr "text"] [--stdout-contains "needle"] [--stdout-not-contains "needle"] [--stderr-contains "needle"] [--stderr-not-contains "needle"] [--stdin "input"]` Runs `command` capturing its exit status, standard output and standard error and checks all provided expectations. When `--exit` is omitted the expected exit status defaults to `0`. +Use `--stdin` to feed input into interactive commands (e.g. commands using +`read`). Multiple answers can be passed by separating them with newlines. + +Use `--stdout-contains` / `--stdout-not-contains` (and the `stderr-*` variants) +for substring matching when you don't want to assert against the full output. + ::: code-group ```bash [Example] function sample() { @@ -535,6 +541,23 @@ function test_failure() { assert_exec sample --exit 0 --stdout "out" --stderr "err" } ``` + +```bash [Interactive] +function question() { + local name lang + read -r name + read -r lang + echo "Your name is $name and you prefer $lang." +} + +function test_interactive_prompt() { + assert_exec question \ + --stdin "Taylor Otwell"$'\n'"PHP"$'\n' \ + --stdout-contains "Your name is Taylor Otwell and you prefer PHP." \ + --stdout-not-contains "Ruby" \ + --exit 0 +} +``` ::: ## assert_array_contains diff --git a/src/assert.sh b/src/assert.sh index 968b974c..3cad4745 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -380,8 +380,18 @@ function assert_exec() { local expected_exit=0 local expected_stdout="" local expected_stderr="" + local stdout_needle="" + local stdout_no_needle="" + local stderr_needle="" + local stderr_no_needle="" + local stdin_input="" local check_stdout=false local check_stderr=false + local check_stdout_contains=false + local check_stdout_not_contains=false + local check_stderr_contains=false + local check_stderr_not_contains=false + local check_stdin=false while [ $# -gt 0 ]; do case "$1" in @@ -399,6 +409,31 @@ function assert_exec() { check_stderr=true shift 2 ;; + --stdout-contains) + stdout_needle="$2" + check_stdout_contains=true + shift 2 + ;; + --stdout-not-contains) + stdout_no_needle="$2" + check_stdout_not_contains=true + shift 2 + ;; + --stderr-contains) + stderr_needle="$2" + check_stderr_contains=true + shift 2 + ;; + --stderr-not-contains) + stderr_no_needle="$2" + check_stderr_not_contains=true + shift 2 + ;; + --stdin) + stdin_input="$2" + check_stdin=true + shift 2 + ;; *) shift ;; @@ -409,8 +444,17 @@ function assert_exec() { stdout_file=$("$MKTEMP") stderr_file=$("$MKTEMP") - eval "$cmd" >"$stdout_file" 2>"$stderr_file" - local exit_code=$? + if $check_stdin; then + local stdin_file + stdin_file=$("$MKTEMP") + printf '%s' "$stdin_input" >"$stdin_file" + eval "$cmd" <"$stdin_file" >"$stdout_file" 2>"$stderr_file" + local exit_code=$? + rm -f "$stdin_file" + else + eval "$cmd" >"$stdout_file" 2>"$stderr_file" + local exit_code=$? + fi local stdout stdout=$(cat "$stdout_file") @@ -435,6 +479,23 @@ function assert_exec() { fi fi + if $check_stdout_contains; then + expected_desc="$expected_desc"$'\n'"stdout contains: $stdout_needle" + actual_desc="$actual_desc"$'\n'"stdout: $stdout" + case "$stdout" in + *"$stdout_needle"*) ;; + *) failed=1 ;; + esac + fi + + if $check_stdout_not_contains; then + expected_desc="$expected_desc"$'\n'"stdout not contains: $stdout_no_needle" + actual_desc="$actual_desc"$'\n'"stdout: $stdout" + case "$stdout" in + *"$stdout_no_needle"*) failed=1 ;; + esac + fi + if $check_stderr; then expected_desc="$expected_desc"$'\n'"stderr: $expected_stderr" actual_desc="$actual_desc"$'\n'"stderr: $stderr" @@ -443,6 +504,23 @@ function assert_exec() { fi fi + if $check_stderr_contains; then + expected_desc="$expected_desc"$'\n'"stderr contains: $stderr_needle" + actual_desc="$actual_desc"$'\n'"stderr: $stderr" + case "$stderr" in + *"$stderr_needle"*) ;; + *) failed=1 ;; + esac + fi + + if $check_stderr_not_contains; then + expected_desc="$expected_desc"$'\n'"stderr not contains: $stderr_no_needle" + actual_desc="$actual_desc"$'\n'"stderr: $stderr" + case "$stderr" in + *"$stderr_no_needle"*) failed=1 ;; + esac + fi + if [ "$failed" -eq 1 ]; then local label label="$(bashunit::assert::label "${label_override:-}")" diff --git a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot index 65240fac..3bfd9242 100644 --- a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot +++ b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot @@ -223,12 +223,18 @@ are more semantic versions of this assertion, for which you don't need to specif ## assert_exec -------------- -> `assert_exec "command" --exit --stdout "text" --stderr "text"` +> `assert_exec "command" --exit --stdout "text" --stderr "text" --stdout-contains "needle" --stdout-not-contains "needle" --stderr-contains "needle" --stderr-not-contains "needle" --stdin "input"` Runs `command` capturing its exit status, standard output and standard error and checks all provided expectations. When `--exit` is omitted the expected exit status defaults to `0`. +Use `--stdin` to feed input into interactive commands (e.g. commands using +`read`). Multiple answers can be passed by separating them with newlines. + +Use `--stdout-contains` / `--stdout-not-contains` (and the `stderr-*` variants) +for substring matching when you don't want to assert against the full output. + ## assert_array_contains -------------- diff --git a/tests/unit/assert_advanced_test.sh b/tests/unit/assert_advanced_test.sh index 6a82fdf9..8f9dfd48 100644 --- a/tests/unit/assert_advanced_test.sh +++ b/tests/unit/assert_advanced_test.sh @@ -160,3 +160,130 @@ function test_assert_line_count_does_not_modify_existing_variable() { assert_empty "$(assert_line_count 1 "one")" assert_same "original" "$additional_new_lines" } + +function test_successful_assert_exec_with_stdin() { + # shellcheck disable=SC2317 + function prompt_command() { + local name lang + read -r name + read -r lang + echo "Your name is $name and you prefer $lang." + } + + assert_empty "$(assert_exec prompt_command \ + --stdin "Taylor Otwell"$'\n'"PHP"$'\n' \ + --stdout "Your name is Taylor Otwell and you prefer PHP." \ + --exit 0)" +} + +function test_successful_assert_exec_stdout_contains() { + # shellcheck disable=SC2317 + function greet_command() { + echo "Hello, World! Welcome to bashunit." + } + + assert_empty "$(assert_exec greet_command --stdout-contains "bashunit")" +} + +function test_unsuccessful_assert_exec_stdout_contains() { + # shellcheck disable=SC2317 + function greet_command() { + echo "Hello, World!" + } + + local expected="exit: 0"$'\n'"stdout contains: bashunit" + local actual="exit: 0"$'\n'"stdout: Hello, World!" + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert exec stdout contains" "$expected" "but got " "$actual")" \ + "$(assert_exec greet_command --stdout-contains "bashunit")" +} + +function test_successful_assert_exec_stdout_not_contains() { + # shellcheck disable=SC2317 + function greet_command() { + echo "Hello, World!" + } + + assert_empty "$(assert_exec greet_command --stdout-not-contains "Ruby")" +} + +function test_unsuccessful_assert_exec_stdout_not_contains() { + # shellcheck disable=SC2317 + function greet_command() { + echo "Hello, Ruby lovers!" + } + + local expected="exit: 0"$'\n'"stdout not contains: Ruby" + local actual="exit: 0"$'\n'"stdout: Hello, Ruby lovers!" + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert exec stdout not contains" "$expected" "but got " "$actual")" \ + "$(assert_exec greet_command --stdout-not-contains "Ruby")" +} + +function test_successful_assert_exec_stderr_contains() { + # shellcheck disable=SC2317 + function warn_command() { + echo "warning: low disk" >&2 + } + + assert_empty "$(assert_exec warn_command --stderr-contains "low disk")" +} + +function test_unsuccessful_assert_exec_stderr_contains() { + # shellcheck disable=SC2317 + function warn_command() { + echo "ok" >&2 + } + + local expected="exit: 0"$'\n'"stderr contains: failure" + local actual="exit: 0"$'\n'"stderr: ok" + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert exec stderr contains" "$expected" "but got " "$actual")" \ + "$(assert_exec warn_command --stderr-contains "failure")" +} + +function test_successful_assert_exec_stderr_not_contains() { + # shellcheck disable=SC2317 + function warn_command() { + echo "ok" >&2 + } + + assert_empty "$(assert_exec warn_command --stderr-not-contains "error")" +} + +function test_unsuccessful_assert_exec_stderr_not_contains() { + # shellcheck disable=SC2317 + function warn_command() { + echo "fatal error" >&2 + } + + local expected="exit: 0"$'\n'"stderr not contains: error" + local actual="exit: 0"$'\n'"stderr: fatal error" + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert exec stderr not contains" "$expected" "but got " "$actual")" \ + "$(assert_exec warn_command --stderr-not-contains "error")" +} + +function test_successful_assert_exec_interactive_prompt_flow() { + # shellcheck disable=SC2317 + function question_command() { + local name lang + read -r name + read -r lang + echo "Your name is $name and you prefer $lang." + } + + assert_empty "$(assert_exec question_command \ + --stdin "Taylor Otwell"$'\n'"PHP"$'\n' \ + --stdout-contains "Your name is Taylor Otwell and you prefer PHP." \ + --stdout-not-contains "Ruby" \ + --exit 0)" +} From bcb4d3ce1ee5fcfb88f8a0a7feee0bc416e3045c Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 19 Apr 2026 17:48:00 +0200 Subject: [PATCH 2/2] test(assert): use Chemaclass/Phel-Lang/Delphi in prompt examples --- docs/assertions.md | 6 +++--- tests/unit/assert_advanced_test.sh | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/assertions.md b/docs/assertions.md index 5d227266..6fefe26c 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -552,9 +552,9 @@ function question() { function test_interactive_prompt() { assert_exec question \ - --stdin "Taylor Otwell"$'\n'"PHP"$'\n' \ - --stdout-contains "Your name is Taylor Otwell and you prefer PHP." \ - --stdout-not-contains "Ruby" \ + --stdin "Chemaclass"$'\n'"Phel-Lang"$'\n' \ + --stdout-contains "Your name is Chemaclass and you prefer Phel-Lang." \ + --stdout-not-contains "Delphi" \ --exit 0 } ``` diff --git a/tests/unit/assert_advanced_test.sh b/tests/unit/assert_advanced_test.sh index 8f9dfd48..084c8c6f 100644 --- a/tests/unit/assert_advanced_test.sh +++ b/tests/unit/assert_advanced_test.sh @@ -171,8 +171,8 @@ function test_successful_assert_exec_with_stdin() { } assert_empty "$(assert_exec prompt_command \ - --stdin "Taylor Otwell"$'\n'"PHP"$'\n' \ - --stdout "Your name is Taylor Otwell and you prefer PHP." \ + --stdin "Chemaclass"$'\n'"Phel-Lang"$'\n' \ + --stdout "Your name is Chemaclass and you prefer Phel-Lang." \ --exit 0)" } @@ -206,22 +206,22 @@ function test_successful_assert_exec_stdout_not_contains() { echo "Hello, World!" } - assert_empty "$(assert_exec greet_command --stdout-not-contains "Ruby")" + assert_empty "$(assert_exec greet_command --stdout-not-contains "Delphi")" } function test_unsuccessful_assert_exec_stdout_not_contains() { # shellcheck disable=SC2317 function greet_command() { - echo "Hello, Ruby lovers!" + echo "Hello, Delphi lovers!" } - local expected="exit: 0"$'\n'"stdout not contains: Ruby" - local actual="exit: 0"$'\n'"stdout: Hello, Ruby lovers!" + local expected="exit: 0"$'\n'"stdout not contains: Delphi" + local actual="exit: 0"$'\n'"stdout: Hello, Delphi lovers!" assert_same \ "$(bashunit::console_results::print_failed_test \ "Unsuccessful assert exec stdout not contains" "$expected" "but got " "$actual")" \ - "$(assert_exec greet_command --stdout-not-contains "Ruby")" + "$(assert_exec greet_command --stdout-not-contains "Delphi")" } function test_successful_assert_exec_stderr_contains() { @@ -282,8 +282,8 @@ function test_successful_assert_exec_interactive_prompt_flow() { } assert_empty "$(assert_exec question_command \ - --stdin "Taylor Otwell"$'\n'"PHP"$'\n' \ - --stdout-contains "Your name is Taylor Otwell and you prefer PHP." \ - --stdout-not-contains "Ruby" \ + --stdin "Chemaclass"$'\n'"Phel-Lang"$'\n' \ + --stdout-contains "Your name is Chemaclass and you prefer Phel-Lang." \ + --stdout-not-contains "Delphi" \ --exit 0)" }