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"' '