A practical guide for contributors writing new tests. This document is about how to think about testing in this project — what to test, how to structure it, and the mistakes to avoid. For the full assertion and helper API reference, see Testing.
- Your first test
- Choosing the right assertion
- Test isolation
- Common patterns
- What not to do
- Getting started quickly
Here is the smallest complete test you can write:
test_info_message_written_to_file() {
start_test "INFO message is written to the log file"
init_logger --level INFO --quiet
local log_file="$TEST_DIR/test.log"
LOG_FILE="$log_file"
log_info "hello from test"
assert_file_contains "$log_file" "hello from test" || return
pass_test
}
test_info_message_written_to_fileEvery line has a reason.
test_info_message_written_to_file() — The function name starts with test_ by convention.
The name describes exactly what is being verified, not how. A reader should understand the
intent without reading the body.
start_test "INFO message is written to the log file" — This registers the test with the
runner and sets the human-readable name used in output and JUnit reports. It also calls
setup_test internally, which re-sources logging.sh and creates a fresh $TEST_DIR.
You must call this before anything else.
init_logger --level INFO --quiet — Initialises the logger. --quiet suppresses console
output so test output is clean. --level INFO sets the minimum severity. You need
init_logger before calling any log_* function.
local log_file="$TEST_DIR/test.log" — Declares a variable for the log path. Using a
local variable avoids polluting the surrounding scope. The path is under $TEST_DIR, which
is a unique per-test directory — see Test isolation below.
LOG_FILE="$log_file" — Tells the logger where to write. After init_logger, you can
assign LOG_FILE to override the default (none). The logger creates the file on first write.
log_info "hello from test" — The code under test. Call it exactly as a real script would.
assert_file_contains "$log_file" "hello from test" || return — The assertion checks that
the string appears in the file. The || return is critical — see the next section.
pass_test — Marks the test as passed. If execution reaches this line, all assertions
passed. If any assertion before it failed, pass_test is never called.
test_info_message_written_to_file — Tests are ordinary functions; they do nothing unless
called. Every function you define must be called at the bottom of the suite file.
This is the lifecycle every test follows:
start_test → (do things) → assert ... || return → pass_test
The || return after each assertion is not optional. Here is what happens without it:
# WRONG — do not do this
assert_file_contains "$log_file" "Expected string"
assert_file_contains "$log_file" "Another string"
pass_testIf the first assertion fails, fail_test is called internally, but execution continues. The
second assertion then runs too — possibly calling fail_test again on the same test, producing
misleading output. Then pass_test runs and marks the test as passed even though it failed.
The correct form:
# CORRECT
assert_file_contains "$log_file" "Expected string" || return
assert_file_contains "$log_file" "Another string" || return
pass_testEach || return says "if the assertion returned non-zero, exit this function immediately". The
test runner sees that pass_test was never called and counts the test as failed. The failure
message from the first failing assertion is preserved.
When testing internal values rather than assertions, use the same pattern:
[[ "$CURRENT_LOG_LEVEL" -eq "$LOG_LEVEL_INFO" ]] || { fail_test "Wrong level"; return; }bash-logger writes to files. The most direct way to verify logging behaviour is to check the file that the logger actually wrote:
init_logger --level DEBUG --quiet
local log_file="$TEST_DIR/test.log"
LOG_FILE="$log_file"
log_error "disk full"
assert_file_contains "$log_file" "disk full" || return
assert_file_contains "$log_file" "[ERROR]" || returnFile assertions (assert_file_contains, assert_file_not_contains) are preferred because:
- They test the real output path — the same file a production script would write to.
- They are not affected by buffering or stream routing differences between subshells and the current shell.
- When a test fails, the file is left on disk (see
teardown_testintest_helpers.sh) and you can inspect it directly.
Some behaviour can only be verified by capturing stdout or stderr — for example, testing that
log_error goes to stderr while log_info goes to stdout. For these cases, use a subshell
with explicit redirects:
bash -c "
source '$PROJECT_ROOT/logging.sh'
init_logger
log_error 'something failed'
" >"$TEST_DIR/stdout" 2>"$TEST_DIR/stderr"
assert_file_contains "$TEST_DIR/stderr" "something failed" || return
assert_file_not_contains "$TEST_DIR/stdout" "something failed" || returnWriting the streams to files under $TEST_DIR and then using assert_file_contains keeps the
assertion style consistent and leaves the files available for debugging on failure.
Avoid capture_output and capture_streams for logging output tests. Those helpers merge or
capture the output of a command in the current shell, where the logger state is already
initialised. A subshell gives you a clean environment each time.
start_test calls setup_test, which contains:
source "$PROJECT_ROOT/logging.sh"This re-sources the module before every single test. The reason is that logging.sh maintains
global state: LOG_FILE, LOG_LEVEL, QUIET_MODE, and several other variables. Without
re-sourcing, state from one test leaks into the next. For example, if test A sets
LOG_FILE=/tmp/a.log and test B forgets to set LOG_FILE, test B writes to test A's file —
a subtle failure that is hard to diagnose.
Re-sourcing resets everything to defaults. This is why you do not need to call a "reset" or "teardown" yourself between tests.
This also means you should not source logging.sh at the top of a suite file. If you do, it
runs once before any test, before setup_test has run, and before $TEST_DIR exists. The
per-test re-source inside setup_test is the correct mechanism.
$TEST_DIR is created by setup_test as:
TEST_DIR="$TEST_TMP_DIR/$(date +%s%N)"Each test gets a path that includes a nanosecond timestamp, making it unique. The parent
$TEST_TMP_DIR is a per-suite temporary directory created by the runner before sourcing the
suite file.
The runner can execute multiple test suites in parallel (it defaults to using all available
cores, capped at 8). Each parallel job runs in its own subshell with its own $TEST_TMP_DIR.
Test suites do not share state.
What this means for you:
- Always use
$TEST_DIRfor any file your test reads or writes. Never use a fixed path like/tmp/test.log— that path is shared across all parallel runs and will cause intermittent failures. - Never write to
$TEST_TMP_DIRdirectly. Write to$TEST_DIR, which is unique to your test. - Do not assume test functions within the same suite run in isolation from each other at the
file-system level — they share the same
$TEST_TMP_DIRbut each has its own$TEST_DIRsubdirectory.
These are the scenarios you will encounter most often. Each example is complete and can be adapted directly.
This is the most common pattern. Use it whenever you are verifying that something is logged.
test_warn_message_in_file() {
start_test "WARN message is written to the log file"
# Initialise with --quiet so nothing appears on the terminal during tests.
# --level DEBUG ensures all severity levels are enabled for this test.
init_logger --level DEBUG --quiet
local log_file="$TEST_DIR/test.log"
LOG_FILE="$log_file" # Direct file output to our test directory
log_warn "low disk space"
# assert_file_contains uses grep -F (fixed string), not a regex.
assert_file_contains "$log_file" "low disk space" || return
assert_file_contains "$log_file" "[WARN]" || return
pass_test
}
test_warn_message_in_fileUse a subshell so you can separate the two streams cleanly.
test_error_goes_to_stderr() {
start_test "ERROR messages go to stderr, not stdout"
# Run the logger in a subshell and redirect each stream to a file.
# $PROJECT_ROOT is exported by the test runner — always available.
bash -c "
source '$PROJECT_ROOT/logging.sh'
init_logger
log_error 'permission denied'
" >"$TEST_DIR/stdout" 2>"$TEST_DIR/stderr"
# Error output must appear on stderr.
assert_file_contains "$TEST_DIR/stderr" "permission denied" || return
# It must not also appear on stdout.
assert_file_not_contains "$TEST_DIR/stdout" "permission denied" || return
pass_test
}
test_error_goes_to_stderrTest the flag's effect directly by verifying observable output, not by checking internal variables.
test_log_level_filtering() {
start_test "DEBUG messages are suppressed when level is INFO"
init_logger --level INFO --quiet
local log_file="$TEST_DIR/test.log"
LOG_FILE="$log_file"
log_debug "this should not appear"
log_info "this should appear"
# The debug message must be absent.
assert_file_not_contains "$log_file" "this should not appear" || return
# The info message must be present.
assert_file_contains "$log_file" "this should appear" || return
pass_test
}
test_log_level_filteringVerify that the library does not crash or produce nonsense output when given unexpected input.
Use assert_success or assert_failure to check exit behaviour:
test_empty_message_does_not_crash() {
start_test "Logging an empty string does not crash"
init_logger --level DEBUG --quiet
local log_file="$TEST_DIR/test.log"
LOG_FILE="$log_file"
# The library should handle an empty string without exiting non-zero.
log_info "" || { fail_test "log_info returned non-zero for empty string"; return; }
pass_test
}
test_empty_message_does_not_crashSome tests require optional system commands (for example, logger for journal logging). Skip
gracefully rather than failing:
test_journal_logging_writes_entry() {
start_test "Journal logging writes an entry via logger"
# Skip if the system logger command is not available.
if ! command -v logger >/dev/null 2>&1; then
skip_test "logger command not available"
return
fi
init_logger --journal --quiet
log_info "journal test entry"
# Verify the entry appeared in the journal (implementation detail:
# bash-logger calls `logger` internally, so we check its side effect).
pass_test
}
test_journal_logging_writes_entryskip_test records the test as skipped in the summary and JUnit output. Always return
immediately after calling it — execution must not continue into the test body.
Forgetting || return after an assertion
# WRONG
assert_file_contains "$log_file" "expected text"
assert_file_contains "$log_file" "other text"
pass_test # Always reached, even if assertions failedExecution continues after a failed assertion. The test will call pass_test despite having
failed. Always write assert_file_contains "$log_file" "expected text" || return.
Writing to a fixed path instead of $TEST_DIR
# WRONG
local log_file="/tmp/test.log"
LOG_FILE="$log_file"The runner executes suites in parallel. Two tests using /tmp/test.log simultaneously will
corrupt each other's output and produce non-deterministic failures. Use
local log_file="$TEST_DIR/test.log" — $TEST_DIR is unique per test.
Sourcing logging.sh at the top of a suite file
# WRONG — place this at the top of a suite file
source "$PROJECT_ROOT/logging.sh"$TEST_DIR does not exist yet when the file is first sourced by the runner. Re-sourcing
logging.sh inside each test (via start_test → setup_test) is the correct mechanism.
Any state you set at file scope will persist across tests and break isolation.
Testing internal variables rather than observable output
# WRONG
assert_equals "6" "$_log_level_numeric"Internal variable names and values are implementation details that can change. Test what the library does — the content of the log file, the exit code of a function, which stream a message appears on — not the private state it uses to do it.
Registering a function but forgetting to call it
# WRONG — function is defined but never invoked
test_new_feature() {
start_test "New feature works"
# ...
pass_test
}
# Missing: test_new_featureTests are ordinary shell functions. Defining them does nothing. Every test function must be called at the bottom of the suite file. The runner does not auto-discover function names — it sources the file and relies on the calls at the bottom to run the tests.
Copy tests/test_example.sh as your starting point. It is a working suite with three annotated
example tests that demonstrate the patterns described here.
For the full assertion API reference, see Testing.