Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Contributions are licensed under the [MIT License](https://github.com/TypedDevs/

### Prerequisites

- Bash 3.2+
- Bash 3.0+
- Git
- Make
- [ShellCheck](https://github.com/koalaman/shellcheck#installing)
Expand Down
6 changes: 3 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ An open-source **library** providing a fast, portable Bash testing framework: **
* Minimal overhead, plain Bash test files.
* Rich **assertions**, **test doubles (mock/spy)**, **data providers**, **snapshots**, **skip/todo**, **globals utilities**, **custom assertions**, **benchmarks**, and **standalone** runs.

**Compatibility**: Bash 3.2+ (macOS, Linux, WSL). No external dependencies beyond standard Unix tools.
**Compatibility**: Bash 3.0+ (macOS, Linux, WSL). No external dependencies beyond standard Unix tools.

---

Expand Down Expand Up @@ -284,7 +284,7 @@ We practice two nested feedback loops to deliver behavior safely and quickly.

### Compatibility & Portability
```bash
# βœ… GOOD - Works on Bash 3.2+
# βœ… GOOD - Works on Bash 3.0+
[[ -n "${var:-}" ]] && echo "set"
array=("item1" "item2")

Expand Down Expand Up @@ -1000,7 +1000,7 @@ Use this template for internal changes, fixes, refactors, documentation.
- **All tests pass** (`./bashunit tests/`)
- **Shellcheck passes** with existing exceptions (`shellcheck -x $(find . -name "*.sh")`)
- **Code formatted** (`shfmt -w .`)
- **Bash 3.2+ compatible** (no `declare -A`, no `readarray`, no `${var^^}`)
- **Bash 3.0+ compatible** (no `declare -A`, no `readarray`, no `${var^^}`)
- **Follows established module namespacing** patterns

### βœ… Testing (following observed patterns)
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- tear_down
- set_up_before_script
- tear_down_after_script
- Support Bash 3.0 (Previously 3.2)

## [0.24.0](https://github.com/TypedDevs/bashunit/compare/0.23.0...0.24.0) - 2025-09-14

Expand Down
8 changes: 4 additions & 4 deletions bashunit
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail

declare -r BASHUNIT_MIN_BASH_VERSION="3.2"
declare -r BASHUNIT_MIN_BASH_VERSION="3.0"

function _check_bash_version() {
local current_version
Expand All @@ -16,10 +16,10 @@ function _check_bash_version() {
current_version="$(bash --version | head -n1 | cut -d' ' -f4 | cut -d. -f1,2)"
fi

local major minor
IFS=. read -r major minor _ <<< "$current_version"
local major
IFS=. read -r major _ _ <<< "$current_version"

if (( major < 3 )) || { (( major == 3 )) && (( minor < 2 )); }; then
if (( major < 3 )); then
printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2
exit 1
fi
Expand Down
3 changes: 2 additions & 1 deletion build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ function build::process_file() {
sourced_file=$(eval echo "$sourced_file")

# Handle relative paths if necessary
if [[ ! "$sourced_file" =~ ^/ ]]; then
local absolute_path_pattern='^/'
if [[ ! "$sourced_file" =~ $absolute_path_pattern ]]; then
sourced_file="$(dirname "$file")/$sourced_file"
fi

Expand Down
3 changes: 2 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ DIR="lib"
VERSION="latest"

function is_version() {
[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || "$1" == "latest" || "$1" == "beta" ]]
local version_pattern='^[0-9]+\.[0-9]+\.[0-9]+$'
[[ "$1" =~ $version_pattern || "$1" == "latest" || "$1" == "beta" ]]
}

# Parse arguments flexibly
Expand Down
8 changes: 5 additions & 3 deletions src/assert.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ function assert_false() {

function run_command_or_eval() {
local cmd="$1"
local eval_pattern='^eval'
local alias_pattern='^alias'

if [[ "$cmd" =~ ^eval ]]; then
if [[ "$cmd" =~ $eval_pattern ]]; then
eval "${cmd#eval }" &> /dev/null
elif [[ "$(command -v "$cmd")" =~ ^alias ]]; then
elif [[ "$(command -v "$cmd")" =~ $alias_pattern ]]; then
eval "$cmd" &> /dev/null
else
"$cmd" &> /dev/null
Expand Down Expand Up @@ -546,7 +548,7 @@ function assert_line_count() {
local actual
actual=$(echo "$input_str" | wc -l | tr -d '[:blank:]')
local additional_new_lines
additional_new_lines=$(grep -o '\\n' <<< "$input_str" | wc -l | tr -d '[:blank:]')
additional_new_lines=$(echo "$input_str" | grep -o '\\n' | wc -l | tr -d '[:blank:]')
((actual+=additional_new_lines))
fi

Expand Down
27 changes: 18 additions & 9 deletions src/benchmark.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,27 @@ function benchmark::parse_annotations() {
local annotation
annotation=$(awk "/function[[:space:]]+${fn_name}[[:space:]]*\(/ {print prev; exit} {prev=\$0}" "$script")

if [[ $annotation =~ @revs=([0-9]+) ]]; then
local revs_pattern='@revs=([0-9]+)'
local revolutions_pattern='@revolutions=([0-9]+)'
local its_pattern='@its=([0-9]+)'
local iterations_pattern='@iterations=([0-9]+)'
local max_ms_pattern='@max_ms=([0-9.]+)'

if [[ $annotation =~ $revs_pattern ]]; then
revs="${BASH_REMATCH[1]}"
elif [[ $annotation =~ @revolutions=([0-9]+) ]]; then
elif [[ $annotation =~ $revolutions_pattern ]]; then
revs="${BASH_REMATCH[1]}"
fi

if [[ $annotation =~ @its=([0-9]+) ]]; then
if [[ $annotation =~ $its_pattern ]]; then
its="${BASH_REMATCH[1]}"
elif [[ $annotation =~ @iterations=([0-9]+) ]]; then
elif [[ $annotation =~ $iterations_pattern ]]; then
its="${BASH_REMATCH[1]}"
fi

if [[ $annotation =~ @max_ms=([0-9.]+) ]]; then
if [[ $annotation =~ $max_ms_pattern ]]; then
max_ms="${BASH_REMATCH[1]}"
elif [[ $annotation =~ @max_ms=([0-9.]+) ]]; then
elif [[ $annotation =~ $max_ms_pattern ]]; then
max_ms="${BASH_REMATCH[1]}"
fi

Expand All @@ -55,7 +61,8 @@ function benchmark::run_function() {
local revs=$2
local its=$3
local max_ms=$4
local durations=()
local durations
durations=()

for ((i=1; i<=its; i++)); do
local start_time=$(clock::now)
Expand Down Expand Up @@ -129,13 +136,15 @@ function benchmark::print_results() {

if (( $(echo "$avg <= $max_ms" | bc -l) )); then
local raw="≀ ${max_ms}"
printf -v padded "%14s" "$raw"
local padded
padded=$(printf "%14s" "$raw")
printf '%-40s %6s %6s %10s %12s\n' "$name" "$revs" "$its" "$avg" "$padded"
continue
fi

local raw="> ${max_ms}"
printf -v padded "%12s" "$raw"
local padded
padded=$(printf "%12s" "$raw")
printf '%-40s %6s %6s %10s %s%s%s\n' \
"$name" "$revs" "$its" "$avg" \
"$_COLOR_FAILED" "$padded" "${_COLOR_DEFAULT}"
Expand Down
6 changes: 4 additions & 2 deletions src/clock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ _CLOCK_NOW_IMPL=""

function clock::_choose_impl() {
local shell_time
local attempts=()
local attempts
attempts=()

# 1. Try Perl with Time::HiRes
attempts+=("Perl")
Expand Down Expand Up @@ -37,8 +38,9 @@ function clock::_choose_impl() {
attempts+=("date")
if ! check_os::is_macos && ! check_os::is_alpine; then
local result
local number_pattern='^[0-9]+$'
result=$(date +%s%N 2>/dev/null)
if [[ "$result" != *N && "$result" =~ ^[0-9]+$ ]]; then
if [[ "$result" != *N && "$result" =~ $number_pattern ]]; then
_CLOCK_NOW_IMPL="date"
return 0
fi
Expand Down
17 changes: 11 additions & 6 deletions src/helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function helper::normalize_test_function_name() {
# Replace underscores with spaces
result="${result//_/ }"
# Capitalize the first letter
result="$(tr '[:lower:]' '[:upper:]' <<< "${result:0:1}")${result:1}"
result="$(echo "${result:0:1}" | tr '[:lower:]' '[:upper:]')${result:1}"

echo "$result"
}
Expand Down Expand Up @@ -160,7 +160,8 @@ function helper::find_files_recursive() {
local pattern="${2:-*[tT]est.sh}"

local alt_pattern=""
if [[ $pattern == *test.sh ]] || [[ $pattern =~ \[tT\]est\.sh$ ]]; then
local test_pattern='\[tT\]est\.sh$'
if [[ $pattern == *test.sh ]] || [[ $pattern =~ $test_pattern ]]; then
alt_pattern="${pattern%.sh}.bash"
fi

Expand All @@ -187,7 +188,8 @@ function helper::normalize_variable_name() {

normalized_string="${input_string//[^a-zA-Z0-9_]/_}"

if [[ ! $normalized_string =~ ^[a-zA-Z_] ]]; then
local valid_start_pattern='^[a-zA-Z_]'
if [[ ! $normalized_string =~ $valid_start_pattern ]]; then
normalized_string="_$normalized_string"
fi

Expand Down Expand Up @@ -269,7 +271,8 @@ function helper::find_total_tests() {
# shellcheck disable=SC2207
local functions_to_run=($filtered_functions)
for fn_name in "${functions_to_run[@]}"; do
local provider_data=()
local provider_data
provider_data=()
while IFS=" " read -r line; do
provider_data+=("$line")
done <<< "$(helper::get_provider_data "$fn_name" "$file")"
Expand All @@ -295,7 +298,8 @@ function helper::load_test_files() {
local filter=$1
local files=("${@:2}")

local test_files=()
local test_files
test_files=()

if [[ "${#files[@]}" -eq 0 ]]; then
if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then
Expand All @@ -314,7 +318,8 @@ function helper::load_bench_files() {
local filter=$1
local files=("${@:2}")

local bench_files=()
local bench_files
bench_files=()

if [[ "${#files[@]}" -eq 0 ]]; then
if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then
Expand Down
9 changes: 6 additions & 3 deletions src/main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ function main::exec_tests() {
local filter=$1
local files=("${@:2}")

local test_files=()
local test_files
test_files=()
while IFS= read -r line; do
test_files+=("$line")
done < <(helper::load_test_files "$filter" "${files[@]}")
Expand Down Expand Up @@ -82,7 +83,8 @@ function main::exec_benchmarks() {
local filter=$1
local files=("${@:2}")

local bench_files=()
local bench_files
bench_files=()
while IFS= read -r line; do
bench_files+=("$line")
done < <(helper::load_bench_files "$filter" "${files[@]}")
Expand Down Expand Up @@ -189,7 +191,8 @@ function main::handle_assert_exit_code() {
last_line=$(echo "$output" | tail -n 1)
if echo "$last_line" | grep -q 'inner_exit_code:[0-9]*'; then
inner_exit_code=$(echo "$last_line" | grep -o 'inner_exit_code:[0-9]*' | cut -d':' -f2)
if ! [[ $inner_exit_code =~ ^[0-9]+$ ]]; then
local number_pattern='^[0-9]+$'
if ! [[ $inner_exit_code =~ $number_pattern ]]; then
inner_exit_code=1
fi
output=$(echo "$output" | sed '$d')
Expand Down
19 changes: 14 additions & 5 deletions src/runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ function runner::load_test_files() {
local filter=$1
shift
local files=("${@}")
local scripts_ids=()
local scripts_ids
scripts_ids=()

for test_file in "${files[@]}"; do
if [[ ! -f $test_file ]]; then
Expand Down Expand Up @@ -113,7 +114,8 @@ function runner::parse_data_provider_args() {
local i
local arg
local encoded_arg
local -a args=()
local args
args=()
# Parse args from the input string into an array, respecting quotes and escapes
for ((i=0; i<${#input}; i++)); do
local char="${input:$i:1}"
Expand Down Expand Up @@ -188,7 +190,8 @@ function runner::call_test_functions() {
break
fi

local provider_data=()
local provider_data
provider_data=()
while IFS=" " read -r line; do
provider_data+=("$line")
done <<< "$(helper::get_provider_data "$fn_name" "$script")"
Expand All @@ -202,7 +205,8 @@ function runner::call_test_functions() {

# Execute the test function for each line of data
for data in "${provider_data[@]}"; do
local parsed_data=()
local parsed_data
parsed_data=()
while IFS= read -r line; do
parsed_data+=( "$(helper::decode_base64 "${line}")" )
done <<< "$(runner::parse_data_provider_args "$data")"
Expand Down Expand Up @@ -235,7 +239,12 @@ function runner::call_bench_functions() {
fi

for fn_name in "${functions_to_run[@]}"; do
read -r revs its max_ms <<< "$(benchmark::parse_annotations "$fn_name" "$script")"
local annotation_result
annotation_result="$(benchmark::parse_annotations "$fn_name" "$script")"
set -- "$annotation_result"
revs="$1"
its="$2"
max_ms="$3"
benchmark::run_function "$fn_name" "$revs" "$its" "$max_ms"
unset fn_name
done
Expand Down
7 changes: 4 additions & 3 deletions src/test_doubles.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bash

declare -a MOCKED_FUNCTIONS=()
MOCKED_FUNCTIONS=()

function unmock() {
local command=$1
Expand Down Expand Up @@ -95,7 +95,8 @@ function assert_have_been_called_with() {
shift

local index=""
if [[ ${!#} =~ ^[0-9]+$ ]]; then
local number_pattern='^[0-9]+$'
if [[ ${!#} =~ $number_pattern ]]; then
index=${!#}
set -- "${@:1:$#-1}"
fi
Expand All @@ -115,7 +116,7 @@ function assert_have_been_called_with() {
fi

local raw
IFS='|' read -r raw _ <<<"$line"
raw=$(echo "$line" | cut -d'|' -f1)

if [[ "$expected" != "$raw" ]]; then
state::add_assertions_failed
Expand Down
10 changes: 8 additions & 2 deletions tests/unit/bash_version_test.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
#!/usr/bin/env bash

function test_fail_with_old_bash_version() {
output=$(BASHUNIT_TEST_BASH_VERSION=3.1 ./bashunit --version 2>&1)
output=$(BASHUNIT_TEST_BASH_VERSION=2.9 ./bashunit --version 2>&1)
exit_code=$?
assert_contains "Bashunit requires Bash >= 3.2. Current version: 3.1" "$output"
assert_contains "Bashunit requires Bash >= 3.0. Current version: 2.9" "$output"
assert_general_error "$output" "" "$exit_code"
}

function test_pass_with_bash_3_0() {
output=$(BASHUNIT_TEST_BASH_VERSION=3.0 ./bashunit --version 2>&1)
exit_code=$?
assert_successful_code "$output" "" "$exit_code"
}
Loading