From e97438c85362464ee9be34c22078b47641805410 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 1 Aug 2026 02:15:57 +0200 Subject: [PATCH] refactor(learn): split src/learn.sh into a src/learn/ module Fourth module under ADR-010. 1213 lines become 15 files behind a `source`-only entry point. learn/progress.sh LEARN_PROGRESS_FILE, mark_completed, is_completed, show_progress, reset_progress learn/session.sh LEARN_TEMP_DIR, init, cleanup, create_example_file, run_lesson_test learn/lessons/*.sh one file per lesson (10) learn/menu.sh print_menu, start Layering from all 37 internal call edges: progress <- session <- lessons <- menu. Every lesson calls exactly create_example_file and run_lesson_test and nothing else; no lesson calls another, so the ten are independent. `start` is the sole dispatcher and `bashunit::learn::start` remains the only external entry point. The issue's table lists this file as 82 functions. Only 20 are real -- the other 62 are sample code inside heredocs (the tutorial's example tests), counted because they sit at line-start in heredoc bodies. That distinction mattered: segmenting on a `^}$` closing brace, which worked for the previous three modules, terminates lesson bodies early here at a brace inside rendered sample code and would have split functions across files. Segment boundaries are taken from the next function's start instead, which heredocs cannot affect, and every new file is `bash -n` clean. `tests/unit/learn_test.sh` sources the module by path to get an isolated $HOME (LEARN_PROGRESS_FILE is a readonly resolved at source time), so its hardcoded `src/learn.sh` is repointed at `src/learn/index.sh`. BASHUNIT_ROOT_DIR is already exported in that sandbox, so the entry point's `source` lines resolve. A relocation: the non-blank line multiset differs only by the new shebangs, module headers and `source` lines; 20 functions before and after; the sorted code content of the built artifact is identical and `bash build.sh bin -v` prints "Build verified". The two file-scope globals do not reference each other, so their reordering across files is inert. The file-wide `# shellcheck disable=SC2016` moved to the seven lesson files that actually need it (their prose shows literal `$vars` in single quotes) rather than being applied to all fifteen. Lines over 120 *bytes* are box-drawing UTF-8 and under 120 characters, so no .editorconfig rule is needed. Related #931 --- bashunit | 2 +- src/learn.sh | 1213 --------------------------- src/learn/index.sh | 26 + src/learn/lessons/assertions.sh | 88 ++ src/learn/lessons/basics.sh | 68 ++ src/learn/lessons/challenge.sh | 146 ++++ src/learn/lessons/data_providers.sh | 119 +++ src/learn/lessons/exit_codes.sh | 122 +++ src/learn/lessons/functions.sh | 98 +++ src/learn/lessons/lifecycle.sh | 97 +++ src/learn/lessons/mocking.sh | 110 +++ src/learn/lessons/scripts.sh | 89 ++ src/learn/lessons/spies.sh | 121 +++ src/learn/menu.sh | 73 ++ src/learn/progress.sh | 71 ++ src/learn/session.sh | 70 ++ tests/unit/learn_test.sh | 10 +- 17 files changed, 1304 insertions(+), 1219 deletions(-) delete mode 100644 src/learn.sh create mode 100644 src/learn/index.sh create mode 100644 src/learn/lessons/assertions.sh create mode 100644 src/learn/lessons/basics.sh create mode 100644 src/learn/lessons/challenge.sh create mode 100644 src/learn/lessons/data_providers.sh create mode 100644 src/learn/lessons/exit_codes.sh create mode 100644 src/learn/lessons/functions.sh create mode 100644 src/learn/lessons/lifecycle.sh create mode 100644 src/learn/lessons/mocking.sh create mode 100644 src/learn/lessons/scripts.sh create mode 100644 src/learn/lessons/spies.sh create mode 100644 src/learn/menu.sh create mode 100644 src/learn/progress.sh create mode 100644 src/learn/session.sh diff --git a/bashunit b/bashunit index 52c49b05..cf71d475 100755 --- a/bashunit +++ b/bashunit @@ -91,7 +91,7 @@ source "$BASHUNIT_ROOT_DIR/src/runner/index.sh" source "$BASHUNIT_ROOT_DIR/src/benchmark.sh" source "$BASHUNIT_ROOT_DIR/src/bashunit.sh" source "$BASHUNIT_ROOT_DIR/src/init.sh" -source "$BASHUNIT_ROOT_DIR/src/learn.sh" +source "$BASHUNIT_ROOT_DIR/src/learn/index.sh" source "$BASHUNIT_ROOT_DIR/src/main.sh" bashunit::check_os::init diff --git a/src/learn.sh b/src/learn.sh deleted file mode 100644 index d9bb6251..00000000 --- a/src/learn.sh +++ /dev/null @@ -1,1213 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2016 - -## -# Interactive learning module for bashunit -# Provides guided tutorials and exercises to learn bashunit -## - -LEARN_TEMP_DIR="" -declare -r LEARN_PROGRESS_FILE="$HOME/.bashunit_learn_progress" - -## -# Initialize learning environment -## -function bashunit::learn::init() { - LEARN_TEMP_DIR=$("${MKTEMP:-mktemp}" -d "${TMPDIR:-/tmp}/bashunit_learn.XXXXXXXX") - mkdir -p tests -} - -## -# Cleanup learning environment -## -function bashunit::learn::cleanup() { - if [ -n "${LEARN_TEMP_DIR:-}" ] && [ -d "$LEARN_TEMP_DIR" ]; then - rm -rf "$LEARN_TEMP_DIR" - fi -} - -## -# Print the learning menu -## -function bashunit::learn::print_menu() { - cat <>"$LEARN_PROGRESS_FILE" -} - -## -# Check if lesson is completed -## -function bashunit::learn::is_completed() { - local lesson=$1 - [ -f "$LEARN_PROGRESS_FILE" ] && [ "$("$GREP" -c "^$lesson$" "$LEARN_PROGRESS_FILE" || true)" -gt 0 ] -} - -## -# Show learning progress -## -function bashunit::learn::show_progress() { - if [ ! -f "$LEARN_PROGRESS_FILE" ]; then - echo "${_BASHUNIT_COLOR_INCOMPLETE}No progress yet. Start with lesson 1!${_BASHUNIT_COLOR_DEFAULT}" - return - fi - - echo "${_BASHUNIT_COLOR_BOLD}Your Progress:${_BASHUNIT_COLOR_DEFAULT}" - echo "" - - local total_lessons=10 - local completed=0 - - local i - for i in $(seq 1 $total_lessons); do - if bashunit::learn::is_completed "lesson_$i"; then - echo " ${_BASHUNIT_COLOR_PASSED}āœ“${_BASHUNIT_COLOR_DEFAULT} Lesson $i completed" - ((++completed)) || true - else - echo " ${_BASHUNIT_COLOR_INCOMPLETE}ā—‹${_BASHUNIT_COLOR_DEFAULT} Lesson $i" - fi - done - - echo "" - echo "Progress: $completed/$total_lessons lessons completed" - - if [ $completed -eq $total_lessons ]; then - echo "" - printf "%s%sšŸŽ‰ Congratulations! You've completed all lessons!%s\n" \ - "$_BASHUNIT_COLOR_PASSED" "$_BASHUNIT_COLOR_BOLD" "$_BASHUNIT_COLOR_DEFAULT" - fi - - read -p "Press Enter to continue..." -r -} - -## -# Reset learning progress -## -function bashunit::learn::reset_progress() { - rm -f "$LEARN_PROGRESS_FILE" - echo "${_BASHUNIT_COLOR_PASSED}Progress reset successfully.${_BASHUNIT_COLOR_DEFAULT}" - read -p "Press Enter to continue..." -r -} - -## -# Create the example file automatically -# Arguments: $1 - filename, $2 - file content -## -function bashunit::learn::create_example_file() { - local filename=$1 - local content=$2 - - echo "" - echo "Creating example file ${_BASHUNIT_COLOR_BOLD}$filename${_BASHUNIT_COLOR_DEFAULT}..." - echo "$content" >"$filename" - chmod +x "$filename" - echo "${_BASHUNIT_COLOR_PASSED}āœ“ Created $filename${_BASHUNIT_COLOR_DEFAULT}" - echo "" - echo "File created! Edit it to complete the TODO items, then run this lesson again." - read -p "Press Enter to continue..." -r - return 0 -} - -## -# Run a lesson test and check results -## -function bashunit::learn::run_lesson_test() { - local test_file=$1 - local lesson_number=$2 - - echo "${_BASHUNIT_COLOR_BOLD}Running your test...${_BASHUNIT_COLOR_DEFAULT}" - echo "" - - if "$BASHUNIT_ROOT_DIR/bashunit" "$test_file" --simple; then - echo "" - printf "%s%sāœ“ Excellent! Lesson %s completed!%s\n" \ - "$_BASHUNIT_COLOR_PASSED" "$_BASHUNIT_COLOR_BOLD" "$lesson_number" "$_BASHUNIT_COLOR_DEFAULT" - bashunit::learn::mark_completed "lesson_$lesson_number" - read -p "Press Enter to continue..." -r - return 0 - else - echo "" - echo "${_BASHUNIT_COLOR_FAILED}Not quite right. Review the requirements and try again.${_BASHUNIT_COLOR_DEFAULT}" - read -p "Press Enter to continue..." -r - return 1 - fi -} - -## -# Lesson 1: Basics - Your First Test -## -function bashunit::learn::lesson_basics() { - clear - cat <<'EOF' -╔════════════════════════════════════════════════════════════════╗ -ā•‘ Lesson 1: Your First Test ā•‘ -ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• - -Welcome to bashunit! Let's write your first test. - -CONCEPT: A test is a function that starts with 'test_' and uses -assertions to verify behavior. - -TASK: Create a test file that checks if two values are equal. - -File: tests/first_test.sh -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function test_bashunit_works() { - # TODO: Use assert_same to check if "hello" equals "hello" - # Hint: assert_same "expected" "actual" -} -─────────────────────────────────────────────────────────────── - -TIPS: - • The assert_same function takes two arguments: - assert_same "expected" "actual" - • Test functions must start with "test_" prefix - • Always quote your strings to avoid word splitting - • Keep test files in a tests/ directory for better organization -EOF - - local default_file="tests/first_test.sh" - echo "" - printf "When ready, enter file path %s[%s]%s: " \ - "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" - read -r test_file - test_file="${test_file:-$default_file}" - - if [ ! -f "$test_file" ]; then - local template='#!/usr/bin/env bash - -function test_bashunit_works() { - # TODO: Use assert_same to check if "hello" equals "hello" - # Hint: assert_same "expected" "actual" -}' - - bashunit::learn::create_example_file "$test_file" "$template" - return 1 - fi - - # Check if file contains assert_same - if [ "$("$GREP" -c "assert_same" "$test_file" || true)" -eq 0 ]; then - echo "${_BASHUNIT_COLOR_FAILED}Your test should use assert_same${_BASHUNIT_COLOR_DEFAULT}" - read -p "Press Enter to continue..." -r - return 1 - fi - - bashunit::learn::run_lesson_test "$test_file" 1 -} - -## -# Lesson 2: Assertions - Testing Different Conditions -## -function bashunit::learn::lesson_assertions() { - clear - cat <<'EOF' -╔════════════════════════════════════════════════════════════════╗ -ā•‘ Lesson 2: Testing Different Conditions ā•‘ -ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• - -CONCEPT: bashunit provides many assertion functions for different checks: - • assert_same - exact equality - • assert_contains - substring check - • assert_matches - regex pattern - • assert_not_same - inequality - • assert_empty - checks if value is empty - • assert_not_empty - checks if value is not empty - -TASK: Write a test file with 3 different assertions. - -File: tests/assertions_test.sh -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function test_multiple_assertions() { - local message="Hello, bashunit!" - - # TODO: Check that message contains "bashunit" - # Hint: assert_contains "substring" "$message" - - # TODO: Check that message matches the pattern "Hello.*!" - # Hint: assert_matches "pattern" "$message" - - # TODO: Check that message is not empty - # Hint: assert_not_empty "$message" -} -─────────────────────────────────────────────────────────────── - -TIPS: - • assert_same checks exact equality (useful for strings/numbers) - • assert_contains is more flexible for partial matches - • assert_matches uses regex patterns (e.g., "^[0-9]+$" for numbers) - • Explore more: assert_empty, assert_true, assert_false -EOF - - local default_file="tests/assertions_test.sh" - echo "" - printf "When ready, enter file path %s[%s]%s: " \ - "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" - read -r test_file - test_file="${test_file:-$default_file}" - - if [ ! -f "$test_file" ]; then - local template='#!/usr/bin/env bash - -function test_multiple_assertions() { - local message="Hello, bashunit!" - - # TODO: Check that message contains "bashunit" - # Hint: assert_contains "substring" "$message" - - # TODO: Check that message matches the pattern "Hello.*!" - # Hint: assert_matches "pattern" "$message" - - # TODO: Check that message is not empty - # Hint: assert_not_empty "$message" -}' - - bashunit::learn::create_example_file "$test_file" "$template" - return 1 - fi - - if [ "$("$GREP" -c "assert_contains" "$test_file" || true)" -eq 0 ] || - [ "$("$GREP" -c "assert_matches" "$test_file" || true)" -eq 0 ] || - [ "$("$GREP" -c "assert_not_empty" "$test_file" || true)" -eq 0 ]; then - echo "${_BASHUNIT_COLOR_FAILED}Your test should use all three assertion types${_BASHUNIT_COLOR_DEFAULT}" - read -p "Press Enter to continue..." -r - return 1 - fi - - bashunit::learn::run_lesson_test "$test_file" 2 -} - -## -# Lesson 3: Setup & Teardown - Managing Test Lifecycle -## -function bashunit::learn::lesson_lifecycle() { - clear - cat <<'EOF' -╔════════════════════════════════════════════════════════════════╗ -ā•‘ Lesson 3: Setup and Teardown Functions ā•‘ -ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• - -CONCEPT: Tests often need preparation and cleanup. bashunit provides: - • set_up() - runs before EACH test - • tear_down() - runs after EACH test - • set_up_before_script() - runs once before ALL tests - • tear_down_after_script() - runs once after ALL tests - -TASK: Create a test that uses setup and teardown to manage files. - -File: tests/lifecycle_test.sh -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function set_up() { - # Create a temp file before each test - # TODO: export TEST_FILE="/tmp/test_$$" - # TODO: echo "test content" > "$TEST_FILE" -} - -function tear_down() { - # Clean up after each test - # TODO: rm -f "$TEST_FILE" -} - -function test_file_exists() { - # TODO: assert_file_exists "$TEST_FILE" -} - -function test_file_has_content() { - # TODO: assert_file_contains "test content" "$TEST_FILE" -} -─────────────────────────────────────────────────────────────── - -TIPS: - • set_up() runs before EACH test (good for test isolation) - • set_up_before_script() runs ONCE before all tests (good for expensive setup) - • Always clean up in tear_down() to avoid polluting other tests - • Use $$ for unique temp file names to avoid conflicts -EOF - - local default_file="tests/lifecycle_test.sh" - echo "" - printf "When ready, enter file path %s[%s]%s: " \ - "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" - read -r test_file - test_file="${test_file:-$default_file}" - - if [ ! -f "$test_file" ]; then - local template='#!/usr/bin/env bash - -function set_up() { - # Create a temp file before each test - # TODO: export TEST_FILE="/tmp/test_$$" - # TODO: echo "test content" > "$TEST_FILE" -} - -function tear_down() { - # Clean up after each test - # TODO: rm -f "$TEST_FILE" -} - -function test_file_exists() { - # TODO: assert_file_exists "$TEST_FILE" -} - -function test_file_has_content() { - # TODO: assert_file_contains "test content" "$TEST_FILE" -}' - - bashunit::learn::create_example_file "$test_file" "$template" - return 1 - fi - - if [ "$("$GREP" -c "function set_up()" "$test_file" || true)" -eq 0 ] || - [ "$("$GREP" -c "function tear_down()" "$test_file" || true)" -eq 0 ]; then - echo "${_BASHUNIT_COLOR_FAILED}Your test should define set_up and tear_down functions${_BASHUNIT_COLOR_DEFAULT}" - read -p "Press Enter to continue..." -r - return 1 - fi - - bashunit::learn::run_lesson_test "$test_file" 3 -} - -## -# Lesson 4: Testing Functions -## -function bashunit::learn::lesson_functions() { - clear - cat <<'EOF' -╔════════════════════════════════════════════════════════════════╗ -ā•‘ Lesson 4: Testing Bash Functions ā•‘ -ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• - -CONCEPT: To test functions, source the file containing them, then -call them in your tests. - -TASK: Create a script with a function, then test it. - -File: calculator.sh (source code) -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function add() { - echo $(($1 + $2)) -} -─────────────────────────────────────────────────────────────── - -File: tests/calculator_test.sh (test file) -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function set_up() { - # TODO: Source calculator.sh from parent directory - # Hint: source ../calculator.sh -} - -function test_add_positive_numbers() { - # TODO: Test that add 2 3 returns "5" - # Hint: result=$(add 2 3) - # Hint: assert_same "5" "$result" -} - -function test_add_negative_numbers() { - # TODO: Test that add -2 -3 returns "-5" - # Hint: result=$(add -2 -3) - # Hint: assert_same "-5" "$result" -} -─────────────────────────────────────────────────────────────── - -TIPS: - • Source files in set_up() to reload them fresh for each test - • Capture function output with: result=$(function_name args) - • Test edge cases: positive, negative, zero, large numbers - • Source files from parent directory: source ../file.sh -EOF - - local default_file="tests/calculator_test.sh" - echo "" - printf "When ready, enter TEST file path %s[%s]%s: " \ - "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" - read -r test_file - test_file="${test_file:-$default_file}" - - if [ ! -f "$test_file" ]; then - local template='#!/usr/bin/env bash - -function set_up() { - # TODO: Source calculator.sh from parent directory - # Hint: source ../calculator.sh -} - -function test_add_positive_numbers() { - # TODO: Test that add 2 3 returns "5" - # Hint: result=$(add 2 3) - # Hint: assert_same "5" "$result" -} - -function test_add_negative_numbers() { - # TODO: Test that add -2 -3 returns "-5" - # Hint: result=$(add -2 -3) - # Hint: assert_same "-5" "$result" -}' - - bashunit::learn::create_example_file "$test_file" "$template" - return 1 - fi - - if [ "$("$GREP" -c "source" "$test_file" || true)" -eq 0 ]; then - echo "${_BASHUNIT_COLOR_FAILED}Your test should source the calculator.sh file${_BASHUNIT_COLOR_DEFAULT}" - read -p "Press Enter to continue..." -r - return 1 - fi - - bashunit::learn::run_lesson_test "$test_file" 4 -} - -## -# Lesson 5: Testing Scripts -## -function bashunit::learn::lesson_scripts() { - clear - cat <<'EOF' -╔════════════════════════════════════════════════════════════════╗ -ā•‘ Lesson 5: Testing Bash Scripts ā•‘ -ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• - -CONCEPT: Scripts that execute commands directly are tested differently. -Run them and capture their output. - -TASK: Create a script and test its output. - -File: greeter.sh (source code) -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash -name=${1:-World} -echo "Hello, $name!" -─────────────────────────────────────────────────────────────── - -File: tests/greeter_test.sh (test file) -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function test_default_greeting() { - # TODO: Run greeter.sh from parent directory and capture output - # Hint: output=$(../greeter.sh) - - # TODO: Assert output contains "Hello, World!" - # Hint: assert_contains "Hello, World!" "$output" -} - -function test_custom_greeting() { - # TODO: Run greeter.sh with argument "Alice" - # Hint: output=$(../greeter.sh "Alice") - - # TODO: Assert output contains "Hello, Alice!" - # Hint: assert_contains "Hello, Alice!" "$output" -} -─────────────────────────────────────────────────────────────── - -TIPS: - • Use command substitution: output=$(./script.sh) - • Make scripts executable: chmod +x script.sh - • Test both default behavior and with various arguments - • Scripts run in subshells, so they can't modify parent environment - • Run scripts from parent directory: ../script.sh -EOF - - local default_file="tests/greeter_test.sh" - echo "" - printf "When ready, enter TEST file path %s[%s]%s: " \ - "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" - read -r test_file - test_file="${test_file:-$default_file}" - - if [ ! -f "$test_file" ]; then - local template='#!/usr/bin/env bash - -function test_default_greeting() { - # TODO: Run greeter.sh from parent directory and capture output - # Hint: output=$(../greeter.sh) - - # TODO: Assert output contains "Hello, World!" - # Hint: assert_contains "Hello, World!" "$output" -} - -function test_custom_greeting() { - # TODO: Run greeter.sh with argument "Alice" - # Hint: output=$(../greeter.sh "Alice") - - # TODO: Assert output contains "Hello, Alice!" - # Hint: assert_contains "Hello, Alice!" "$output" -}' - - bashunit::learn::create_example_file "$test_file" "$template" - return 1 - fi - - bashunit::learn::run_lesson_test "$test_file" 5 -} - -## -# Lesson 6: Mocking -## -function bashunit::learn::lesson_mocking() { - clear - cat <<'EOF' -╔════════════════════════════════════════════════════════════════╗ -ā•‘ Lesson 6: Mocking External Commands ā•‘ -ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• - -CONCEPT: Mocks let you override external commands or functions to -control their behavior in tests. - -TASK: Test a function that uses external commands. - -File: system_info.sh (source code) -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function get_system_info() { - echo "OS: $(uname -s)" -} -─────────────────────────────────────────────────────────────── - -File: tests/system_info_test.sh (test file) -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function set_up() { - source ../system_info.sh -} - -function test_system_info_on_linux() { - # TODO: Mock uname to return "Linux" - # Hint: mock uname echo "Linux" - - local output - output=$(get_system_info) - - # TODO: Assert output contains "OS: Linux" -} - -function test_system_info_on_macos() { - # TODO: Mock uname to return "Darwin" - - local output - output=$(get_system_info) - - # TODO: Assert output contains "OS: Darwin" -} -─────────────────────────────────────────────────────────────── - -TIPS: - • Mocks replace commands/functions with custom behavior - • Syntax: mock command_name echo "mocked output" - • Mocks are automatically cleaned up after each test - • Use mocks to avoid calling expensive external commands -EOF - - local default_file="tests/system_info_test.sh" - echo "" - printf "When ready, enter TEST file path %s[%s]%s: " \ - "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" - read -r test_file - test_file="${test_file:-$default_file}" - - if [ ! -f "$test_file" ]; then - local template='#!/usr/bin/env bash - -function set_up() { - source ../system_info.sh -} - -function test_system_info_on_linux() { - # TODO: Mock uname to return "Linux" - # Hint: mock uname echo "Linux" - - local output - output=$(get_system_info) - - # TODO: Assert output contains "OS: Linux" -} - -function test_system_info_on_macos() { - # TODO: Mock uname to return "Darwin" - - local output - output=$(get_system_info) - - # TODO: Assert output contains "OS: Darwin" -}' - - bashunit::learn::create_example_file "$test_file" "$template" - return 1 - fi - - if [ "$("$GREP" -c "mock" "$test_file" || true)" -eq 0 ]; then - echo "${_BASHUNIT_COLOR_FAILED}Your test should use mock${_BASHUNIT_COLOR_DEFAULT}" - read -p "Press Enter to continue..." -r - return 1 - fi - - bashunit::learn::run_lesson_test "$test_file" 6 -} - -## -# Lesson 7: Spies -## -function bashunit::learn::lesson_spies() { - clear - cat <<'EOF' -╔════════════════════════════════════════════════════════════════╗ -ā•‘ Lesson 7: Spies - Verifying Calls ā•‘ -ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• - -CONCEPT: Spies let you verify that functions were called with specific -arguments or a certain number of times. - -KEY DIFFERENCE: Spies track calls without changing behavior, while -mocks (Lesson 6) replace the function entirely with custom behavior. - -TASK: Use spies to verify function calls. - -File: deploy.sh -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function deploy_app() { - git push origin main - docker build -t myapp . - docker push myapp -} -─────────────────────────────────────────────────────────────── - -File: deploy_test.sh -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function set_up() { - source deploy.sh -} - -function test_deploy_calls_git_push() { - # TODO: Create spies for git and docker - # Hint: spy git - # Hint: spy docker - - deploy_app - - # TODO: Assert git was called - # Hint: assert_have_been_called git - - # TODO: Assert docker was called -} - -function test_deploy_calls_docker_twice() { - # TODO: Spy on docker - - deploy_app - - # TODO: Assert docker was called exactly 2 times - # Hint: assert_have_been_called_times 2 docker -} -─────────────────────────────────────────────────────────────── - -TIPS: - • Spies track calls but don't change behavior (unlike mocks) - • assert_have_been_called - verifies at least one call - • assert_have_been_called_times N - verifies exact call count - • assert_have_been_called_with - verifies specific arguments - • Spies are cleaned up automatically after each test -EOF - - local default_file="deploy_test.sh" - echo "" - printf "When ready, enter TEST file path %s[%s]%s: " \ - "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" - read -r test_file - test_file="${test_file:-$default_file}" - - if [ ! -f "$test_file" ]; then - local template='#!/usr/bin/env bash - -function set_up() { - source deploy.sh -} - -function test_deploy_calls_git_push() { - # TODO: Create spies for git and docker - # Hint: spy git - # Hint: spy docker - - deploy_app - - # TODO: Assert git was called - # Hint: assert_have_been_called git - - # TODO: Assert docker was called -} - -function test_deploy_calls_docker_twice() { - # TODO: Spy on docker - - deploy_app - - # TODO: Assert docker was called exactly 2 times - # Hint: assert_have_been_called_times 2 docker -}' - - bashunit::learn::create_example_file "$test_file" "$template" - return 1 - fi - - if [ "$("$GREP" -c "spy" "$test_file" || true)" -eq 0 ]; then - echo "${_BASHUNIT_COLOR_FAILED}Your test should use spy${_BASHUNIT_COLOR_DEFAULT}" - read -p "Press Enter to continue..." -r - return 1 - fi - - bashunit::learn::run_lesson_test "$test_file" 7 -} - -## -# Lesson 8: Data Providers -## -function bashunit::learn::lesson_data_providers() { - clear - cat <<'EOF' -╔════════════════════════════════════════════════════════════════╗ -ā•‘ Lesson 8: Data Providers - Parameterized Tests ā•‘ -ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• - -CONCEPT: Data providers let you run the same test with different inputs. -Define a function that echoes test data, one per line. - -HOW IT WORKS: Each line from data_provider_* becomes $1 in your test. -The test runs once for each line of data. - -TASK: Test multiple email formats using a data provider. - -File: validator.sh -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function is_valid_email() { - local email_pattern='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' - [ "$(echo "$1" | "$GREP" -cE "$email_pattern" || true)" -gt 0 ] -} -─────────────────────────────────────────────────────────────── - -File: validator_test.sh -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function set_up() { - source validator.sh -} - -function data_provider_valid_emails() { - # TODO: Echo valid email addresses, one per line - # Example: echo "user@example.com" -} - -function test_valid_emails() { - # $1 contains the email from data provider - # TODO: Assert is_valid_email succeeds - # Hint: assert_successful_code "is_valid_email \"$1\"" -} - -function data_provider_invalid_emails() { - # TODO: Echo invalid email addresses, one per line - # Example: echo "not-an-email" -} - -function test_invalid_emails() { - # TODO: Assert is_valid_email fails - # Hint: assert_general_error "is_valid_email \"$1\"" -} -─────────────────────────────────────────────────────────────── - -TIPS: - • Data providers must be named: data_provider_ - • Each line of output becomes one test case - • The test function receives the line as $1 - • Great for testing multiple inputs without duplicating code - • You can have multiple data provider/test pairs in one file -EOF - - local default_file="validator_test.sh" - echo "" - printf "When ready, enter TEST file path %s[%s]%s: " \ - "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" - read -r test_file - test_file="${test_file:-$default_file}" - - if [ ! -f "$test_file" ]; then - local template='#!/usr/bin/env bash - -function set_up() { - source validator.sh -} - -function data_provider_valid_emails() { - # TODO: Echo valid email addresses, one per line - # Example: echo "user@example.com" -} - -function test_valid_emails() { - # $1 contains the email from data provider - # TODO: Assert is_valid_email succeeds - # Hint: assert_successful_code "is_valid_email \"$1\"" -} - -function data_provider_invalid_emails() { - # TODO: Echo invalid email addresses, one per line - # Example: echo "not-an-email" -} - -function test_invalid_emails() { - # TODO: Assert is_valid_email fails - # Hint: assert_general_error "is_valid_email \"$1\"" -}' - - bashunit::learn::create_example_file "$test_file" "$template" - return 1 - fi - - if [ "$("$GREP" -c "function data_provider_" "$test_file" || true)" -eq 0 ]; then - echo "${_BASHUNIT_COLOR_FAILED}Your test should define data provider functions${_BASHUNIT_COLOR_DEFAULT}" - read -p "Press Enter to continue..." -r - return 1 - fi - - bashunit::learn::run_lesson_test "$test_file" 8 -} - -## -# Lesson 9: Exit Codes -## -function bashunit::learn::lesson_exit_codes() { - clear - cat <<'EOF' -╔════════════════════════════════════════════════════════════════╗ -ā•‘ Lesson 9: Testing Exit Codes ā•‘ -ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• - -CONCEPT: Exit codes indicate success (0) or failure (non-zero). -bashunit provides assertions to test them: - • assert_successful_code - expects exit code 0 - • assert_general_error - expects exit code 1 - • assert_exit_code N - expects specific exit code N - -TASK: Test different exit codes. - -File: checker.sh -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function check_file() { - if [ ! -e "$1" ]; then - echo "File not found" >&2 - return 127 - fi - - if [ ! -r "$1" ]; then - echo "Permission denied" >&2 - return 1 - fi - - echo "File OK" - return 0 -} -─────────────────────────────────────────────────────────────── - -File: checker_test.sh -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function set_up() { - source checker.sh - # Create a test file - export TEST_FILE="/tmp/test_file_$$" - touch "$TEST_FILE" -} - -function tear_down() { - rm -f "$TEST_FILE" -} - -function test_existing_file_returns_success() { - # TODO: Assert check_file succeeds with TEST_FILE - # Hint: assert_successful_code "check_file '$TEST_FILE'" -} - -function test_missing_file_returns_127() { - # TODO: Assert check_file returns exit code 127 for missing file - # Hint: assert_exit_code 127 "check_file '/nonexistent/file'" -} -─────────────────────────────────────────────────────────────── - -TIPS: - • Exit code 0 = success (assert_successful_code) - • Exit code 1 = general error (assert_general_error) - • Other codes = specific errors (assert_exit_code N) - • Bash uses 'return N' in functions, 'exit N' in scripts - • Common codes: 127=not found, 126=not executable, 2=misuse -EOF - - local default_file="checker_test.sh" - echo "" - printf "When ready, enter TEST file path %s[%s]%s: " \ - "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" - read -r test_file - test_file="${test_file:-$default_file}" - - if [ ! -f "$test_file" ]; then - local template='#!/usr/bin/env bash - -function set_up() { - source checker.sh - # Create a test file - export TEST_FILE="/tmp/test_file_$$" - touch "$TEST_FILE" -} - -function tear_down() { - rm -f "$TEST_FILE" -} - -function test_existing_file_returns_success() { - # TODO: Assert check_file succeeds with TEST_FILE - # Hint: assert_successful_code "check_file '\''$TEST_FILE'\''" -} - -function test_missing_file_returns_127() { - # TODO: Assert check_file returns exit code 127 for missing file - # Hint: assert_exit_code 127 "check_file '\''/nonexistent/file'\''" -}' - - bashunit::learn::create_example_file "$test_file" "$template" - return 1 - fi - - local _exit_assert_pattern="assert_successful_code\|assert_exit_code\|assert_general_error" - if [ "$("$GREP" -c "$_exit_assert_pattern" "$test_file" || true)" -eq 0 ]; then - echo "${_BASHUNIT_COLOR_FAILED}Your test should use exit code assertions${_BASHUNIT_COLOR_DEFAULT}" - read -p "Press Enter to continue..." -r - return 1 - fi - - bashunit::learn::run_lesson_test "$test_file" 9 -} - -## -# Lesson 10: Complete Challenge -## -function bashunit::learn::lesson_challenge() { - clear - cat <<'EOF' -╔════════════════════════════════════════════════════════════════╗ -ā•‘ Lesson 10: Complete Challenge - Backup Script ā•‘ -ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• - -FINAL CHALLENGE: Combine everything you've learned! - -CONCEPT: Real-world tests combine multiple concepts: lifecycle -management, assertions, exit codes, and test doubles. - -TASK: Create a backup script and comprehensive tests. - -File: backup.sh -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -function create_backup() { - local source=$1 - local dest=$2 - - if [ ! -d "$source" ]; then - echo "Source directory not found" >&2 - return 1 - fi - - tar -czf "$dest" -C "$source" . - echo "Backup created: $dest" -} -─────────────────────────────────────────────────────────────── - -File: backup_test.sh -─────────────────────────────────────────────────────────────── -#!/usr/bin/env bash - -Your test must include: - 1. set_up and tear_down functions - 2. Test successful backup creation - 3. Test failure when source doesn't exist - 4. Mock or spy on tar command - 5. Verify backup file exists - 6. Check output message - -TIP: Combine patterns from all previous lessons! -EOF - - local default_file="backup_test.sh" - echo "" - printf "When ready, enter TEST file path %s[%s]%s: " \ - "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" - read -r test_file - test_file="${test_file:-$default_file}" - - if [ ! -f "$test_file" ]; then - local template='#!/usr/bin/env bash - -function set_up() { - source backup.sh - # TODO: Create test directories and variables -} - -function tear_down() { - # TODO: Clean up test files -} - -function test_successful_backup() { - # TODO: Test backup creation -} - -function test_backup_failure_when_source_missing() { - # TODO: Test failure case -} - -# Add more tests as needed: -# - Mock or spy on tar command -# - Verify backup file exists -# - Check output message -# -# TIPS: -# - Combine lifecycle (set_up/tear_down) with file assertions -# - Use spies to verify tar was called correctly -# - Test both success and failure scenarios -# - Mock external commands to avoid side effects' - - bashunit::learn::create_example_file "$test_file" "$template" - return 1 - fi - - # Verify the test has key components - local -a missing_components=() - local missing_components_count=0 - - if [ "$("$GREP" -c "function set_up()" "$test_file" || true)" -eq 0 ]; then - missing_components[missing_components_count]="set_up function" - missing_components_count=$((missing_components_count + 1)) - fi - - if [ "$("$GREP" -c "function tear_down()" "$test_file" || true)" -eq 0 ]; then - missing_components[missing_components_count]="tear_down function" - missing_components_count=$((missing_components_count + 1)) - fi - - if [ "$missing_components_count" -gt 0 ]; then - echo "${_BASHUNIT_COLOR_FAILED}Missing required components:${_BASHUNIT_COLOR_DEFAULT}" - printf " - %s\n" "${missing_components[@]}" - read -p "Press Enter to continue..." -r - return 1 - fi - - if bashunit::learn::run_lesson_test "$test_file" 10; then - echo "" - echo "${_BASHUNIT_COLOR_PASSED}${_BASHUNIT_COLOR_BOLD}" - cat <<'EOF' -╔════════════════════════════════════════════════════════════════╗ -ā•‘ šŸŽ‰ CONGRATULATIONS! šŸŽ‰ ā•‘ -ā•‘ ā•‘ -ā•‘ You've completed all bashunit lessons! ā•‘ -ā•‘ ā•‘ -ā•‘ You now know how to: ā•‘ -ā•‘ āœ“ Write and run tests ā•‘ -ā•‘ āœ“ Use various assertions ā•‘ -ā•‘ āœ“ Manage test lifecycle ā•‘ -ā•‘ āœ“ Test functions and scripts ā•‘ -ā•‘ āœ“ Mock external dependencies ā•‘ -ā•‘ āœ“ Spy on function calls ā•‘ -ā•‘ āœ“ Use data providers ā•‘ -ā•‘ āœ“ Test exit codes ā•‘ -ā•‘ ā•‘ -ā•‘ Next steps: ā•‘ -ā•‘ • Explore https://bashunit.com ā•‘ -ā•‘ • Check out /common-patterns for more examples ā•‘ -ā•‘ • Start testing your own bash scripts! ā•‘ -ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• -EOF - echo "${_BASHUNIT_COLOR_DEFAULT}" - read -p "Press Enter to continue..." -r - fi -} diff --git a/src/learn/index.sh b/src/learn/index.sh new file mode 100644 index 00000000..4e3e7aff --- /dev/null +++ b/src/learn/index.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +# Interactive learning module for bashunit: guided tutorials and exercises. +# +# Entry point for the src/learn/ module: only `source` lines and comments belong +# here. build.sh emits a file's body before recursing into its `source` lines, so +# any statement here would run before its dependencies in the built binary +# (adrs/adr-010-src-module-directories.md). +# +# Sourced in dependency layers, leaves first: +# progress -> session -> lessons -> menu +# Every lesson calls only session's create_example_file and run_lesson_test; no +# lesson calls another, so the ten are mutually independent. +source "$BASHUNIT_ROOT_DIR/src/learn/progress.sh" +source "$BASHUNIT_ROOT_DIR/src/learn/session.sh" +source "$BASHUNIT_ROOT_DIR/src/learn/lessons/basics.sh" +source "$BASHUNIT_ROOT_DIR/src/learn/lessons/assertions.sh" +source "$BASHUNIT_ROOT_DIR/src/learn/lessons/lifecycle.sh" +source "$BASHUNIT_ROOT_DIR/src/learn/lessons/functions.sh" +source "$BASHUNIT_ROOT_DIR/src/learn/lessons/scripts.sh" +source "$BASHUNIT_ROOT_DIR/src/learn/lessons/mocking.sh" +source "$BASHUNIT_ROOT_DIR/src/learn/lessons/spies.sh" +source "$BASHUNIT_ROOT_DIR/src/learn/lessons/data_providers.sh" +source "$BASHUNIT_ROOT_DIR/src/learn/lessons/exit_codes.sh" +source "$BASHUNIT_ROOT_DIR/src/learn/lessons/challenge.sh" +source "$BASHUNIT_ROOT_DIR/src/learn/menu.sh" diff --git a/src/learn/lessons/assertions.sh b/src/learn/lessons/assertions.sh new file mode 100644 index 00000000..e80345c5 --- /dev/null +++ b/src/learn/lessons/assertions.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # lesson text shows literal $vars in single quotes + +# The "assertions" lesson. + +## +# Lesson 2: Assertions - Testing Different Conditions +## +function bashunit::learn::lesson_assertions() { + clear + cat <<'EOF' +╔════════════════════════════════════════════════════════════════╗ +ā•‘ Lesson 2: Testing Different Conditions ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +CONCEPT: bashunit provides many assertion functions for different checks: + • assert_same - exact equality + • assert_contains - substring check + • assert_matches - regex pattern + • assert_not_same - inequality + • assert_empty - checks if value is empty + • assert_not_empty - checks if value is not empty + +TASK: Write a test file with 3 different assertions. + +File: tests/assertions_test.sh +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function test_multiple_assertions() { + local message="Hello, bashunit!" + + # TODO: Check that message contains "bashunit" + # Hint: assert_contains "substring" "$message" + + # TODO: Check that message matches the pattern "Hello.*!" + # Hint: assert_matches "pattern" "$message" + + # TODO: Check that message is not empty + # Hint: assert_not_empty "$message" +} +─────────────────────────────────────────────────────────────── + +TIPS: + • assert_same checks exact equality (useful for strings/numbers) + • assert_contains is more flexible for partial matches + • assert_matches uses regex patterns (e.g., "^[0-9]+$" for numbers) + • Explore more: assert_empty, assert_true, assert_false +EOF + + local default_file="tests/assertions_test.sh" + echo "" + printf "When ready, enter file path %s[%s]%s: " \ + "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" + read -r test_file + test_file="${test_file:-$default_file}" + + if [ ! -f "$test_file" ]; then + local template='#!/usr/bin/env bash + +function test_multiple_assertions() { + local message="Hello, bashunit!" + + # TODO: Check that message contains "bashunit" + # Hint: assert_contains "substring" "$message" + + # TODO: Check that message matches the pattern "Hello.*!" + # Hint: assert_matches "pattern" "$message" + + # TODO: Check that message is not empty + # Hint: assert_not_empty "$message" +}' + + bashunit::learn::create_example_file "$test_file" "$template" + return 1 + fi + + if [ "$("$GREP" -c "assert_contains" "$test_file" || true)" -eq 0 ] || + [ "$("$GREP" -c "assert_matches" "$test_file" || true)" -eq 0 ] || + [ "$("$GREP" -c "assert_not_empty" "$test_file" || true)" -eq 0 ]; then + echo "${_BASHUNIT_COLOR_FAILED}Your test should use all three assertion types${_BASHUNIT_COLOR_DEFAULT}" + read -p "Press Enter to continue..." -r + return 1 + fi + + bashunit::learn::run_lesson_test "$test_file" 2 +} + diff --git a/src/learn/lessons/basics.sh b/src/learn/lessons/basics.sh new file mode 100644 index 00000000..b182d202 --- /dev/null +++ b/src/learn/lessons/basics.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +# The "basics" lesson. + +## +# Lesson 1: Basics - Your First Test +## +function bashunit::learn::lesson_basics() { + clear + cat <<'EOF' +╔════════════════════════════════════════════════════════════════╗ +ā•‘ Lesson 1: Your First Test ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +Welcome to bashunit! Let's write your first test. + +CONCEPT: A test is a function that starts with 'test_' and uses +assertions to verify behavior. + +TASK: Create a test file that checks if two values are equal. + +File: tests/first_test.sh +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function test_bashunit_works() { + # TODO: Use assert_same to check if "hello" equals "hello" + # Hint: assert_same "expected" "actual" +} +─────────────────────────────────────────────────────────────── + +TIPS: + • The assert_same function takes two arguments: + assert_same "expected" "actual" + • Test functions must start with "test_" prefix + • Always quote your strings to avoid word splitting + • Keep test files in a tests/ directory for better organization +EOF + + local default_file="tests/first_test.sh" + echo "" + printf "When ready, enter file path %s[%s]%s: " \ + "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" + read -r test_file + test_file="${test_file:-$default_file}" + + if [ ! -f "$test_file" ]; then + local template='#!/usr/bin/env bash + +function test_bashunit_works() { + # TODO: Use assert_same to check if "hello" equals "hello" + # Hint: assert_same "expected" "actual" +}' + + bashunit::learn::create_example_file "$test_file" "$template" + return 1 + fi + + # Check if file contains assert_same + if [ "$("$GREP" -c "assert_same" "$test_file" || true)" -eq 0 ]; then + echo "${_BASHUNIT_COLOR_FAILED}Your test should use assert_same${_BASHUNIT_COLOR_DEFAULT}" + read -p "Press Enter to continue..." -r + return 1 + fi + + bashunit::learn::run_lesson_test "$test_file" 1 +} + diff --git a/src/learn/lessons/challenge.sh b/src/learn/lessons/challenge.sh new file mode 100644 index 00000000..0c7411b2 --- /dev/null +++ b/src/learn/lessons/challenge.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash + +# The "challenge" lesson. + +## +# Lesson 10: Complete Challenge +## +function bashunit::learn::lesson_challenge() { + clear + cat <<'EOF' +╔════════════════════════════════════════════════════════════════╗ +ā•‘ Lesson 10: Complete Challenge - Backup Script ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +FINAL CHALLENGE: Combine everything you've learned! + +CONCEPT: Real-world tests combine multiple concepts: lifecycle +management, assertions, exit codes, and test doubles. + +TASK: Create a backup script and comprehensive tests. + +File: backup.sh +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function create_backup() { + local source=$1 + local dest=$2 + + if [ ! -d "$source" ]; then + echo "Source directory not found" >&2 + return 1 + fi + + tar -czf "$dest" -C "$source" . + echo "Backup created: $dest" +} +─────────────────────────────────────────────────────────────── + +File: backup_test.sh +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +Your test must include: + 1. set_up and tear_down functions + 2. Test successful backup creation + 3. Test failure when source doesn't exist + 4. Mock or spy on tar command + 5. Verify backup file exists + 6. Check output message + +TIP: Combine patterns from all previous lessons! +EOF + + local default_file="backup_test.sh" + echo "" + printf "When ready, enter TEST file path %s[%s]%s: " \ + "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" + read -r test_file + test_file="${test_file:-$default_file}" + + if [ ! -f "$test_file" ]; then + local template='#!/usr/bin/env bash + +function set_up() { + source backup.sh + # TODO: Create test directories and variables +} + +function tear_down() { + # TODO: Clean up test files +} + +function test_successful_backup() { + # TODO: Test backup creation +} + +function test_backup_failure_when_source_missing() { + # TODO: Test failure case +} + +# Add more tests as needed: +# - Mock or spy on tar command +# - Verify backup file exists +# - Check output message +# +# TIPS: +# - Combine lifecycle (set_up/tear_down) with file assertions +# - Use spies to verify tar was called correctly +# - Test both success and failure scenarios +# - Mock external commands to avoid side effects' + + bashunit::learn::create_example_file "$test_file" "$template" + return 1 + fi + + # Verify the test has key components + local -a missing_components=() + local missing_components_count=0 + + if [ "$("$GREP" -c "function set_up()" "$test_file" || true)" -eq 0 ]; then + missing_components[missing_components_count]="set_up function" + missing_components_count=$((missing_components_count + 1)) + fi + + if [ "$("$GREP" -c "function tear_down()" "$test_file" || true)" -eq 0 ]; then + missing_components[missing_components_count]="tear_down function" + missing_components_count=$((missing_components_count + 1)) + fi + + if [ "$missing_components_count" -gt 0 ]; then + echo "${_BASHUNIT_COLOR_FAILED}Missing required components:${_BASHUNIT_COLOR_DEFAULT}" + printf " - %s\n" "${missing_components[@]}" + read -p "Press Enter to continue..." -r + return 1 + fi + + if bashunit::learn::run_lesson_test "$test_file" 10; then + echo "" + echo "${_BASHUNIT_COLOR_PASSED}${_BASHUNIT_COLOR_BOLD}" + cat <<'EOF' +╔════════════════════════════════════════════════════════════════╗ +ā•‘ šŸŽ‰ CONGRATULATIONS! šŸŽ‰ ā•‘ +ā•‘ ā•‘ +ā•‘ You've completed all bashunit lessons! ā•‘ +ā•‘ ā•‘ +ā•‘ You now know how to: ā•‘ +ā•‘ āœ“ Write and run tests ā•‘ +ā•‘ āœ“ Use various assertions ā•‘ +ā•‘ āœ“ Manage test lifecycle ā•‘ +ā•‘ āœ“ Test functions and scripts ā•‘ +ā•‘ āœ“ Mock external dependencies ā•‘ +ā•‘ āœ“ Spy on function calls ā•‘ +ā•‘ āœ“ Use data providers ā•‘ +ā•‘ āœ“ Test exit codes ā•‘ +ā•‘ ā•‘ +ā•‘ Next steps: ā•‘ +ā•‘ • Explore https://bashunit.com ā•‘ +ā•‘ • Check out /common-patterns for more examples ā•‘ +ā•‘ • Start testing your own bash scripts! ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• +EOF + echo "${_BASHUNIT_COLOR_DEFAULT}" + read -p "Press Enter to continue..." -r + fi +} diff --git a/src/learn/lessons/data_providers.sh b/src/learn/lessons/data_providers.sh new file mode 100644 index 00000000..51cc6c3d --- /dev/null +++ b/src/learn/lessons/data_providers.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # lesson text shows literal $vars in single quotes + +# The "data_providers" lesson. + +## +# Lesson 8: Data Providers +## +function bashunit::learn::lesson_data_providers() { + clear + cat <<'EOF' +╔════════════════════════════════════════════════════════════════╗ +ā•‘ Lesson 8: Data Providers - Parameterized Tests ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +CONCEPT: Data providers let you run the same test with different inputs. +Define a function that echoes test data, one per line. + +HOW IT WORKS: Each line from data_provider_* becomes $1 in your test. +The test runs once for each line of data. + +TASK: Test multiple email formats using a data provider. + +File: validator.sh +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function is_valid_email() { + local email_pattern='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + [ "$(echo "$1" | "$GREP" -cE "$email_pattern" || true)" -gt 0 ] +} +─────────────────────────────────────────────────────────────── + +File: validator_test.sh +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function set_up() { + source validator.sh +} + +function data_provider_valid_emails() { + # TODO: Echo valid email addresses, one per line + # Example: echo "user@example.com" +} + +function test_valid_emails() { + # $1 contains the email from data provider + # TODO: Assert is_valid_email succeeds + # Hint: assert_successful_code "is_valid_email \"$1\"" +} + +function data_provider_invalid_emails() { + # TODO: Echo invalid email addresses, one per line + # Example: echo "not-an-email" +} + +function test_invalid_emails() { + # TODO: Assert is_valid_email fails + # Hint: assert_general_error "is_valid_email \"$1\"" +} +─────────────────────────────────────────────────────────────── + +TIPS: + • Data providers must be named: data_provider_ + • Each line of output becomes one test case + • The test function receives the line as $1 + • Great for testing multiple inputs without duplicating code + • You can have multiple data provider/test pairs in one file +EOF + + local default_file="validator_test.sh" + echo "" + printf "When ready, enter TEST file path %s[%s]%s: " \ + "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" + read -r test_file + test_file="${test_file:-$default_file}" + + if [ ! -f "$test_file" ]; then + local template='#!/usr/bin/env bash + +function set_up() { + source validator.sh +} + +function data_provider_valid_emails() { + # TODO: Echo valid email addresses, one per line + # Example: echo "user@example.com" +} + +function test_valid_emails() { + # $1 contains the email from data provider + # TODO: Assert is_valid_email succeeds + # Hint: assert_successful_code "is_valid_email \"$1\"" +} + +function data_provider_invalid_emails() { + # TODO: Echo invalid email addresses, one per line + # Example: echo "not-an-email" +} + +function test_invalid_emails() { + # TODO: Assert is_valid_email fails + # Hint: assert_general_error "is_valid_email \"$1\"" +}' + + bashunit::learn::create_example_file "$test_file" "$template" + return 1 + fi + + if [ "$("$GREP" -c "function data_provider_" "$test_file" || true)" -eq 0 ]; then + echo "${_BASHUNIT_COLOR_FAILED}Your test should define data provider functions${_BASHUNIT_COLOR_DEFAULT}" + read -p "Press Enter to continue..." -r + return 1 + fi + + bashunit::learn::run_lesson_test "$test_file" 8 +} + diff --git a/src/learn/lessons/exit_codes.sh b/src/learn/lessons/exit_codes.sh new file mode 100644 index 00000000..dbebb1c3 --- /dev/null +++ b/src/learn/lessons/exit_codes.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # lesson text shows literal $vars in single quotes + +# The "exit_codes" lesson. + +## +# Lesson 9: Exit Codes +## +function bashunit::learn::lesson_exit_codes() { + clear + cat <<'EOF' +╔════════════════════════════════════════════════════════════════╗ +ā•‘ Lesson 9: Testing Exit Codes ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +CONCEPT: Exit codes indicate success (0) or failure (non-zero). +bashunit provides assertions to test them: + • assert_successful_code - expects exit code 0 + • assert_general_error - expects exit code 1 + • assert_exit_code N - expects specific exit code N + +TASK: Test different exit codes. + +File: checker.sh +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function check_file() { + if [ ! -e "$1" ]; then + echo "File not found" >&2 + return 127 + fi + + if [ ! -r "$1" ]; then + echo "Permission denied" >&2 + return 1 + fi + + echo "File OK" + return 0 +} +─────────────────────────────────────────────────────────────── + +File: checker_test.sh +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function set_up() { + source checker.sh + # Create a test file + export TEST_FILE="/tmp/test_file_$$" + touch "$TEST_FILE" +} + +function tear_down() { + rm -f "$TEST_FILE" +} + +function test_existing_file_returns_success() { + # TODO: Assert check_file succeeds with TEST_FILE + # Hint: assert_successful_code "check_file '$TEST_FILE'" +} + +function test_missing_file_returns_127() { + # TODO: Assert check_file returns exit code 127 for missing file + # Hint: assert_exit_code 127 "check_file '/nonexistent/file'" +} +─────────────────────────────────────────────────────────────── + +TIPS: + • Exit code 0 = success (assert_successful_code) + • Exit code 1 = general error (assert_general_error) + • Other codes = specific errors (assert_exit_code N) + • Bash uses 'return N' in functions, 'exit N' in scripts + • Common codes: 127=not found, 126=not executable, 2=misuse +EOF + + local default_file="checker_test.sh" + echo "" + printf "When ready, enter TEST file path %s[%s]%s: " \ + "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" + read -r test_file + test_file="${test_file:-$default_file}" + + if [ ! -f "$test_file" ]; then + local template='#!/usr/bin/env bash + +function set_up() { + source checker.sh + # Create a test file + export TEST_FILE="/tmp/test_file_$$" + touch "$TEST_FILE" +} + +function tear_down() { + rm -f "$TEST_FILE" +} + +function test_existing_file_returns_success() { + # TODO: Assert check_file succeeds with TEST_FILE + # Hint: assert_successful_code "check_file '\''$TEST_FILE'\''" +} + +function test_missing_file_returns_127() { + # TODO: Assert check_file returns exit code 127 for missing file + # Hint: assert_exit_code 127 "check_file '\''/nonexistent/file'\''" +}' + + bashunit::learn::create_example_file "$test_file" "$template" + return 1 + fi + + local _exit_assert_pattern="assert_successful_code\|assert_exit_code\|assert_general_error" + if [ "$("$GREP" -c "$_exit_assert_pattern" "$test_file" || true)" -eq 0 ]; then + echo "${_BASHUNIT_COLOR_FAILED}Your test should use exit code assertions${_BASHUNIT_COLOR_DEFAULT}" + read -p "Press Enter to continue..." -r + return 1 + fi + + bashunit::learn::run_lesson_test "$test_file" 9 +} + diff --git a/src/learn/lessons/functions.sh b/src/learn/lessons/functions.sh new file mode 100644 index 00000000..d3acdd6f --- /dev/null +++ b/src/learn/lessons/functions.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # lesson text shows literal $vars in single quotes + +# The "functions" lesson. + +## +# Lesson 4: Testing Functions +## +function bashunit::learn::lesson_functions() { + clear + cat <<'EOF' +╔════════════════════════════════════════════════════════════════╗ +ā•‘ Lesson 4: Testing Bash Functions ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +CONCEPT: To test functions, source the file containing them, then +call them in your tests. + +TASK: Create a script with a function, then test it. + +File: calculator.sh (source code) +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function add() { + echo $(($1 + $2)) +} +─────────────────────────────────────────────────────────────── + +File: tests/calculator_test.sh (test file) +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function set_up() { + # TODO: Source calculator.sh from parent directory + # Hint: source ../calculator.sh +} + +function test_add_positive_numbers() { + # TODO: Test that add 2 3 returns "5" + # Hint: result=$(add 2 3) + # Hint: assert_same "5" "$result" +} + +function test_add_negative_numbers() { + # TODO: Test that add -2 -3 returns "-5" + # Hint: result=$(add -2 -3) + # Hint: assert_same "-5" "$result" +} +─────────────────────────────────────────────────────────────── + +TIPS: + • Source files in set_up() to reload them fresh for each test + • Capture function output with: result=$(function_name args) + • Test edge cases: positive, negative, zero, large numbers + • Source files from parent directory: source ../file.sh +EOF + + local default_file="tests/calculator_test.sh" + echo "" + printf "When ready, enter TEST file path %s[%s]%s: " \ + "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" + read -r test_file + test_file="${test_file:-$default_file}" + + if [ ! -f "$test_file" ]; then + local template='#!/usr/bin/env bash + +function set_up() { + # TODO: Source calculator.sh from parent directory + # Hint: source ../calculator.sh +} + +function test_add_positive_numbers() { + # TODO: Test that add 2 3 returns "5" + # Hint: result=$(add 2 3) + # Hint: assert_same "5" "$result" +} + +function test_add_negative_numbers() { + # TODO: Test that add -2 -3 returns "-5" + # Hint: result=$(add -2 -3) + # Hint: assert_same "-5" "$result" +}' + + bashunit::learn::create_example_file "$test_file" "$template" + return 1 + fi + + if [ "$("$GREP" -c "source" "$test_file" || true)" -eq 0 ]; then + echo "${_BASHUNIT_COLOR_FAILED}Your test should source the calculator.sh file${_BASHUNIT_COLOR_DEFAULT}" + read -p "Press Enter to continue..." -r + return 1 + fi + + bashunit::learn::run_lesson_test "$test_file" 4 +} + diff --git a/src/learn/lessons/lifecycle.sh b/src/learn/lessons/lifecycle.sh new file mode 100644 index 00000000..e22ae72c --- /dev/null +++ b/src/learn/lessons/lifecycle.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # lesson text shows literal $vars in single quotes + +# The "lifecycle" lesson. + +## +# Lesson 3: Setup & Teardown - Managing Test Lifecycle +## +function bashunit::learn::lesson_lifecycle() { + clear + cat <<'EOF' +╔════════════════════════════════════════════════════════════════╗ +ā•‘ Lesson 3: Setup and Teardown Functions ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +CONCEPT: Tests often need preparation and cleanup. bashunit provides: + • set_up() - runs before EACH test + • tear_down() - runs after EACH test + • set_up_before_script() - runs once before ALL tests + • tear_down_after_script() - runs once after ALL tests + +TASK: Create a test that uses setup and teardown to manage files. + +File: tests/lifecycle_test.sh +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function set_up() { + # Create a temp file before each test + # TODO: export TEST_FILE="/tmp/test_$$" + # TODO: echo "test content" > "$TEST_FILE" +} + +function tear_down() { + # Clean up after each test + # TODO: rm -f "$TEST_FILE" +} + +function test_file_exists() { + # TODO: assert_file_exists "$TEST_FILE" +} + +function test_file_has_content() { + # TODO: assert_file_contains "test content" "$TEST_FILE" +} +─────────────────────────────────────────────────────────────── + +TIPS: + • set_up() runs before EACH test (good for test isolation) + • set_up_before_script() runs ONCE before all tests (good for expensive setup) + • Always clean up in tear_down() to avoid polluting other tests + • Use $$ for unique temp file names to avoid conflicts +EOF + + local default_file="tests/lifecycle_test.sh" + echo "" + printf "When ready, enter file path %s[%s]%s: " \ + "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" + read -r test_file + test_file="${test_file:-$default_file}" + + if [ ! -f "$test_file" ]; then + local template='#!/usr/bin/env bash + +function set_up() { + # Create a temp file before each test + # TODO: export TEST_FILE="/tmp/test_$$" + # TODO: echo "test content" > "$TEST_FILE" +} + +function tear_down() { + # Clean up after each test + # TODO: rm -f "$TEST_FILE" +} + +function test_file_exists() { + # TODO: assert_file_exists "$TEST_FILE" +} + +function test_file_has_content() { + # TODO: assert_file_contains "test content" "$TEST_FILE" +}' + + bashunit::learn::create_example_file "$test_file" "$template" + return 1 + fi + + if [ "$("$GREP" -c "function set_up()" "$test_file" || true)" -eq 0 ] || + [ "$("$GREP" -c "function tear_down()" "$test_file" || true)" -eq 0 ]; then + echo "${_BASHUNIT_COLOR_FAILED}Your test should define set_up and tear_down functions${_BASHUNIT_COLOR_DEFAULT}" + read -p "Press Enter to continue..." -r + return 1 + fi + + bashunit::learn::run_lesson_test "$test_file" 3 +} + diff --git a/src/learn/lessons/mocking.sh b/src/learn/lessons/mocking.sh new file mode 100644 index 00000000..e6c61797 --- /dev/null +++ b/src/learn/lessons/mocking.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # lesson text shows literal $vars in single quotes + +# The "mocking" lesson. + +## +# Lesson 6: Mocking +## +function bashunit::learn::lesson_mocking() { + clear + cat <<'EOF' +╔════════════════════════════════════════════════════════════════╗ +ā•‘ Lesson 6: Mocking External Commands ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +CONCEPT: Mocks let you override external commands or functions to +control their behavior in tests. + +TASK: Test a function that uses external commands. + +File: system_info.sh (source code) +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function get_system_info() { + echo "OS: $(uname -s)" +} +─────────────────────────────────────────────────────────────── + +File: tests/system_info_test.sh (test file) +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function set_up() { + source ../system_info.sh +} + +function test_system_info_on_linux() { + # TODO: Mock uname to return "Linux" + # Hint: mock uname echo "Linux" + + local output + output=$(get_system_info) + + # TODO: Assert output contains "OS: Linux" +} + +function test_system_info_on_macos() { + # TODO: Mock uname to return "Darwin" + + local output + output=$(get_system_info) + + # TODO: Assert output contains "OS: Darwin" +} +─────────────────────────────────────────────────────────────── + +TIPS: + • Mocks replace commands/functions with custom behavior + • Syntax: mock command_name echo "mocked output" + • Mocks are automatically cleaned up after each test + • Use mocks to avoid calling expensive external commands +EOF + + local default_file="tests/system_info_test.sh" + echo "" + printf "When ready, enter TEST file path %s[%s]%s: " \ + "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" + read -r test_file + test_file="${test_file:-$default_file}" + + if [ ! -f "$test_file" ]; then + local template='#!/usr/bin/env bash + +function set_up() { + source ../system_info.sh +} + +function test_system_info_on_linux() { + # TODO: Mock uname to return "Linux" + # Hint: mock uname echo "Linux" + + local output + output=$(get_system_info) + + # TODO: Assert output contains "OS: Linux" +} + +function test_system_info_on_macos() { + # TODO: Mock uname to return "Darwin" + + local output + output=$(get_system_info) + + # TODO: Assert output contains "OS: Darwin" +}' + + bashunit::learn::create_example_file "$test_file" "$template" + return 1 + fi + + if [ "$("$GREP" -c "mock" "$test_file" || true)" -eq 0 ]; then + echo "${_BASHUNIT_COLOR_FAILED}Your test should use mock${_BASHUNIT_COLOR_DEFAULT}" + read -p "Press Enter to continue..." -r + return 1 + fi + + bashunit::learn::run_lesson_test "$test_file" 6 +} + diff --git a/src/learn/lessons/scripts.sh b/src/learn/lessons/scripts.sh new file mode 100644 index 00000000..3afcdd73 --- /dev/null +++ b/src/learn/lessons/scripts.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # lesson text shows literal $vars in single quotes + +# The "scripts" lesson. + +## +# Lesson 5: Testing Scripts +## +function bashunit::learn::lesson_scripts() { + clear + cat <<'EOF' +╔════════════════════════════════════════════════════════════════╗ +ā•‘ Lesson 5: Testing Bash Scripts ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +CONCEPT: Scripts that execute commands directly are tested differently. +Run them and capture their output. + +TASK: Create a script and test its output. + +File: greeter.sh (source code) +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash +name=${1:-World} +echo "Hello, $name!" +─────────────────────────────────────────────────────────────── + +File: tests/greeter_test.sh (test file) +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function test_default_greeting() { + # TODO: Run greeter.sh from parent directory and capture output + # Hint: output=$(../greeter.sh) + + # TODO: Assert output contains "Hello, World!" + # Hint: assert_contains "Hello, World!" "$output" +} + +function test_custom_greeting() { + # TODO: Run greeter.sh with argument "Alice" + # Hint: output=$(../greeter.sh "Alice") + + # TODO: Assert output contains "Hello, Alice!" + # Hint: assert_contains "Hello, Alice!" "$output" +} +─────────────────────────────────────────────────────────────── + +TIPS: + • Use command substitution: output=$(./script.sh) + • Make scripts executable: chmod +x script.sh + • Test both default behavior and with various arguments + • Scripts run in subshells, so they can't modify parent environment + • Run scripts from parent directory: ../script.sh +EOF + + local default_file="tests/greeter_test.sh" + echo "" + printf "When ready, enter TEST file path %s[%s]%s: " \ + "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" + read -r test_file + test_file="${test_file:-$default_file}" + + if [ ! -f "$test_file" ]; then + local template='#!/usr/bin/env bash + +function test_default_greeting() { + # TODO: Run greeter.sh from parent directory and capture output + # Hint: output=$(../greeter.sh) + + # TODO: Assert output contains "Hello, World!" + # Hint: assert_contains "Hello, World!" "$output" +} + +function test_custom_greeting() { + # TODO: Run greeter.sh with argument "Alice" + # Hint: output=$(../greeter.sh "Alice") + + # TODO: Assert output contains "Hello, Alice!" + # Hint: assert_contains "Hello, Alice!" "$output" +}' + + bashunit::learn::create_example_file "$test_file" "$template" + return 1 + fi + + bashunit::learn::run_lesson_test "$test_file" 5 +} + diff --git a/src/learn/lessons/spies.sh b/src/learn/lessons/spies.sh new file mode 100644 index 00000000..9e315b87 --- /dev/null +++ b/src/learn/lessons/spies.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +# The "spies" lesson. + +## +# Lesson 7: Spies +## +function bashunit::learn::lesson_spies() { + clear + cat <<'EOF' +╔════════════════════════════════════════════════════════════════╗ +ā•‘ Lesson 7: Spies - Verifying Calls ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +CONCEPT: Spies let you verify that functions were called with specific +arguments or a certain number of times. + +KEY DIFFERENCE: Spies track calls without changing behavior, while +mocks (Lesson 6) replace the function entirely with custom behavior. + +TASK: Use spies to verify function calls. + +File: deploy.sh +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function deploy_app() { + git push origin main + docker build -t myapp . + docker push myapp +} +─────────────────────────────────────────────────────────────── + +File: deploy_test.sh +─────────────────────────────────────────────────────────────── +#!/usr/bin/env bash + +function set_up() { + source deploy.sh +} + +function test_deploy_calls_git_push() { + # TODO: Create spies for git and docker + # Hint: spy git + # Hint: spy docker + + deploy_app + + # TODO: Assert git was called + # Hint: assert_have_been_called git + + # TODO: Assert docker was called +} + +function test_deploy_calls_docker_twice() { + # TODO: Spy on docker + + deploy_app + + # TODO: Assert docker was called exactly 2 times + # Hint: assert_have_been_called_times 2 docker +} +─────────────────────────────────────────────────────────────── + +TIPS: + • Spies track calls but don't change behavior (unlike mocks) + • assert_have_been_called - verifies at least one call + • assert_have_been_called_times N - verifies exact call count + • assert_have_been_called_with - verifies specific arguments + • Spies are cleaned up automatically after each test +EOF + + local default_file="deploy_test.sh" + echo "" + printf "When ready, enter TEST file path %s[%s]%s: " \ + "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" + read -r test_file + test_file="${test_file:-$default_file}" + + if [ ! -f "$test_file" ]; then + local template='#!/usr/bin/env bash + +function set_up() { + source deploy.sh +} + +function test_deploy_calls_git_push() { + # TODO: Create spies for git and docker + # Hint: spy git + # Hint: spy docker + + deploy_app + + # TODO: Assert git was called + # Hint: assert_have_been_called git + + # TODO: Assert docker was called +} + +function test_deploy_calls_docker_twice() { + # TODO: Spy on docker + + deploy_app + + # TODO: Assert docker was called exactly 2 times + # Hint: assert_have_been_called_times 2 docker +}' + + bashunit::learn::create_example_file "$test_file" "$template" + return 1 + fi + + if [ "$("$GREP" -c "spy" "$test_file" || true)" -eq 0 ]; then + echo "${_BASHUNIT_COLOR_FAILED}Your test should use spy${_BASHUNIT_COLOR_DEFAULT}" + read -p "Press Enter to continue..." -r + return 1 + fi + + bashunit::learn::run_lesson_test "$test_file" 7 +} + diff --git a/src/learn/menu.sh b/src/learn/menu.sh new file mode 100644 index 00000000..b0067c18 --- /dev/null +++ b/src/learn/menu.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +# The interactive menu and the top-level dispatch loop. + +## +# Print the learning menu +## +function bashunit::learn::print_menu() { + cat <>"$LEARN_PROGRESS_FILE" +} + + +## +# Check if lesson is completed +## +function bashunit::learn::is_completed() { + local lesson=$1 + [ -f "$LEARN_PROGRESS_FILE" ] && [ "$("$GREP" -c "^$lesson$" "$LEARN_PROGRESS_FILE" || true)" -gt 0 ] +} + + +## +# Show learning progress +## +function bashunit::learn::show_progress() { + if [ ! -f "$LEARN_PROGRESS_FILE" ]; then + echo "${_BASHUNIT_COLOR_INCOMPLETE}No progress yet. Start with lesson 1!${_BASHUNIT_COLOR_DEFAULT}" + return + fi + + echo "${_BASHUNIT_COLOR_BOLD}Your Progress:${_BASHUNIT_COLOR_DEFAULT}" + echo "" + + local total_lessons=10 + local completed=0 + + local i + for i in $(seq 1 $total_lessons); do + if bashunit::learn::is_completed "lesson_$i"; then + echo " ${_BASHUNIT_COLOR_PASSED}āœ“${_BASHUNIT_COLOR_DEFAULT} Lesson $i completed" + ((++completed)) || true + else + echo " ${_BASHUNIT_COLOR_INCOMPLETE}ā—‹${_BASHUNIT_COLOR_DEFAULT} Lesson $i" + fi + done + + echo "" + echo "Progress: $completed/$total_lessons lessons completed" + + if [ $completed -eq $total_lessons ]; then + echo "" + printf "%s%sšŸŽ‰ Congratulations! You've completed all lessons!%s\n" \ + "$_BASHUNIT_COLOR_PASSED" "$_BASHUNIT_COLOR_BOLD" "$_BASHUNIT_COLOR_DEFAULT" + fi + + read -p "Press Enter to continue..." -r +} + + +## +# Reset learning progress +## +function bashunit::learn::reset_progress() { + rm -f "$LEARN_PROGRESS_FILE" + echo "${_BASHUNIT_COLOR_PASSED}Progress reset successfully.${_BASHUNIT_COLOR_DEFAULT}" + read -p "Press Enter to continue..." -r +} + diff --git a/src/learn/session.sh b/src/learn/session.sh new file mode 100644 index 00000000..77127526 --- /dev/null +++ b/src/learn/session.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +# The learning session's scratch directory and the runner that executes a lesson's example test. + +LEARN_TEMP_DIR="" + +## +# Initialize learning environment +## +function bashunit::learn::init() { + LEARN_TEMP_DIR=$("${MKTEMP:-mktemp}" -d "${TMPDIR:-/tmp}/bashunit_learn.XXXXXXXX") + mkdir -p tests +} + + +## +# Cleanup learning environment +## +function bashunit::learn::cleanup() { + if [ -n "${LEARN_TEMP_DIR:-}" ] && [ -d "$LEARN_TEMP_DIR" ]; then + rm -rf "$LEARN_TEMP_DIR" + fi +} + + +## +# Create the example file automatically +# Arguments: $1 - filename, $2 - file content +## +function bashunit::learn::create_example_file() { + local filename=$1 + local content=$2 + + echo "" + echo "Creating example file ${_BASHUNIT_COLOR_BOLD}$filename${_BASHUNIT_COLOR_DEFAULT}..." + echo "$content" >"$filename" + chmod +x "$filename" + echo "${_BASHUNIT_COLOR_PASSED}āœ“ Created $filename${_BASHUNIT_COLOR_DEFAULT}" + echo "" + echo "File created! Edit it to complete the TODO items, then run this lesson again." + read -p "Press Enter to continue..." -r + return 0 +} + + +## +# Run a lesson test and check results +## +function bashunit::learn::run_lesson_test() { + local test_file=$1 + local lesson_number=$2 + + echo "${_BASHUNIT_COLOR_BOLD}Running your test...${_BASHUNIT_COLOR_DEFAULT}" + echo "" + + if "$BASHUNIT_ROOT_DIR/bashunit" "$test_file" --simple; then + echo "" + printf "%s%sāœ“ Excellent! Lesson %s completed!%s\n" \ + "$_BASHUNIT_COLOR_PASSED" "$_BASHUNIT_COLOR_BOLD" "$lesson_number" "$_BASHUNIT_COLOR_DEFAULT" + bashunit::learn::mark_completed "lesson_$lesson_number" + read -p "Press Enter to continue..." -r + return 0 + else + echo "" + echo "${_BASHUNIT_COLOR_FAILED}Not quite right. Review the requirements and try again.${_BASHUNIT_COLOR_DEFAULT}" + read -p "Press Enter to continue..." -r + return 1 + fi +} + diff --git a/tests/unit/learn_test.sh b/tests/unit/learn_test.sh index 775a490c..b8f6df25 100644 --- a/tests/unit/learn_test.sh +++ b/tests/unit/learn_test.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -# Covers the non-interactive core of src/learn.sh: progress persistence and the -# environment lifecycle. LEARN_PROGRESS_FILE is a readonly resolved from $HOME at -# source time, so each test sources learn.sh in a fresh shell with HOME pointed +# Covers the non-interactive core of the src/learn/ module: progress persistence +# and the environment lifecycle. LEARN_PROGRESS_FILE is a readonly resolved from +# $HOME at source time, so each test sources the module in a fresh shell with HOME # at an isolated directory — the suite's own already-sourced copy (bound to the # real $HOME) is never exercised. @@ -26,14 +26,14 @@ function _learn_in_sandbox() { BASHUNIT_ROOT_DIR="'"$root_dir"'" export BASHUNIT_ROOT_DIR GREP="$(command -v grep)" - # learn.sh renders coloured output but does not source colors.sh. Stub + # learn renders coloured output but does not source colors.sh. Stub # the palette to empty rather than pulling in the real chain # (str -> globals -> env -> colors), which would couple this unit test to # source order and make the assertions match escape codes. _BASHUNIT_COLOR_BOLD="" _BASHUNIT_COLOR_DEFAULT="" _BASHUNIT_COLOR_FAILED="" _BASHUNIT_COLOR_FAINT="" _BASHUNIT_COLOR_INCOMPLETE="" _BASHUNIT_COLOR_PASSED="" # shellcheck source=/dev/null - source "'"$root_dir"'/src/learn.sh" + source "'"$root_dir"'/src/learn/index.sh" '"$1"' '