# bashunit — full documentation
> Full text of the bashunit docs, concatenated for LLMs and agents. Source: https://bashunit.com — see also https://bashunit.com/llms.txt
---
# Quickstart
**bashunit** is a dedicated testing tool crafted specifically for Bash scripts. It empowers you with tests on your Bash codebase, ensuring that your scripts operate reliably and as intended.
With an intuitive API and documentation, it streamlines the process for developers to implement and manage tests. This is beneficial regardless of the project's size or intricacy in Bash.
Thanks to **bashunit**, verifying and validating your Bash code has never been so easy.
## Installation
Pick whichever fits your project. See [installation](/installation) for the complete list (Brew, MacPorts, bashdep, GitHub Actions, etc.).
::: code-group
```bash [install.sh]
# Generates lib/bashunit (single-file executable)
curl -s https://bashunit.com/install.sh | bash
```
```bash [npm]
# Per-project (pinned in package.json), run via npx
npm install --save-dev bashunit
npx bashunit tests/
# Global install, run directly on PATH
npm install -g bashunit
bashunit tests/
```
```bash [Windows]
# IMPORTANT: You need WSL (Windows Subsystem for Linux) to run bashunit
#
# Step 1: Install WSL if you haven't already
# - Open PowerShell as Administrator
# - Run: wsl --install
# - Restart your computer
#
# Step 2: Open your WSL terminal and run:
curl -s https://bashunit.com/install.sh | bash
```
:::
The `install.sh` route creates `lib/bashunit`; the npm route exposes `bashunit` via `npx` or your global `PATH`.
## Usage
Once **bashunit** is installed, you're ready to get started.
You can bootstrap a ready to use test suite with the `init` subcommand:
```bash
./lib/bashunit init tests
```
This will create a `tests` directory containing a sample test and bootstrap file.
Alternatively, create your tests manually:
1. First, create a folder to place your tests:
```bash
mkdir tests
```
2. Next, create your first test file named `example_test.sh` within this folder:
::: code-group
```bash [tests/example_test.sh]
#!/usr/bin/env bash
function test_bashunit_is_working() {
assert_same "bashunit is working" "bashunit is working"
}
```
:::
3. Finally, run the **bashunit** executable:
```bash
./lib/bashunit ./tests
```
4. If everything works correctly, you should see an output similar to the following:
```-vue
bashunit - {{ pkg.version }} | Tests: 1
Running tests/example_test.sh
✓ Passed: Bashunit is working 16 ms
Tests: 1 passed, 1 total
Assertions: 1 passed, 1 total
All tests passed
Time taken: 90 ms
```
5. Now you can start testing the functionalities of your own Bash scripts.
## Learning bashunit interactively
If you prefer hands-on learning, bashunit includes an interactive tutorial:
```bash
./lib/bashunit learn
```
This will guide you through 10 progressive lessons covering all major features with practical exercises and immediate feedback.
## Next steps
Dive deeper into the documentation:
- **[Common patterns](common-patterns)** - Real-world testing scenarios and best practices
- **[Assertions](assertions)** - Learn all available assertion functions
- **[Test doubles](test-doubles)** - Master mocks and spies for isolated testing
- **[Data providers](/data-providers)** - Write parameterized tests efficiently
- **[Snapshots](snapshots)** - Test complex output easily
- **[Test files](/test-files)** - Understand test file structure and lifecycle hooks
---
# Installation
**bashunit** ships as a single-file executable. Pick the option that fits your project: `install.sh` (universal), [npm](#npm) (Node.js projects), [Brew](#brew) (macOS/Linux global), [MacPorts](#macports), or [bashdep](#bashdep).
## Requirements
bashunit requires **Bash 3.0** or newer. On Windows use [WSL](https://learn.microsoft.com/windows/wsl/install).
## install.sh
There is a tool that will generate an executable with the whole library in a single file:
::: code-group
```bash [Linux/Mac]
curl -s https://bashunit.com/install.sh | bash
```
```bash [Windows]
# IMPORTANT: You need WSL (Windows Subsystem for Linux) to run bashunit
#
# Step 1: Install WSL if you haven't already
# - Open PowerShell as Administrator
# - Run: wsl --install
# - Restart your computer
#
# Step 2: Open your WSL terminal and run:
curl -s https://bashunit.com/install.sh | bash
```
:::
This will create a file inside a lib folder, such as `lib/bashunit`.
::: tip Automatic checksum verification
`install.sh` verifies the download against the release `checksum` asset by default and
aborts on a mismatch, so a tampered or corrupted download never lands. Set
`BASHUNIT_VERIFY_CHECKSUM=false` to opt out (e.g. for old releases published before
checksum assets existed). The manual check below is only needed when you opt out.
:::
#### Verify
```bash-vue
# Verify the sha256sum for latest stable: {{ pkg.version }}
DIR="lib"; KNOWN_HASH="{{pkg.checksum}}"; FILE="$DIR/bashunit"; [ "$(shasum -a 256 "$FILE" | awk '{ print $1 }')" = "$KNOWN_HASH" ] && echo -e "✓ \033[1mbashunit\033[0m verified." || { echo -e "✗ \033[1mbashunit\033[0m corrupt"; rm "$FILE"; }
```
:::tip
You can find the checksum for each version inside [GitHub's releases](https://github.com/TypedDevs/bashunit/releases). E.g.:
```-vue
https://github.com/TypedDevs/bashunit/releases/download/{{ pkg.version }}/checksum
```
:::
#### Define custom tag and folder
The installation script can receive arguments (in any order):
::: code-group
```bash [Linux/Mac]
curl -s https://bashunit.com/install.sh | bash -s [dir] [version]
```
```bash [Windows]
# IMPORTANT: You need WSL (Windows Subsystem for Linux) to run bashunit
#
# Step 1: Install WSL if you haven't already
# - Open PowerShell as Administrator
# - Run: wsl --install
# - Restart your computer
#
# Step 2: Open your WSL terminal and run:
curl -s https://bashunit.com/install.sh | bash -s [dir] [version]
```
:::
- `[dir]`: the destiny directory to save the executable bashunit; `lib` by default
- `[version]`: the [release](https://github.com/TypedDevs/bashunit/releases) to download, for instance `{{ pkg.version }}`; `latest` by default.
::: tip
You can use `beta` as `[version]` to get the next non-stable preview release.
We try to keep it stable, but there is no promise that we won't change functions or their signatures without prior notice.
:::
::: tip
Committing (or not) this file to your project it's up to you. In the end, it is a dev dependency.
:::
## npm
[bashunit on npm](https://www.npmjs.com/package/bashunit) is the recommended option for Node.js projects.
::: code-group
```bash [Per-project (recommended)]
# Install as dev dependency
npm install --save-dev bashunit
# Run via npx (resolves node_modules/.bin/bashunit)
npx bashunit tests/
```
```bash [Global]
# Install on PATH
npm install -g bashunit
# Run directly, no npx needed
bashunit tests/
```
```bash [One-shot]
# No install, runs the latest release
npx bashunit@latest tests/
```
:::
### Per-project: `package.json` script
Only relevant for the per-project install (global and one-shot don't touch `package.json`).
Define a script so contributors and CI share one command:
```json-vue
{
"scripts": {
"test": "bashunit tests/"
},
"devDependencies": {
"bashunit": "^{{ pkg.version }}"
}
}
```
Then run:
```bash
npm test
# or any custom script name
npm run
```
::: warning Windows (native) not supported
The npm package declares `"os": ["darwin", "linux"]`, so `npm install bashunit` on native Windows (PowerShell / cmd) fails with `EBADPLATFORM`:
```text
npm error code EBADPLATFORM
npm error notsup Unsupported platform for bashunit@x.y.z: wanted {"os":"darwin,linux"} (current: {"os":"win32"})
```
Fix: install [WSL](https://learn.microsoft.com/windows/wsl/install) and run `npm install --save-dev bashunit` inside the WSL shell. Alternatively use the [`install.sh`](#install-sh) route from WSL.
:::
::: warning
The npm package only ships the prebuilt single-file binary (no `src/` tree). You cannot `source` internals from `node_modules/bashunit/` - use the `bashunit` command. To vendor or extend the framework, use [install.sh](#install-sh) or clone the repository.
:::
## Brew
You can install **bashunit** globally on macOS or Linux using brew.
```bash
brew install bashunit
```
## MacPorts
On macOS, you can also install **bashunit** via [MacPorts](https://www.macports.org):
```bash
sudo port install bashunit
```
## bashdep
You can manage your dependencies using [bashdep](https://github.com/Chemaclass/bashdep),
a simple dependency manager for bash.
::: code-group
```bash-vue [Linux/Mac - install-dependencies.sh]
# Ensure bashdep is installed
[ ! -f lib/bashdep ] && {
mkdir -p lib
curl -sLo lib/bashdep \
https://github.com/Chemaclass/bashdep/releases/download/0.1/bashdep
chmod +x lib/bashdep
}
# Add latest bashunit release to your dependencies
DEPENDENCIES=(
"https://github.com/TypedDevs/bashunit/releases/download/{{ pkg.version }}/bashunit"
)
# Load, configure and run bashdep
source lib/bashdep
bashdep::setup dir="lib" silent=false
bashdep::install "${DEPENDENCIES[@]}"
```
```bash-vue [Windows - install-dependencies.sh]
# IMPORTANT: You need WSL (Windows Subsystem for Linux) to run bashunit
#
# Step 1: Install WSL if you haven't already
# - Open PowerShell as Administrator
# - Run: wsl --install
# - Restart your computer
#
# Step 2: Open your WSL terminal and run:
# Ensure bashdep is installed
[ ! -f lib/bashdep ] && {
mkdir -p lib
curl -sLo lib/bashdep \
https://github.com/Chemaclass/bashdep/releases/download/0.1/bashdep
chmod +x lib/bashdep
}
# Add latest bashunit release to your dependencies
DEPENDENCIES=(
"https://github.com/TypedDevs/bashunit/releases/download/{{ pkg.version }}/bashunit"
)
# Load, configure and run bashdep
source lib/bashdep
bashdep::setup dir="lib" silent=false
bashdep::install "${DEPENDENCIES[@]}"
```
```[Output]
Downloading 'bashunit' to 'lib'...
> bashunit installed successfully in 'lib'
```
:::
## GitHub Actions
The official `TypedDevs/bashunit` action installs the binary in one step.
Pin it to the floating major tag `@v0` to track the latest release within a major,
or to a commit SHA for an immutable, supply-chain-safe install
(keeps static analyzers such as [zizmor](https://github.com/woodruffw/zizmor) happy):
::: code-group
```yaml-vue [via action]
# .github/workflows/bashunit-tests.yml
name: Tests
on: [pull_request, push]
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# @v0 tracks the latest release within the v0 major.
# For an immutable pin use a commit SHA: TypedDevs/bashunit@ # {{ pkg.version }}
- uses: TypedDevs/bashunit@v0
with:
version: '{{ pkg.version }}' # or "latest" (default)
directory: lib # optional, "lib" by default
add-to-path: 'true' # optional, "true" by default
verify-checksum: 'true' # optional, "true" by default
# add-to-path puts the binary on $PATH, so just call "bashunit":
- run: bashunit tests
```
```yaml-vue [install + run]
# .github/workflows/bashunit-tests.yml
name: Tests
on: [pull_request, push]
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# Install and run the suite in a single step via the `args` input.
- uses: TypedDevs/bashunit@v0
with:
version: '{{ pkg.version }}'
args: tests/ --strict
```
```yaml [via install.sh]
# .github/workflows/bashunit-tests.yml
name: Tests
on: [pull_request, push]
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: curl -s https://bashunit.com/install.sh | bash
- run: ./lib/bashunit tests
```
```yaml [via npm]
# .github/workflows/bashunit-tests.yml
name: Tests
on: [pull_request, push]
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with: { node-version: 22 }
- run: npm ci
- run: npx bashunit tests/
```
:::
**Inputs:** `version` (default `latest`), `directory` (default `lib`), `add-to-path` (default `true`), `verify-checksum` (default `true`), `args` (default empty — when set, runs `bashunit ` after installing).
**Outputs:** `path` (binary path relative to the workspace), `version` (installed version).
`verify-checksum` validates the downloaded binary against the release `checksum`
asset (sha256) and fails the install on any mismatch. Set it to `false` only when
pinning a release published before checksum assets existed.
### Keep the SHA pin fresh automatically
A commit-SHA pin is the most secure, but bumping it by hand is tedious. Let a bot do it
and keep the `# {{ pkg.version }}` comment as the human-readable tracker.
::: code-group
```json [Renovate - renovate.json]
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"packageRules": [
{
"matchManagers": ["github-actions"],
"matchPackageNames": ["TypedDevs/bashunit"],
"pinDigests": true
}
]
}
```
```yaml [Dependabot - .github/dependabot.yml]
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
```
:::
Renovate updates the pinned SHA and refreshes the trailing `# tag` comment in the same PR.
Dependabot bumps `github-actions` pins on the schedule you set.
Either way you get bashunit updates as routine pull requests — no manual re-pinning or
`curl | bash` bumps to remember. Review the PR, let CI run, merge.
::: tip
See bashunit's own pipeline for a real example: https://github.com/TypedDevs/bashunit/blob/main/.github/workflows/tests.yml
:::
## Shell completion
bashunit ships tab-completion scripts for bash and zsh under
[`completions/`](https://github.com/TypedDevs/bashunit/tree/main/completions)
— subcommands, all `test` flags (with value hints like `--jobs auto` and
`--output tap`), and the assertion names after `bashunit assert`.
::: code-group
```bash [bash]
# With bash-completion installed (path may vary by OS):
cp completions/bashunit.bash /usr/local/etc/bash_completion.d/bashunit
# Or source it directly from your ~/.bashrc:
source /path/to/bashunit/completions/bashunit.bash
```
```zsh [zsh]
# Copy into any directory in your $fpath, e.g.:
cp completions/_bashunit /usr/local/share/zsh/site-functions/_bashunit
# then restart zsh (or reinitialize completions):
autoload -Uz compinit && compinit
```
:::
The scripts are kept honest by an anti-drift test in CI: adding a flag to
bashunit without updating the completions fails the build.
## Related
- [Quickstart](/quickstart) - write and run your first test
- [Command line](/command-line) - CLI flags and options
- [Configuration](/configuration) - env vars and config files
- [Project overview](/project-overview) - repo layout and contributor workflow
---
# Project overview
**bashunit** is a lightweight testing framework for Bash. It focuses on helping developers verify their shell scripts with minimal setup. The library bundles hundreds of assertions and helpers, including spies, mocks and data providers.
This repository hosts the bashunit source code, its documentation and many automated tests. New contributors can use this overview to understand the basic layout and workflow when working on the project.
## Repository layout
- `src` – library functions used by `bashunit`.
- `bin` – the executable entry points.
- `adrs` – internal architecture decisions records.
- `example` – example scripts and tests demonstrating usage.
- `tests` – automated tests for bashunit itself.
- `docs` – documentation built with [VitePress](https://vitepress.dev/).
## Running tests
The project uses bashunit to test itself. To execute the full suite, run:
::: code-group
```bash [Quick]
./bashunit -s -p tests # Regular tests
./bashunit -s -b tests # Benchmark tests
```
```bash [Complete]
./bashunit --simple --parallel tests # Regular tests
./bashunit --simple --bench tests # Benchmark tests
```
:::
> See more command line options: [here](/command-line)
## Contributing
Pull requests are welcome! Please read the [contribution guide](https://github.com/TypedDevs/bashunit/blob/main/.github/CONTRIBUTING.md) before sending patches. All contributions are covered by the MIT license.
For documentation changes you can preview locally with:
```bash
cd docs
npm ci
npm run dev
```
Before submitting your pull request ensure that `npm run build` (run from `docs/`) succeeds and that the test suite passes.
## Further reading
For a step‑by‑step introduction check the [quickstart](/quickstart). Detailed usage of individual features is explained throughout the docs site.
## Related
- [Quickstart](/quickstart) - write and run your first test
- [Installation](/installation) - install bashunit
- [Command line](/command-line) - CLI flags and options
- [Examples](/examples) - sample tests and real-world projects
---
# Assertions
When creating tests, you'll need to verify your commands and functions.
We provide assertions for these checks.
Below is their documentation.
## assert_true
> `assert_true bool|function|command`
Reports an error if the argument result in a truthy value: `true` or `0`.
- [assert_false](#assert-false) is similar but different.
::: code-group
```bash [Example]
function test_success() {
assert_true true
assert_true 0
assert_true "eval return 0"
assert_true mock_true
}
function test_failure() {
assert_true false
assert_true 1
assert_true "eval return 1"
assert_true mock_false
}
```
```bash [globals.sh]
function mock_true() {
return 0
}
function mock_false() {
return 1
}
```
:::
## assert_false
> `assert_false bool|function|command`
Reports an error if the argument result in a falsy value: `false` or `1`.
- [assert_true](#assert-true) is similar but different.
::: code-group
```bash [Example]
function test_success() {
assert_false false
assert_false 1
assert_false "eval return 1"
assert_false mock_false
}
function test_failure() {
assert_false true
assert_false 0
assert_false "eval return 0"
assert_false mock_true
}
```
```bash [globals.sh]
function mock_true() {
return 0
}
function mock_false() {
return 1
}
```
:::
## assert_same
> `assert_same "expected" "actual"`
Reports an error if the `expected` and `actual` are not the same - including special chars.
- [assert_not_same](#assert-not-same) is the inverse of this assertion and takes the same arguments.
- [assert_equals](#assert-equals) is similar but ignoring the special chars.
::: code-group
```bash [Example]
function test_success() {
assert_same "foo" "foo"
}
function test_failure() {
assert_same "foo" "bar"
}
```
:::
## assert_equals
> `assert_equals "expected" "actual"`
Reports an error if the two variables `expected` and `actual` are not equal ignoring the special chars like ANSI Escape Sequences (colors) and other special chars like tabs and new lines.
- [assert_same](#assert-same) is similar but including special chars.
::: code-group
```bash [Example]
function test_success() {
assert_equals "foo" "\e[31mfoo"
}
function test_failure() {
assert_equals "\e[31mfoo" "\e[31mfoo"
}
```
:::
## assert_contains
> `assert_contains "needle" "haystack"`
Reports an error if `needle` is not a substring of `haystack`.
- [assert_not_contains](#assert-not-contains) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_contains "foo" "foobar"
}
function test_failure() {
assert_contains "baz" "foobar"
}
```
:::
## assert_contains_ignore_case
> `assert_contains_ignore_case "needle" "haystack"`
Reports an error if `needle` is not a substring of `haystack`.
Differences in casing are ignored when needle is searched for in haystack.
::: code-group
```bash [Example]
function test_success() {
assert_contains_ignore_case "foo" "FooBar"
}
function test_failure() {
assert_contains_ignore_case "baz" "FooBar"
}
```
:::
## assert_empty
> `assert_empty "actual"`
Reports an error if `actual` is not empty.
- [assert_not_empty](#assert-not-empty) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_empty ""
}
function test_failure() {
assert_empty "foo"
}
```
:::
## assert_matches
> `assert_matches "pattern" "value"`
Reports an error if `value` does not match the regular expression `pattern`.
- [assert_not_matches](#assert-not-matches) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_matches "^foo" "foobar"
}
function test_failure() {
assert_matches "^bar" "foobar"
}
```
:::
## assert_string_starts_with
> `assert_string_starts_with "needle" "haystack"`
Reports an error if `haystack` does not starts with `needle`.
- [assert_string_not_starts_with](#assert-string-not-starts-with) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_string_starts_with "foo" "foobar"
}
function test_failure() {
assert_string_starts_with "baz" "foobar"
}
```
:::
## assert_string_ends_with
> `assert_string_ends_with "needle" "haystack"`
Reports an error if `haystack` does not ends with `needle`.
- [assert_string_not_ends_with](#assert-string-not-ends-with) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_string_ends_with "bar" "foobar"
}
function test_failure() {
assert_string_ends_with "foo" "foobar"
}
```
:::
## assert_string_matches_format
> `assert_string_matches_format "format" "value"`
Reports an error if `value` does not match the `format` string. The format string uses PHPUnit-style placeholders:
| Placeholder | Matches |
|-------------|---------|
| `%d` | One or more digits |
| `%i` | Signed integer (e.g. `+1`, `-42`) |
| `%f` | Floating point number (e.g. `3.14`) |
| `%s` | One or more non-whitespace characters |
| `%x` | Hexadecimal (e.g. `ff00ab`) |
| `%e` | Scientific notation (e.g. `1.5e10`) |
| `%%` | Literal `%` character |
- [assert_string_not_matches_format](#assert-string-not-matches-format) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_string_matches_format "%d items found" "42 items found"
assert_string_matches_format "%s has %d items at %f each" "cart has 5 items at 9.99 each"
}
function test_failure() {
assert_string_matches_format "%d items" "hello world"
}
```
:::
## assert_line_count
> `assert_line_count "count" "haystack"`
Reports an error if `haystack` does not contain `count` lines.
::: code-group
```bash [Example]
function test_success() {
local string="this is line one
this is line two
this is line three"
assert_line_count 3 "$string"
}
function test_failure() {
assert_line_count 2 "foobar"
}
```
:::
## assert_less_than
> `assert_less_than "expected" "actual"`
Reports an error if `actual` is not less than `expected`.
- [assert_greater_than](#assert-greater-than) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_less_than "999" "1"
}
function test_failure() {
assert_less_than "1" "999"
}
```
:::
## assert_less_or_equal_than
> `assert_less_or_equal_than "expected" "actual"`
Reports an error if `actual` is not less than or equal to `expected`.
- [assert_greater_than](#assert-greater-or-equal-than) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_less_or_equal_than "999" "1"
}
function test_success_with_two_equal_numbers() {
assert_less_or_equal_than "999" "999"
}
function test_failure() {
assert_less_or_equal_than "1" "999"
}
```
:::
## assert_greater_than
> `assert_greater_than "expected" "actual"`
Reports an error if `actual` is not greater than `expected`.
- [assert_less_than](#assert-less-than) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_greater_than "1" "999"
}
function test_failure() {
assert_greater_than "999" "1"
}
```
:::
## assert_greater_or_equal_than
> `assert_greater_or_equal_than "expected" "actual"`
Reports an error if `actual` is not greater than or equal to `expected`.
- [assert_less_or_equal_than](#assert-less-or-equal-than) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_greater_or_equal_than "1" "999"
}
function test_success_with_two_equal_numbers() {
assert_greater_or_equal_than "999" "999"
}
function test_failure() {
assert_greater_or_equal_than "999" "1"
}
```
:::
## assert_within_delta
> `assert_within_delta "expected" "actual" "delta"`
Reports an error if `actual` is not within `delta` of `expected`
(i.e. `|actual - expected| > delta`). Supports floating-point values.
Useful for timing or measured values where exact equality is too strict.
::: code-group
```bash [Example]
function test_success() {
assert_within_delta "3.14159" "3.14" "0.01"
}
function test_failure() {
assert_within_delta "100" "105" "3"
}
```
:::
## assert_date_equals
> `assert_date_equals "expected" "actual"`
Reports an error if the two date values `expected` and `actual` are not equal.
Inputs are automatically converted to epoch seconds. Supported formats:
- Epoch seconds (integers): `1700000000`
- ISO 8601 date: `2023-11-14`
- ISO 8601 datetime: `2023-11-14T12:00:00`
- ISO 8601 datetime with UTC Z: `2023-11-14T12:00:00Z`
- ISO 8601 datetime with timezone offset: `2023-11-14T12:00:00+0100`
- Space-separated datetime: `2023-11-14 12:00:00`
You can mix formats in the same assertion (e.g., one epoch, one ISO).
::: code-group
```bash [Example]
function test_success() {
local now
now="$(date +%s)"
assert_date_equals "$now" "$now"
}
function test_failure() {
assert_date_equals "1700000000" "1600000000"
}
```
:::
## assert_date_before
> `assert_date_before "expected" "actual"`
Reports an error if `actual` is not before `expected` (i.e. `actual` must be less than `expected`).
Inputs are automatically converted to epoch seconds. See [assert_date_equals](#assert_date_equals) for supported formats.
::: code-group
```bash [Example]
function test_success() {
assert_date_before "1700000000" "1600000000"
}
function test_failure() {
assert_date_before "1700000000" "1800000000"
}
```
:::
## assert_date_after
> `assert_date_after "expected" "actual"`
Reports an error if `actual` is not after `expected` (i.e. `actual` must be greater than `expected`).
Inputs are automatically converted to epoch seconds. See [assert_date_equals](#assert_date_equals) for supported formats.
::: code-group
```bash [Example]
function test_success() {
assert_date_after "1600000000" "1700000000"
}
function test_failure() {
assert_date_after "1600000000" "1500000000"
}
```
:::
## assert_date_within_range
> `assert_date_within_range "from" "to" "actual"`
Reports an error if `actual` does not fall between `from` and `to` (inclusive).
Inputs are automatically converted to epoch seconds. See [assert_date_equals](#assert_date_equals) for supported formats.
::: code-group
```bash [Example]
function test_success() {
assert_date_within_range "1600000000" "1800000000" "1700000000"
}
function test_failure() {
assert_date_within_range "1600000000" "1800000000" "1900000000"
}
```
:::
## assert_date_within_delta
> `assert_date_within_delta "expected" "actual" "delta"`
Reports an error if `actual` is not within `delta` seconds of `expected`.
Inputs are automatically converted to epoch seconds. See [assert_date_equals](#assert_date_equals) for supported formats.
::: code-group
```bash [Example]
function test_success() {
local now
now="$(date +%s)"
local five_seconds_later=$(( now + 5 ))
assert_date_within_delta "$now" "$five_seconds_later" "10"
}
function test_failure() {
assert_date_within_delta "1700000000" "1700000020" "5"
}
```
:::
## assert_exit_code
> `assert_exit_code "expected"`
Reports an error if the exit code of the last executed command is not equal to `expected`.
This assertion captures `$?` from the command executed **before** calling the assertion.
It does **not** execute a string command passed as a second parameter.
::: tip
Use [assert_exec](#assert-exec) if you want to pass a command as a string and check its exit code:
`assert_exec "your_command" --exit 0`
:::
- [assert_successful_code](#assert-successful-code), [assert_unsuccessful_code](#assert-unsuccessful-code), [assert_general_error](#assert-general-error) and [assert_command_not_found](#assert-command-not-found)
are more semantic versions of this assertion, for which you don't need to specify an exit code.
::: code-group
```bash [Example]
function test_success_checking_previous_command() {
function foo() {
return 1
}
foo
assert_exit_code "1"
}
function test_success_with_external_command() {
touch /tmp/myfile
assert_exit_code "0"
}
function test_failure() {
function foo() {
return 1
}
foo
assert_exit_code "0"
}
```
:::
## assert_exec
> `assert_exec "command" [--exit ] [--stdout "text"] [--stderr "text"] [--stdout-contains "needle"] [--stdout-not-contains "needle"] [--stderr-contains "needle"] [--stderr-not-contains "needle"] [--stdin "input"]`
Runs `command` capturing its exit status, standard output and standard error and
checks all provided expectations. When `--exit` is omitted the expected exit
status defaults to `0`.
Use `--stdin` to feed input into interactive commands (e.g. commands using
`read`). Multiple answers can be passed by separating them with newlines.
Use `--stdout-contains` / `--stdout-not-contains` (and the `stderr-*` variants)
for substring matching when you don't want to assert against the full output.
::: code-group
```bash [Example]
function sample() {
echo "out"
echo "err" >&2
return 1
}
function test_success() {
assert_exec sample --exit 1 --stdout "out" --stderr "err"
}
function test_failure() {
assert_exec sample --exit 0 --stdout "out" --stderr "err"
}
```
```bash [Interactive]
function question() {
local name lang
read -r name
read -r lang
echo "Your name is $name and you prefer $lang."
}
function test_interactive_prompt() {
assert_exec question \
--stdin "Chemaclass"$'\n'"Phel-Lang"$'\n' \
--stdout-contains "Your name is Chemaclass and you prefer Phel-Lang." \
--stdout-not-contains "Delphi" \
--exit 0
}
```
:::
## assert_arrays_equal
> `assert_arrays_equal "expected..." -- "actual..."`
Reports an error if the arrays have different lengths or any element differs at the same index.
Use `--` to separate the expected array from the actual array.
::: code-group
```bash [Example]
function test_success() {
local expected=(foo bar baz)
local actual=(foo bar baz)
assert_arrays_equal "${expected[@]}" -- "${actual[@]}"
}
function test_failure() {
local expected=(foo bar baz)
local actual=(foo baz bar)
assert_arrays_equal "${expected[@]}" -- "${actual[@]}"
}
```
:::
## assert_array_contains
> `assert_array_contains "needle" "haystack"`
Reports an error if `needle` is not an element of `haystack`.
- [assert_array_not_contains](#assert-array-not-contains) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local haystack=(foo bar baz)
assert_array_contains "bar" "${haystack[@]}"
}
function test_failure() {
local haystack=(foo bar baz)
assert_array_contains "foobar" "${haystack[@]}"
}
```
:::
## assert_array_length
> `assert_array_length "expected_length" "array"`
Reports an error if `array` does not have exactly `expected_length` elements.
::: code-group
```bash [Example]
function test_success() {
local haystack=(foo bar baz)
assert_array_length 3 "${haystack[@]}"
}
function test_failure() {
local haystack=(foo bar baz)
assert_array_length 2 "${haystack[@]}"
}
```
:::
## assert_successful_code
> `assert_successful_code`
Reports an error if the exit code of the last executed command is not successful (`0`).
This assertion captures `$?` from the command executed **before** calling the assertion.
It does **not** execute a string command passed as a parameter.
::: tip
Use [assert_exec](#assert-exec) if you want to pass a command as a string and check its exit code:
`assert_exec "your_command"` (defaults to expecting exit code 0)
:::
- [assert_exit_code](#assert-exit-code) is the full version of this assertion where you can specify the expected exit code.
::: code-group
```bash [Example]
function test_success_with_function() {
function foo() {
return 0
}
foo
assert_successful_code
}
function test_success_with_external_command() {
touch /tmp/myfile
assert_successful_code
}
function test_failure() {
function foo() {
return 1
}
foo
assert_successful_code
}
```
:::
## assert_unsuccessful_code
> `assert_unsuccessful_code`
Reports an error if the exit code of the last executed command is not unsuccessful (non-zero).
This assertion captures `$?` from the command executed **before** calling the assertion.
It does **not** execute a string command passed as a parameter.
::: tip
Use [assert_exec](#assert-exec) if you want to pass a command as a string and check its exit code:
`assert_exec "your_command" --exit 1`
:::
- [assert_exit_code](#assert-exit-code) is the full version of this assertion where you can specify the expected exit code.
::: code-group
```bash [Example]
function test_success_with_function() {
function foo() {
return 1
}
foo
assert_unsuccessful_code
}
function test_success_with_failing_command() {
ls /nonexistent_path 2>/dev/null
assert_unsuccessful_code
}
function test_failure() {
function foo() {
return 0
}
foo
assert_unsuccessful_code
}
```
:::
## assert_general_error
> `assert_general_error`
Reports an error if the exit code of the last executed command is not a general error (`1`).
This assertion captures `$?` from the command executed **before** calling the assertion.
It does **not** execute a string command passed as a parameter.
::: tip
Use [assert_exec](#assert-exec) if you want to pass a command as a string and check its exit code:
`assert_exec "your_command" --exit 1`
:::
- [assert_exit_code](#assert-exit-code) is the full version of this assertion where you can specify the expected exit code.
::: code-group
```bash [Example]
function test_success_with_function() {
function foo() {
return 1
}
foo
assert_general_error
}
function test_success_with_external_command() {
grep "nonexistent" /dev/null
assert_general_error
}
function test_failure() {
function foo() {
return 0
}
foo
assert_general_error
}
```
:::
## assert_command_not_found
> `assert_command_not_found`
Reports an error if the last executed command did not return a "command not found" exit code (`127`).
This assertion captures `$?` from the command executed **before** calling the assertion.
It does **not** execute a string command passed as a parameter.
::: tip
Use [assert_exec](#assert-exec) if you want to pass a command as a string and check its exit code:
`assert_exec "nonexistent_command" --exit 127`
:::
- [assert_exit_code](#assert-exit-code) is the full version of this assertion where you can specify the expected exit code.
::: code-group
```bash [Example]
function test_success_with_nonexistent_command() {
nonexistent_command 2>/dev/null
assert_command_not_found
}
function test_failure_with_existing_command() {
ls > /dev/null 2>&1
assert_command_not_found
}
```
:::
## assert_file_exists
> `assert_file_exists "file"`
Reports an error if `file` does not exists, or it is a directory.
- [assert_file_not_exists](#assert-file-not-exists) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local file_path="foo.txt"
touch "$file_path"
assert_file_exists "$file_path"
rm "$file_path"
}
function test_failure() {
local file_path="foo.txt"
rm -f $file_path
assert_file_exists "$file_path"
}
```
:::
## assert_file_contains
> `assert_file_contains "file" "search"`
Reports an error if `file` does not contains the search string.
- [assert_file_not_contains](#assert-file-not-contains) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local file="/tmp/file-path.txt"
echo -e "original content" > "$file"
assert_file_contains "$file" "content"
}
function test_failure() {
local file="/tmp/file-path.txt"
echo -e "original content" > "$file"
assert_file_contains "$file" "non existing"
}
```
:::
## assert_file_permissions
> `assert_file_permissions "mode" "file"`
Reports an error if `file` does not have the expected octal permission `mode`
(e.g. `644`, `0755`). A leading zero is optional (`0755` and `755` are equal).
Works on both Linux (GNU `stat`) and macOS (BSD `stat`).
::: code-group
```bash [Example]
function test_success() {
local file="/tmp/file-path.txt"
touch "$file"
chmod 600 "$file"
assert_file_permissions "600" "$file"
}
function test_failure() {
local file="/tmp/file-path.txt"
touch "$file"
chmod 644 "$file"
assert_file_permissions "600" "$file"
}
```
:::
## assert_is_file
> `assert_is_file "file"`
Reports an error if `file` is not a file.
::: code-group
```bash [Example]
function test_success() {
local file_path="foo.txt"
touch "$file_path"
assert_is_file "$file_path"
rm "$file_path"
}
function test_failure() {
local dir_path="bar"
mkdir "$dir_path"
assert_is_file "$dir_path"
rmdir "$dir_path"
}
```
:::
## assert_is_file_empty
> `assert_is_file_empty "file"`
Reports an error if `file` is not empty.
::: code-group
```bash [Example]
function test_success() {
local file_path="foo.txt"
touch "$file_path"
assert_is_file_empty "$file_path"
rm "$file_path"
}
function test_failure() {
local file_path="foo.txt"
echo "bar" > "$file_path"
assert_is_file_empty "$file_path"
rm "$file_path"
}
```
:::
## assert_directory_exists
> `assert_directory_exists "directory"`
Reports an error if `directory` does not exist.
- [assert_directory_not_exists](#assert-directory-not-exists) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local directory="/var"
assert_directory_exists "$directory"
}
function test_failure() {
local directory="/nonexistent_directory"
assert_directory_exists "$directory"
}
```
:::
## assert_is_directory
> `assert_is_directory "directory"`
Reports an error if `directory` is not a directory.
::: code-group
```bash [Example]
function test_success() {
local directory="/var"
assert_is_directory "$directory"
}
function test_failure() {
local file="/etc/hosts"
assert_is_directory "$file"
}
```
:::
## assert_is_directory_empty
> `assert_is_directory_empty "directory"`
Reports an error if `directory` is not an empty directory.
- [assert_is_directory_not_empty](#assert-is-directory-not-empty) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local directory="/home/user/empty_directory"
mkdir "$directory"
assert_is_directory_empty "$directory"
}
function test_failure() {
local directory="/etc"
assert_is_directory_empty "$directory"
}
```
:::
## assert_is_directory_readable
> `assert_is_directory_readable "directory"`
Reports an error if `directory` is not a readable directory.
- [assert_is_directory_not_readable](#assert-is-directory-not-readable) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local directory="/var"
assert_is_directory_readable "$directory"
}
function test_failure() {
local directory="/home/user/test"
chmod -r "$directory"
assert_is_directory_readable "$directory"
}
```
:::
## assert_is_directory_writable
> `assert_is_directory_writable "directory"`
Reports an error if `directory` is not a writable directory.
- [assert_is_directory_not_writable](#assert-is-directory-not-writable) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local directory="/tmp"
assert_is_directory_writable "$directory"
}
function test_failure() {
local directory="/home/user/test"
chmod -w "$directory"
assert_is_directory_writable "$directory"
}
```
:::
## assert_files_equals
> `assert_files_equals "expected" "actual"`
Reports an error if `expected` and `actual` are not equals.
- [assert_files_not_equals](#assert-files-not-equals) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local expected="/tmp/file1.txt"
local actual="/tmp/file2.txt"
echo "file content" > "$expected"
echo "file content" > "$actual"
assert_files_equals "$expected" "$actual"
}
function test_failure() {
local expected="/tmp/file1.txt"
local actual="/tmp/file2.txt"
echo "file content" > "$expected"
echo "different content" > "$actual"
assert_files_equals "$expected" "$actual"
}
```
```[Output]
✓ Passed: Success
✗ Failed: Failure
Expected '/tmp/file1.txt'
Compared '/tmp/file2.txt'
Diff '@@ -1 +1 @@
-file content
+different content'
```
:::
## assert_not_equals
> `assert_not_equals "expected" "actual"`
Reports an error if the two variables `expected` and `actual` are equal ignoring the special chars like ANSI Escape Sequences (colors) and other special chars like tabs and new lines.
- [assert_equals](#assert-equals) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_not_equals "foo" "bar"
}
function test_failure() {
assert_not_equals "foo" "foo"
}
```
:::
## assert_not_same
> `assert_not_same "expected" "actual"`
Reports an error if the two variables `expected` and `actual` are the same value.
- [assert_same](#assert-same) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_not_same "foo" "bar"
}
function test_failure() {
assert_not_same "foo" "foo"
}
```
:::
## assert_not_contains
> `assert_not_contains "needle" "haystack"`
Reports an error if `needle` is a substring of `haystack`.
- [assert_contains](#assert-contains) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_not_contains "baz" "foobar"
}
function test_failure() {
assert_not_contains "foo" "foobar"
}
```
:::
## assert_string_not_starts_with
> `assert_string_not_starts_with "needle" "haystack"`
Reports an error if `haystack` does starts with `needle`.
- [assert_string_starts_with](#assert-string-starts-with) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_string_not_starts_with "bar" "foobar"
}
function test_failure() {
assert_string_not_starts_with "foo" "foobar"
}
```
:::
## assert_string_not_ends_with
> `assert_string_not_ends_with "needle" "haystack"`
Reports an error if `haystack` does ends with `needle`.
- [assert_string_ends_with](#assert-string-ends-with) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_string_not_ends_with "foo" "foobar"
}
function test_failure() {
assert_string_not_ends_with "bar" "foobar"
}
```
:::
## assert_not_empty
> `assert_not_empty "actual"`
Reports an error if `actual` is empty.
- [assert_empty](#assert-empty) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_not_empty "foo"
}
function test_failure() {
assert_not_empty ""
}
```
:::
## assert_not_matches
> `assert_not_matches "pattern" "value"`
Reports an error if `value` matches the regular expression `pattern`.
- [assert_matches](#assert-matches) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_not_matches "foo$" "foobar"
}
function test_failure() {
assert_not_matches "bar$" "foobar"
}
```
:::
## assert_string_not_matches_format
> `assert_string_not_matches_format "format" "value"`
Reports an error if `value` matches the `format` string. See [assert_string_matches_format](#assert-string-matches-format) for supported placeholders.
- [assert_string_matches_format](#assert-string-matches-format) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
assert_string_not_matches_format "%d items" "hello world"
}
function test_failure() {
assert_string_not_matches_format "%d items" "42 items"
}
```
:::
## assert_array_not_contains
> `assert_array_not_contains "needle" "haystack"`
Reports an error if `needle` is an element of `haystack`.
- [assert_array_contains](#assert-array-contains) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local haystack=(foo bar baz)
assert_array_not_contains "foobar" "${haystack[@]}"
}
function test_failure() {
local haystack=(foo bar baz)
assert_array_not_contains "baz" "${haystack[@]}"
}
```
:::
## assert_file_not_exists
> `assert_file_not_exists "file"`
Reports an error if `file` does exists.
- [assert_file_exists](#assert-file-exists) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local file_path="foo.txt"
touch "$file_path"
rm "$file_path"
assert_file_not_exists "$file_path"
}
function test_failed() {
local file_path="foo.txt"
touch "$file_path"
assert_file_not_exists "$file_path"
rm "$file_path"
}
```
:::
## assert_file_not_contains
> `assert_file_not_contains "file" "search"`
Reports an error if `file` contains the search string.
- [assert_file_contains](#assert-file-contains) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local file="/tmp/file-path.txt"
echo -e "original content" > "$file"
assert_file_not_contains "$file" "non existing"
}
function test_failure() {
local file="/tmp/file-path.txt"
echo -e "original content" > "$file"
assert_file_not_contains "$file" "content"
}
```
:::
## assert_directory_not_exists
> `assert_directory_not_exists "directory"`
Reports an error if `directory` exists.
- [assert_directory_exists](#assert-directory-exists) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local directory="/nonexistent_directory"
assert_directory_not_exists "$directory"
}
function test_failure() {
local directory="/var"
assert_directory_not_exists "$directory"
}
```
:::
## assert_is_directory_not_empty
> `assert_is_directory_not_empty "directory"`
Reports an error if `directory` is empty.
- [assert_is_directory_empty](#assert-is-directory-empty) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local directory="/etc"
assert_is_directory_not_empty "$directory"
}
function test_failure() {
local directory="/home/user/empty_directory"
mkdir "$directory"
assert_is_directory_not_empty "$directory"
}
```
:::
## assert_is_directory_not_readable
> `assert_is_directory_not_readable "directory"`
Reports an error if `directory` is readable.
- [assert_is_directory_readable](#assert-is-directory-readable) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local directory="/home/user/test"
chmod -r "$directory"
assert_is_directory_not_readable "$directory"
}
function test_failure() {
local directory="/var"
assert_is_directory_not_readable "$directory"
}
```
:::
## assert_is_directory_not_writable
> `assert_is_directory_not_writable "directory"`
Reports an error if `directory` is writable.
- [assert_is_directory_writable](#assert-is-directory-writable) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local directory="/home/user/test"
chmod -w "$directory"
assert_is_directory_not_writable "$directory"
}
function test_failure() {
local directory="/tmp"
assert_is_directory_not_writable "$directory"
}
```
:::
## assert_files_not_equals
> `assert_files_not_equals "expected" "actual"`
Reports an error if `expected` and `actual` are not equals.
- [assert_files_equals](#assert-files-equals) is the inverse of this assertion and takes the same arguments.
::: code-group
```bash [Example]
function test_success() {
local expected="/tmp/file1.txt"
local actual="/tmp/file2.txt"
echo "file content" > "$expected"
echo "different content" > "$actual"
assert_files_not_equals "$expected" "$actual"
}
function test_failure() {
local expected="/tmp/file1.txt"
local actual="/tmp/file2.txt"
echo "file content" > "$expected"
echo "file content" > "$actual"
assert_files_not_equals "$expected" "$actual"
}
```
```[Output]
✓ Passed: Success
✗ Failed: Failure
Expected '/tmp/file1.txt'
Compared '/tmp/file2.txt'
Diff 'Files are equals'
```
:::
## assert_json_key_exists
> `assert_json_key_exists "key" "json"`
Reports an error if `key` does not exist in the JSON string. Uses [jq](https://jqlang.github.io/jq/) syntax for key paths. Requires `jq` to be installed; if missing the test is skipped.
::: code-group
```bash [Example]
function test_success() {
assert_json_key_exists ".name" '{"name":"bashunit","version":"1.0"}'
assert_json_key_exists ".data.id" '{"data":{"id":42}}'
}
function test_failure() {
assert_json_key_exists ".missing" '{"name":"bashunit"}'
}
```
:::
## assert_json_contains
> `assert_json_contains "key" "expected" "json"`
Reports an error if `key` does not exist in the JSON string or its value does not equal `expected`. Uses [jq](https://jqlang.github.io/jq/) syntax for key paths. Requires `jq` to be installed; if missing the test is skipped.
::: code-group
```bash [Example]
function test_success() {
assert_json_contains ".name" "bashunit" '{"name":"bashunit","version":"1.0"}'
assert_json_contains ".count" "42" '{"count":42}'
}
function test_failure() {
assert_json_contains ".name" "other" '{"name":"bashunit"}'
assert_json_contains ".missing" "value" '{"name":"bashunit"}'
}
```
:::
## assert_json_equals
> `assert_json_equals "expected" "actual"`
Reports an error if the two JSON strings are not structurally equal. Key order is ignored. Requires `jq` to be installed; if missing the test is skipped.
::: code-group
```bash [Example]
function test_success() {
assert_json_equals '{"b":2,"a":1}' '{"a":1,"b":2}'
}
function test_failure() {
assert_json_equals '{"a":1}' '{"a":2}'
}
```
:::
## assert_duration
> `assert_duration "command" threshold_ms`
Reports an error if `command` takes longer than `threshold_ms` milliseconds to execute. Uses the framework's portable clock internally.
::: code-group
```bash [Example]
function test_success() {
assert_duration "echo hello" 500
}
function test_failure() {
assert_duration "sleep 2" 1000
}
```
:::
## assert_duration_less_than
> `assert_duration_less_than "command" threshold_ms`
Reports an error if `command` takes `threshold_ms` milliseconds or more to execute. Stricter than [assert_duration](#assert-duration) which allows equal values.
::: code-group
```bash [Example]
function test_success() {
assert_duration_less_than "echo hello" 500
}
function test_failure() {
assert_duration_less_than "sleep 2" 1000
}
```
:::
## assert_duration_greater_than
> `assert_duration_greater_than "command" threshold_ms`
Reports an error if `command` completes in `threshold_ms` milliseconds or less. Useful for verifying that a command takes at least a minimum amount of time.
::: code-group
```bash [Example]
function test_success() {
assert_duration_greater_than "sleep 1" 500
}
function test_failure() {
assert_duration_greater_than "echo hello" 5000
}
```
:::
## bashunit::fail
> `bashunit::fail "failure message"`
Unambiguously reports an error message. Useful for reporting specific message
when testing situations not covered by any `assert_*` functions.
::: code-group
```bash [Example]
function test_success() {
if [ "$(date +%-H)" -gt 25 ]; then
bashunit::fail "Something is very wrong with your clock"
fi
}
function test_failure() {
if [ "$(date +%-H)" -lt 25 ]; then
bashunit::fail "This test will always fail"
fi
}
```
:::
## Related
- [Custom asserts](/custom-asserts) — build your own domain-specific assertions
- [Test doubles](/test-doubles) — mocks and spies for isolated tests
- [Data providers](/data-providers) — run the same assertions over many inputs
- [Globals](/globals) — `bashunit::` helper functions
---
# Custom asserts
Custom assertions let you extend **bashunit** with your own reusable checks, ideal for domain-specific assertions that don't need to live in the core library.
:::tip
Check the internal functional tests: `tests/functional/custom_asserts_test.sh` ([link](https://github.com/TypedDevs/bashunit/blob/main/tests/functional/custom_asserts_test.sh))
:::
::: info Assertion behavior
When using the bashunit facade, assertions automatically respect the guard behavior: if a previous assertion in the same test already failed, subsequent assertions are skipped. This matches popular testing libraries default behavior.
:::
::: info Test name detection
Custom assertions automatically display the correct **test function name** in failure messages, not the custom assertion name. This makes it easy to identify which test failed, even when using deeply nested custom assertions.
:::
## API Reference
### assertion_failed
> `bashunit::assertion_failed `
Marks the current assertion as failed and prints a failure message.
| Parameter | Description |
|-----------|-------------|
| `expected` | The expected value |
| `actual` | The actual value received |
| `failure_condition_message` | Optional message describing the failure condition (default: "but got") |
### assertion_passed
> `bashunit::assertion_passed`
Marks the current assertion as passed. Call this when your custom assertion succeeds.
## Examples
### Basic custom assertion
```bash
function assert_foo() {
local actual="$1"
if [[ "foo" != "$actual" ]]; then
bashunit::assertion_failed "foo" "$actual"
return
fi
bashunit::assertion_passed
}
function test_value_is_foo() {
assert_foo "foo" # Passes
}
function test_value_is_not_foo() {
assert_foo "bar" # Fails with: "Failed: Value is not foo"
}
```
### Using fail() for simple messages
You can also use `bashunit::fail` for custom assertions that just need a message:
```bash
function assert_valid_json() {
local json="$1"
if ! echo "$json" | jq . > /dev/null 2>&1; then
bashunit::fail "Invalid JSON: $json"
return
fi
bashunit::assertion_passed
}
function test_api_returns_valid_json() {
local response='{"status": "ok"}'
assert_valid_json "$response"
}
```
### Composing with existing assertions
Custom assertions can call other [bashunit assertions](/assertions) internally:
```bash
function assert_http_success() {
local status_code="$1"
assert_greater_or_equal_than "200" "$status_code"
assert_less_than "300" "$status_code"
}
function test_api_returns_success() {
local status_code=200
assert_http_success "$status_code"
}
```
### Custom assertion with custom failure message
```bash
function assert_positive_number() {
local actual="$1"
if [[ "$actual" -le 0 ]]; then
bashunit::assertion_failed "positive number" "$actual" "got"
return
fi
bashunit::assertion_passed
}
```
## Best practices
1. **Always return after failure**: Call `return` after `bashunit::assertion_failed` or `bashunit::fail` to stop execution of your custom assertion.
2. **Always mark success**: Call `bashunit::assertion_passed` or `state::add_assertions_passed` when your assertion succeeds.
3. **Use descriptive names**: Name your custom assertions clearly, e.g., `assert_valid_email`, `assert_file_contains_header`.
4. **Keep assertions focused**: Each custom assertion should test one specific condition.
## Related
- [Assertions](/assertions) — the built-in assertion reference
- [Globals](/globals) — `bashunit::` helper functions
- [Common patterns](/common-patterns) — real-world testing patterns
---
# Test doubles
Test doubles let you override an existing function to write tests isolated from external behaviour: use mocks to replace a function's output, and spies to assert that a function was called, with which arguments, and how many times.
Temporary files created by spies are isolated per test run, so they work reliably when executing tests in parallel.
Spies record their calls in temporary files scoped to each test run.
This avoids clashes between processes and allows spies to work reliably when tests execute in parallel using `BASHUNIT_PARALLEL_RUN`.
## bashunit::mock
> `bashunit::mock "function" "body"`
Allows you to override the behavior of a callable.
::: code-group
```bash [Example]
function test_example() {
bashunit::mock ps echo hello world
assert_same "hello world" "$(ps)"
}
```
:::
> `bashunit::mock "function" <<< "output"`
Allows you to override the output of a callable. When the mocked output fits on
a single line you can use a here-string:
```bash
bashunit::mock uname <<< "Linux"
```
For multi-line output rely on a here-document:
```bash
bashunit::mock ps < `bashunit::spy "function"`
Overrides the original behavior of a callable to allow you to make various assertions about its calls.
::: code-group
```bash [Example]
function test_example() {
bashunit::spy ps
ps foo bar
assert_have_been_called_with ps "foo bar"
assert_have_been_called ps
}
```
:::
## assert_have_been_called
> `assert_have_been_called "spy"`
Reports an error if `spy` is not called.
::: code-group
```bash [Example]
function test_success() {
bashunit::spy ps
ps
assert_have_been_called ps
}
function test_failure() {
bashunit::spy ps
assert_have_been_called ps
}
```
:::
## assert_have_been_called_with
> `assert_have_been_called_with spy expected [call_index]`
Reports an error if `spy` is not called with `expected`. When `call_index` is provided, the assertion checks the arguments of that specific call (starting at 1). Without `call_index` it checks the last invocation. Arguments are joined with spaces before comparison.
::: code-group
```bash [Example]
function test_success() {
bashunit::spy ps
ps foo
ps bar
assert_have_been_called_with ps "foo" 1
assert_have_been_called_with ps "bar" 2
}
function test_failure() {
bashunit::spy ps
ps bar
assert_have_been_called_with ps "foo" 1
}
```
:::
## assert_have_been_called_nth_with
> `assert_have_been_called_nth_with "nth" "spy" "expected"`
Reports an error if the `nth` invocation of `spy` was not called with `expected`. The index starts at 1. Reports an error if `spy` was called fewer than `nth` times.
::: code-group
```bash [Example]
function test_success() {
bashunit::spy ps
ps first
ps second
ps third
assert_have_been_called_nth_with 1 ps "first"
assert_have_been_called_nth_with 2 ps "second"
assert_have_been_called_nth_with 3 ps "third"
}
function test_failure() {
bashunit::spy ps
ps first
assert_have_been_called_nth_with 1 ps "wrong"
}
```
:::
## assert_have_been_called_times
> assert_have_been_called_times "expected" "spy"
Reports an error if `spy` is not called exactly `expected` times.
::: code-group
```bash [Example]
function test_success() {
bashunit::spy ps
ps
ps
assert_have_been_called_times 2 ps
}
function test_failure() {
bashunit::spy ps
ps
ps
assert_have_been_called_times 1 ps
}
```
:::
## assert_not_called
> `assert_not_called "spy"`
Reports an error if `spy` has been executed at least once.
::: code-group
```bash [Example]
function test_success() {
bashunit::spy ps
assert_not_called ps
}
function test_failure() {
bashunit::spy ps
ps
assert_not_called ps
}
```
:::
## Related
- [Assertions](/assertions) — the built-in assertion reference
- [Custom asserts](/custom-asserts) — build your own domain-specific assertions
- [Common patterns](/common-patterns) — real-world testing patterns
---
# Data providers
**bashunit** offers a way to parameterize your test functions with data providers.
Ideal when you want to execute the same test function multiple times, each with a different set of arguments.
## Defining a data provider
You can add a special comment `@data_provider` before a test function to specify an auxiliary function. This function controls how many times the test will run and what arguments it will receive.
A data provider function is specified as follows:
> **Note**: The previous `# data_provider` syntax is still supported but
> deprecated. Prefer using the `@` prefix going forward.
::: code-group
```bash [Example]
# @data_provider provider_function
function test_my_test_case() {
...
}
```
:::
## Implementing a data provider
A data provider function contains one or more `bashunit::data_set` lines. Each `bashunit::data_set` results in a separate run of the test function with the individual `bashunit::data_set` arguments being passed to it as positional arguments (`$1`, `$2`, ...).
Each run is treated as a separate test, so it can pass or fail independently. Plus, [set_up](/test-files#set-up-function) and [tear_down](/test-files#tear-down-function) are called before and after each run. This reduces code repetition and helps create related tests more efficiently.
A data provider function is implemented as follows:
::: code-group
```bash [Example]
function provider_function() {
bashunit::data_set "one"
bashunit::data_set "two" "three"
bashunit::data_set "value containing spaces"
bashunit::data_set "" "first value is empty"
}
```
:::
> **Note**: The previous variant of using `echo` to define data within a data
> provider is still supported but deprecated, as it does not support empty values or
> values containing spaces. Prefer using the `bashunit::data_set` function going forward.
## Interpolating arguments in test names
You can reference the values provided by a data provider directly in the test
function name using placeholders like `::1::`, `::2::`, ... matching the
argument position.
::: code-group
```bash [example_test.sh]
# @data_provider fizz_numbers
function test_returns_fizz_when_multiple_of_::1::_like_::2::_given() {
# ...
}
function fizz_numbers() {
bashunit::data_set 3 4
bashunit::data_set 3 6
}
```
```[Output]
Running example_test.sh
✓ Passed: Returns fizz when multiple of '3' like '4' given
✓ Passed: Returns fizz when multiple of '3' like '6' given
```
:::
## Multiple args in one call
::: code-group
```bash [example_test.sh]
# @data_provider provider_directories
function test_directories_exists() {
local dir1=$1
local dir2=$2
local dir3=$3
assert_directory_exists "$dir1"
assert_directory_exists "$dir2"
assert_directory_exists "$dir3"
}
function provider_directories() {
bashunit::data_set "/usr" "/etc" "/var"
}
```
```[Output]
Running example_test.sh
✓ Passed: Directories exists ('/usr', '/etc', '/var')
```
:::
## Single arg in multiple calls
::: code-group
```bash [example_test.sh]
# @data_provider provider_directories
function test_directory_exists() {
local directory=$1
assert_directory_exists "$directory"
}
function provider_directories() {
bashunit::data_set "/usr"
bashunit::data_set "/etc"
bashunit::data_set "/var"
}
```
```[Output]
Running example_test.sh
✓ Passed: Directory exists ('/usr')
✓ Passed: Directory exists ('/etc')
✓ Passed: Directory exists ('/var')
```
:::
## Multiple args in multiple calls
::: code-group
```bash [example_test.sh]
# @data_provider provider_directories
function test_directory_exists() {
local outro=$1
local directory=$2
assert_equals "outro" "$outro"
assert_directory_exists "$directory"
}
function provider_directories() {
bashunit::data_set "outro" "/usr"
bashunit::data_set "outro" "/etc"
bashunit::data_set "outro" "/var"
}
```
```[Output]
Running example_test.sh
✓ Passed: Directory exists ('outro', '/usr')
✓ Passed: Directory exists ('outro', '/etc')
✓ Passed: Directory exists ('outro', '/var')
```
:::
## Related
- [Test files](/test-files) — `set_up` and `tear_down` lifecycle hooks
- [Assertions](/assertions) — the built-in assertion reference
- [Common patterns](/common-patterns) — real-world testing patterns
---
# Snapshots
Snapshot testing is valuable for verifying the output of commands or scripts over time.
By capturing and comparing the "snapshot" of the output at different stages,
you can easily spot unintended changes or regressions.
This way, it helps maintain the expected behavior while modifications are being made,
making the verification process more efficient and reliable.
## assert_match_snapshot
> `assert_match_snapshot "actual"`
Reports an error if `actual` does not match the existing snapshot file associated with the current test function.
If no such file exists, a new one is created with the provided value.
::: tip
You can update the snapshot by deleting it and running its test again.
:::
::: code-group
```bash [Example]
function test_success() {
assert_match_snapshot "$(ls)"
}
function test_failure() {
assert_match_snapshot "$(date)"
}
```
```[First run]
Running snapshot_test.sh
✎ Snapshot: Success
✎ Snapshot: Failure
Tests: 2 snapshot, 2 total
Assertions: 2 snapshot, 2 total
Some snapshots created
```
```[Subsequent runs]
Running snapshot_test.sh
✓ Passed: Success
✗ Failed: Failure
Expected to match the snapshot
Mon Jul 27 [-13:37:46-]{+13:37:49+} UTC 1987
Tests: 1 passed, 1 failed, 2 total
Assertions: 1 passed, 1 failed, 2 total
Some tests failed
```
:::
::: warning
You need to run the tests for this example twice to see them work.
The first time you run them, the snapshots will be generated and the second time they will be asserted.
:::
## assert_match_snapshot_ignore_colors
> `assert_match_snapshot_ignore_colors "actual"`
Like `assert_match_snapshot` but ANSI escape codes in `actual` are ignored. This allows
verifying the output text while disregarding its style.
::: code-group
```bash [Example]
function test_success() {
assert_match_snapshot_ignore_colors "$(printf '\e[31mHello\e[0m World!')"
}
function test_failure() {
assert_match_snapshot_ignore_colors "World"
}
```
:::
## Placeholders
Snapshot files can contain placeholder tokens to ignore variable parts of the output.
By default the token `::ignore::` will match any text. You can override it with the
`BASHUNIT_SNAPSHOT_PLACEHOLDER` environment variable.
```bash [Example]
# snapshot file content
echo 'Run at ::ignore::' > snapshots/example.snapshot
# test
assert_match_snapshot "Run at $(date)"
```
## Related
- [Assertions](/assertions) — the built-in assertion reference
- [Configuration](/configuration) — env vars like `BASHUNIT_SNAPSHOT_PLACEHOLDER`
- [Test doubles](/test-doubles) — mocks and spies for isolated tests
---
# Skipping and incomplete tests
There may be various scenarios where the "passed" and "failed" outcomes for a test are not sufficient.
To address these situations, the following functions are available for your use.
## bashunit::skip
> `bashunit::skip "[reason]"`
Not all tests can be run in every environment; when such situations arise, you can mark a test as skipped.
It reports that the test has been skipped, including the `[reason]` if one was specified.
Skipping tests will not cause **bashunit** to exit with an error code;
however, it will indicate that some tests were skipped in the final output.
::: code-group
```bash [Example]
function test_skipped() {
if [[ $OS != "GEOS" ]]; then
bashunit::skip && return
fi
assert_empty "not reached"
}
function test_skipped_with_reason() {
if [[ $OS != "GEOS" ]]; then
bashunit::skip "Not running under Commodore" && return
fi
assert_empty "not reached"
}
```
```[Output]
↷ Skipped: Skipped
↷ Skipped: Skipped with reason
Not running under Commodore
Tests: 2 skipped, 2 total
Assertions: 2 skipped, 2 total
Some tests skipped
```
:::
## bashunit::todo
> `bashunit::todo "[pending]"`
You may come up with a test that you'd like to implement later.
Instead of leaving the test implementation empty —which would mark the test as complete— you can flag it as incomplete.
Reports that the test is incomplete as it is under development, including any `[pending]` to do details if specified.
Incomplete tests will not cause **bashunit** to exit with an error code;
however, it will indicate that some tests were incomplete in the final output.
::: code-group
```bash [Example]
function test_incomplete() {
bashunit::todo
}
function test_incomplete_with_pending_details() {
bashunit::todo "Detailed description of what needs to be done"
}
```
```[Output]
✒ Incomplete: Incomplete
✒ Incomplete: Incomplete with pending details
Detailed description of what needs to be done
Tests: 2 incomplete, 2 total
Assertions: 2 incomplete, 2 total
Some tests incomplete
```
:::
## Related
- [Assertions](/assertions) — the built-in assertion reference
- [Test files](/test-files) — test discovery and lifecycle hooks
- [Command line](/command-line) — CLI flags and options
---
# Test files
**bashunit** discovers, names, and runs test files through a set of conventions and helpers.
In this section, you'll find information about these features along with some helpful tips.
## Test file names
**bashunit** is flexible about how you name your test files.
You can use a directory name, and bashunit will look for all files (ending with `test.sh` or `test.bash`) recursively inside that directory, and execute them.
If you're using wildcards for scanning your tests, keep in mind that the initial search can slow down if you don't filter the test files in the wildcard.
To optimize this, we recommend adding a `test` prefix or suffix to your test file names, and include this identifier in your wildcard pattern too (e.g., `**/*test.sh` or `**/*test.bash`).
This naming convention not only speeds up the scanning process but also helps you keep your test files organized.
This is useful regardless of whether your test files are located near your production code or share directories with your [mocks](/test-doubles), stubs, or fixtures.
## Test function names
**bashunit** will search for and execute all test functions it finds within each test file.
To distinguish test functions from auxiliary functions, the names must be prefixed with the word `test`.
The function names are case-insensitive.
Below are some example test function names that would work seamlessly:
::: code-group
```bash [Example]
function test_should_validate_an_ok_exit_code() { ... }
function testRenderAllTestsPassedWhenNotFailedTests { ... }
test_getFunctionsToRun_with_filter_should_return_matching_functions() { ... }
```
:::
::: tip
You're free to use any of Bash's syntax options to define these functions.
:::
## Custom test titles
By default, **bashunit** derives the name shown in reports from the test function name.
If you need a more descriptive title, you can override it inside the test using `set_test_title`:
::: code-group
```bash [Example]
function test_handles_invalid_input() {
set_test_title "🔥 handles custom test names 🚀"
# test logic...
}
```
:::
The provided title is used only for display purposes. The original function name is still
used internally, and custom titles are reset automatically after each test.
## `set_up` function
The `set_up` auxiliary function is called, if it is present in the test file, before each test function in the test file is executed.
This provides a hook to prepare the environment or set initial variables specific to each test case.
For example, you might want to create temporary directories or files that your test will manipulate.
::: code-group
```bash [Example]
function set_up() {
touch temp_file.txt
}
```
:::
## `tear_down` function
The `tear_down` auxiliary function is called, if it is present in the test file, immediately after each test function in the test file is executed.
This auxiliary function offers you a place to clean up any resources allocated or changes made during the `set_up` or test function itself.
This helps to ensure that each test starts with a fresh state.
::: code-group
```bash [Example]
function tear_down() {
rm temp_file.txt
}
```
:::
## `set_up_before_script` function
The `set_up_before_script` auxiliary function is called, if it is present in the test file, only once before all tests functions in the test file begin.
This is useful for global setup that applies to all test functions in the script, such as loading shared resources.
During test execution, bashunit displays the hook execution with its duration, right-aligned to match test output:
```
Running tests/example_test.sh
● set_up_before_script 2.03s
✓ Passed: test_example 12ms
```
This visibility helps identify slow setup operations that may impact test run time.
If `set_up_before_script` fails — any failing command, or the function returning a non-zero status (watch out for a trailing `cmd && var=value` guard: when `cmd` fails, the guard is the hook's return value) — bashunit reports the hook error, marks **every test in the file as failed** (they are included in the totals), and continues with the next test file. The rest of the suite always runs, and the failure is attributed to the hook rather than surfacing as mysterious individual test errors. If you want a missing optional dependency to skip tests instead of failing them, end the hook with an explicit success, e.g. `command -v jq >/dev/null 2>&1 && HAS_JQ=true; return 0` — then call `bashunit::skip` inside the tests.
::: code-group
```bash [Example]
function set_up_before_script() {
open_database_connection
}
```
:::
## `tear_down_after_script` function
The `tear_down_after_script` auxiliary function is called, if it is present in the test file, only once when all the test functions in the test file have been executed.
This auxiliary function is similar to how `set_up_before_script` works but at the end of the tests.
It provides a hook for any cleanup that should occur after all tests have run, such as deleting temporary files or releasing resources.
Like `set_up_before_script`, the execution is displayed with its duration:
```
✓ Passed: test_example 12ms
● tear_down_after_script 1.05s
Tests: 1 passed, 1 total
```
Failures inside `tear_down_after_script` are also surfaced as dedicated errors after the final test output so cleanup problems (for example, missing tools or permissions) are visible in the run summary.
::: code-group
```bash [Example]
function tear_down_after_script() {
close_database_connection
}
```
:::
## Syntax errors in test files
If a test file contains a Bash syntax error, **bashunit** records a failing
test for that file instead of silently skipping the remaining tests. The exact
error message from Bash (including file path and line number) is shown in the
summary, and the suite exits with a non-zero status.
```
Running tests/example_test.sh
✗ Error: Source
tests/example_test.sh: line 10: syntax error near unexpected token `fi'
tests/example_test.sh: line 10: ` fi'
Tests: 1 failed, 1 total
Some tests failed
```
This guarantees a broken test file always fails the suite, so it never
passes by absence.
## Related
- [Command line](/command-line) — discover and run test files from the terminal
- [Configuration](/configuration) — set the default test path and bootstrap file
- [Assertions](/assertions) — assertions to use inside your test functions
- [Common patterns](/common-patterns) — real-world testing patterns
---
# Globals
These global helper functions exist primarily to improve your developer experience.
All helper functions use the `bashunit::` namespace prefix to prevent naming collisions with your own code.
## bashunit::log
Write into the `BASHUNIT_DEV_LOG` a log message. The log line records the source file and line number for easier debugging.
> See: [Dev log](/configuration#dev-log)
```bash
bashunit::log "hello" "world" # default level: info
bashunit::log "info" "hello" "world"
bashunit::log "debug" "hello" "world"
bashunit::log "warning" "hello" "world"
bashunit::log "critical" "hello" "world"
bashunit::log "error" "hello" "world"
```
Internal messages from bashunit include the `[INTERNAL]` prefix so you can easily spot them. You can enable them with `BASHUNIT_INTERNAL_LOG=true|false`.
## bashunit::current_dir
> `bashunit::current_dir`: Gets the current directory name.
## bashunit::current_filename
> `bashunit::current_filename`: Gets the current filename.
## bashunit::caller_filename
> `bashunit::caller_filename`: Gets the caller filename.
## bashunit::caller_line
> `bashunit::caller_line`: Gets the line number of the caller.
Useful inside custom assertions to report the line that triggered the failure.
## bashunit::current_timestamp
> `bashunit::current_timestamp`: Gets the current timestamp.
## bashunit::random_str
> `bashunit::random_str `: generate a random string
## bashunit::temp_file
> `bashunit::temp_file `: creates a temporal file
The file is automatically deleted when bashunit completes.
## bashunit::temp_dir
> `bashunit::temp_dir `: creates a temporal directory
The directory is automatically deleted when bashunit completes.
## bashunit::is_command_available
> `bashunit::is_command_available `: Checks if a command is available in `PATH`.
Returns `0` when the command is found, `1` otherwise.
```bash
if bashunit::is_command_available jq; then
# jq-based assertions
fi
```
## bashunit::print_line
> `bashunit::print_line `: Prints a horizontal separator.
Defaults to 70 characters of `-`. Both arguments are optional.
```bash
bashunit::print_line # 70 dashes
bashunit::print_line 40 '=' # 40 equals signs
```
## Custom assertion helpers
These helpers are intended for building [custom assertions](/custom-asserts).
- `bashunit::assertion_passed` — Mark the current assertion as passed.
- `bashunit::assertion_failed ` — Mark the current
assertion as failed and print a failure report.
- `bashunit::fail ` — Fail the current test with an optional message.
See [Custom asserts](/custom-asserts) for full examples.
## Test doubles
The `bashunit::spy`, `bashunit::mock`, and `bashunit::unmock` helpers are
documented in [Test doubles](/test-doubles).
## Related
- [Custom asserts](/custom-asserts) — build your own assertions with these helpers
- [Assertions](/assertions) — the built-in assertion reference
- [Test doubles](/test-doubles) — spies and mocks
- [Configuration](/configuration) — configure the dev log used by `bashunit::log`
---
# Common Patterns
This guide shows real-world testing patterns to help you write effective tests for your bash scripts. Each pattern includes practical examples and explains when to use it.
## Table of Contents
- [Testing Functions vs Scripts](#testing-functions-vs-scripts)
- [Setup and Teardown](#setup-and-teardown)
- [Testing Exit Codes](#testing-exit-codes)
- [Testing File Operations](#testing-file-operations)
- [Testing Scripts with Input](#testing-scripts-with-input)
- [Testing Error Conditions](#testing-error-conditions)
- [Testing External Dependencies](#testing-external-dependencies)
- [Testing Output Formats](#testing-output-formats)
- [Organizing Large Test Suites](#organizing-large-test-suites)
- [Testing Private Functions](#testing-private-functions)
- [Parameterized Testing](#parameterized-testing)
## Testing Functions vs Scripts
### Testing Individual Functions
When you have a script with functions you want to test individually, source the script and test each function:
::: code-group
```bash [src/calculator.sh]
#!/usr/bin/env bash
function add() {
echo $(($1 + $2))
}
function multiply() {
echo $(($1 * $2))
}
```
```bash [tests/calculator_test.sh]
#!/usr/bin/env bash
function set_up() {
source "src/calculator.sh"
}
function test_add_two_positive_numbers() {
assert_same "5" "$(add 2 3)"
}
function test_add_negative_numbers() {
assert_same "-5" "$(add -2 -3)"
}
function test_multiply() {
assert_same "6" "$(multiply 2 3)"
}
```
:::
### Testing Complete Scripts
When testing a script that executes commands directly, treat it as an executable:
::: code-group
```bash [src/deploy.sh]
#!/usr/bin/env bash
set -euo pipefail
environment=${1:-staging}
echo "Deploying to $environment..."
# deployment logic here
echo "Deployment complete!"
```
```bash [tests/deploy_test.sh]
#!/usr/bin/env bash
function test_deploy_with_default_environment() {
local output
output=$(bash src/deploy.sh)
assert_contains "Deploying to staging" "$output"
assert_contains "Deployment complete" "$output"
}
function test_deploy_with_custom_environment() {
local output
output=$(bash src/deploy.sh production)
assert_contains "Deploying to production" "$output"
}
```
:::
## Setup and Teardown
### Using set_up and tear_down
Use lifecycle hooks to prepare test environments and clean up afterward:
::: code-group
```bash [tests/database_test.sh]
#!/usr/bin/env bash
function set_up() {
# Runs before each test
export TEST_DB="/tmp/test_db_$$"
mkdir -p "$TEST_DB"
source "src/database.sh"
}
function tear_down() {
# Runs after each test
rm -rf "$TEST_DB"
}
function test_create_table() {
create_table "users"
assert_file_exists "$TEST_DB/users.txt"
}
function test_insert_record() {
create_table "users"
insert_record "users" "john@example.com"
assert_file_contains "john@example.com" "$TEST_DB/users.txt"
}
```
:::
### Using set_up_before_script and tear_down_after_script
Use these for expensive operations that only need to run once per file:
::: code-group
```bash [tests/integration_test.sh]
#!/usr/bin/env bash
function set_up_before_script() {
# Runs once before all tests in this file
export TEST_SERVER_PID
./scripts/start_test_server.sh &
TEST_SERVER_PID=$!
sleep 2 # Wait for server to start
}
function tear_down_after_script() {
# Runs once after all tests in this file
kill "$TEST_SERVER_PID" 2>/dev/null || true
}
function test_server_responds_to_ping() {
assert_successful_code "curl -s http://localhost:8080/ping"
}
function test_server_returns_json() {
local response
response=$(curl -s http://localhost:8080/api/data)
assert_contains '"status":"ok"' "$response"
}
```
:::
## Testing Exit Codes
Use `assert_exec` to run a command and check its exit code, stdout, or stderr in one step. Alternatively, run the command first and then use `assert_successful_code` or `assert_exit_code` to check the result of the previous command.
### Testing Successful Execution
::: code-group
```bash [tests/validation_test.sh]
#!/usr/bin/env bash
function test_valid_email_returns_success() {
assert_exec "./src/validate_email.sh user@example.com"
}
function test_valid_email_returns_success_alternative() {
./src/validate_email.sh user@example.com
assert_successful_code
}
function test_backup_succeeds() {
assert_exec "./src/backup.sh --dry-run" --exit 0
}
function test_backup_succeeds_alternative() {
./src/backup.sh --dry-run
assert_exit_code "0"
}
```
:::
### Testing Failure Cases
::: code-group
```bash [tests/validation_test.sh]
#!/usr/bin/env bash
function test_invalid_email_returns_error() {
assert_exec "./src/validate_email.sh invalid-email" --exit 1
}
function test_invalid_email_returns_error_alternative() {
./src/validate_email.sh invalid-email
assert_general_error
}
function test_missing_file_returns_specific_code() {
assert_exec "./src/process_file.sh /nonexistent/file.txt" --exit 127
}
function test_missing_file_returns_specific_code_alternative() {
./src/process_file.sh /nonexistent/file.txt
assert_exit_code "127"
}
```
:::
## Testing File Operations
### Testing File Creation and Content
::: code-group
```bash [tests/logger_test.sh]
#!/usr/bin/env bash
function set_up() {
export TEST_LOG="/tmp/test_log_$$_$RANDOM.log"
source "src/logger.sh"
}
function tear_down() {
rm -f "$TEST_LOG"
}
function test_log_creates_file() {
log_message "Test message" "$TEST_LOG"
assert_file_exists "$TEST_LOG"
}
function test_log_writes_timestamp_and_message() {
log_message "Error occurred" "$TEST_LOG"
assert_file_contains "Error occurred" "$TEST_LOG"
# Check for timestamp pattern (YYYY-MM-DD HH:MM:SS)
assert_matches "[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}" "$TEST_LOG"
}
function test_log_appends_multiple_messages() {
log_message "First message" "$TEST_LOG"
log_message "Second message" "$TEST_LOG"
local line_count
line_count=$(wc -l < "$TEST_LOG")
assert_same "2" "$line_count"
}
```
:::
### Testing Directory Operations
::: code-group
```bash [tests/directory_test.sh]
#!/usr/bin/env bash
function set_up() {
export TEST_DIR="/tmp/bashunit_test_$$"
source "src/file_manager.sh"
}
function tear_down() {
rm -rf "$TEST_DIR"
}
function test_create_directory_structure() {
create_project_structure "$TEST_DIR"
assert_directory_exists "$TEST_DIR"
assert_directory_exists "$TEST_DIR/src"
assert_directory_exists "$TEST_DIR/tests"
assert_directory_exists "$TEST_DIR/docs"
}
function test_cleanup_removes_old_files() {
mkdir -p "$TEST_DIR"
touch "$TEST_DIR/old_file.txt"
touch "$TEST_DIR/new_file.txt"
# Backdate old_file
touch -t 202301010000 "$TEST_DIR/old_file.txt"
cleanup_old_files "$TEST_DIR" 365
assert_file_not_exists "$TEST_DIR/old_file.txt"
assert_file_exists "$TEST_DIR/new_file.txt"
}
```
:::
## Testing Scripts with Input
### Testing with Command Line Arguments
::: code-group
```bash [tests/cli_test.sh]
#!/usr/bin/env bash
function test_help_flag_shows_usage() {
local output
output=$(./src/cli.sh --help)
assert_contains "Usage:" "$output"
assert_contains "Options:" "$output"
}
function test_multiple_flags() {
local output
output=$(./src/cli.sh --verbose --output /tmp/test.log process)
assert_contains "Verbose mode enabled" "$output"
}
```
:::
### Testing with Piped Input
::: code-group
```bash [tests/filter_test.sh]
#!/usr/bin/env bash
function test_filter_removes_empty_lines() {
local input="line1
line2
line3"
local output
output=$(echo "$input" | ./src/filter.sh --remove-empty)
local line_count
line_count=$(echo "$output" | wc -l)
assert_same "3" "$line_count"
}
function test_grep_pattern() {
local output
output=$(echo -e "error: failed\ninfo: started\nerror: crashed" | ./src/filter.sh error)
assert_contains "failed" "$output"
assert_contains "crashed" "$output"
assert_not_contains "started" "$output"
}
```
:::
### Testing with Here-Documents
::: code-group
```bash [tests/parser_test.sh]
#!/usr/bin/env bash
function test_parse_multiline_config() {
local output
output=$(./src/config_parser.sh <&1 || true)
assert_contains "Error: Missing required argument" "$output"
}
function test_invalid_option() {
local output
output=$(./src/backup.sh --invalid-option 2>&1 || true)
assert_contains "Unknown option" "$output"
assert_exit_code 1 "./src/backup.sh --invalid-option"
}
```
:::
### Testing set -e Behavior
::: code-group
```bash [tests/strict_mode_test.sh]
#!/usr/bin/env bash
function test_script_fails_on_error() {
# Scripts with 'set -e' should exit on first error
local exit_code=0
./src/strict_script.sh || exit_code=$?
assert_not_equals "0" "$exit_code"
}
function test_error_handled_gracefully() {
# Script should catch and handle expected errors
assert_successful_code "./src/resilient_script.sh"
}
```
:::
## Testing External Dependencies
### Mocking External Commands
::: code-group
```bash [tests/git_wrapper_test.sh]
#!/usr/bin/env bash
function set_up() {
source "src/git_wrapper.sh"
}
function test_get_current_branch() {
bashunit::mock git echo "feature/new-feature"
local branch
branch=$(get_current_branch)
assert_same "feature/new-feature" "$branch"
}
function test_check_for_changes() {
bashunit::mock git <&2
return 128
}
export -f git
local exit_code=0
check_git_status || exit_code=$?
assert_equals "128" "$exit_code"
}
```
:::
### Using Spies to Verify Calls
::: code-group
```bash [tests/deployment_test.sh]
#!/usr/bin/env bash
function set_up() {
source "src/deploy.sh"
}
function test_deployment_calls_docker_push() {
bashunit::spy docker
deploy_image "myapp:latest"
assert_have_been_called docker
}
function test_docker_called_with_correct_arguments() {
bashunit::spy docker
deploy_image "myapp:v1.0.0"
assert_have_been_called_with docker "push myapp:v1.0.0"
}
function test_deploy_calls_docker_twice() {
bashunit::spy docker
deploy_image "myapp:latest"
deploy_image "myapp:v1.0.0"
assert_have_been_called_times 2 docker
}
```
:::
## Testing Output Formats
### Testing JSON Output
::: code-group
```bash [tests/json_test.sh]
#!/usr/bin/env bash
function test_json_contains_expected_fields() {
local output
output=$(./src/generate_report.sh --format json)
assert_contains '"status"' "$output"
assert_contains '"timestamp"' "$output"
assert_contains '"data"' "$output"
}
function test_json_is_valid() {
local output
output=$(./src/generate_report.sh --format json)
# Use jq to validate JSON (mock it if jq not available)
assert_successful_code "echo '$output' | jq . > /dev/null"
}
```
:::
### Testing Colored Output
::: code-group
```bash [tests/colors_test.sh]
#!/usr/bin/env bash
function test_colored_output_contains_escape_codes() {
local output
output=$(./src/print_status.sh --color)
# Check for ANSI color codes
assert_matches '\[3[0-9]m' "$output"
}
function test_no_color_flag_removes_colors() {
local output
output=$(./src/print_status.sh --no-color)
# Should not contain ANSI color codes
assert_not_matches '\[3[0-9]m' "$output"
}
```
:::
### Testing Table Output
::: code-group
```bash [tests/table_test.sh]
#!/usr/bin/env bash
function test_table_has_headers() {
local output
output=$(./src/list_users.sh)
assert_contains "Name" "$output"
assert_contains "Email" "$output"
assert_contains "Status" "$output"
}
function test_table_formatting() {
local output
output=$(./src/list_users.sh)
# Check for separator line (dashes)
assert_matches '[-]+' "$output"
}
```
:::
## Organizing Large Test Suites
### Grouping Related Tests
Organize tests by feature or component:
```
tests/
├── unit/
│ ├── parser_test.sh
│ ├── validator_test.sh
│ └── formatter_test.sh
├── integration/
│ ├── api_test.sh
│ └── database_test.sh
├── functional/
│ ├── user_workflow_test.sh
│ └── admin_workflow_test.sh
└── helpers/
└── test_helpers.sh
```
### Creating Test Helpers
::: code-group
```bash [tests/helpers/test_helpers.sh]
#!/usr/bin/env bash
# Create a temporary test database
function create_test_db() {
local db_path="/tmp/test_db_$$_$RANDOM"
mkdir -p "$db_path"
echo "$db_path"
}
# Clean up test database
function cleanup_test_db() {
local db_path=$1
rm -rf "$db_path"
}
# Create a test user
function create_test_user() {
local name=$1
local email=$2
echo "$name,$email,active" >> "$TEST_DB/users.csv"
}
```
```bash [tests/unit/user_test.sh]
#!/usr/bin/env bash
function set_up() {
source "tests/helpers/test_helpers.sh"
source "src/user.sh"
export TEST_DB
TEST_DB=$(create_test_db)
}
function tear_down() {
cleanup_test_db "$TEST_DB"
}
function test_create_user() {
create_test_user "John Doe" "john@example.com"
assert_file_contains "John Doe" "$TEST_DB/users.csv"
}
```
:::
### Using Environment Bootstrap Files
::: code-group
```bash [tests/bootstrap.sh]
#!/usr/bin/env bash
# Set test environment variables
export TEST_MODE=true
export LOG_LEVEL=debug
export CONFIG_PATH=/tmp/test_config
# Load common test utilities
source "tests/helpers/test_helpers.sh"
# Setup test database connection
export DB_HOST=localhost
export DB_PORT=5432
export DB_NAME=test_db
```
```bash [Run with bootstrap]
# Run tests with bootstrap file
./bashunit --env tests/bootstrap.sh tests/
```
:::
### Bootstrap Files with Arguments
Pass configuration to your bootstrap file for different test scenarios:
::: code-group
```bash [tests/bootstrap.sh]
#!/usr/bin/env bash
# Receive arguments passed to bootstrap
ENVIRONMENT="${1:-production}"
VERBOSE="${2:-false}"
# Configure based on environment
case "$ENVIRONMENT" in
staging)
export API_URL="https://staging.api.example.com"
export DB_NAME="test_staging_db"
;;
ci)
export API_URL="https://ci.api.example.com"
export DB_NAME="test_ci_db"
;;
*)
export API_URL="https://api.example.com"
export DB_NAME="test_db"
;;
esac
[[ "$VERBOSE" == "true" ]] && export LOG_LEVEL=debug
```
```bash [Run with arguments]
# Pass arguments inline with --env
./bashunit --env "tests/bootstrap.sh staging true" tests/
# Or via environment variable
BASHUNIT_BOOTSTRAP_ARGS="staging true" ./bashunit tests/
```
:::
::: tip
Use bootstrap arguments to avoid duplicating bootstrap files for different
environments. A single bootstrap file can configure staging, CI, or local
development based on the arguments passed.
:::
## Testing Private Functions
When you need to test functions that aren't exported:
::: code-group
```bash [src/processor.sh]
#!/usr/bin/env bash
# Private helper function
function _validate_input() {
[[ -n $1 ]] && [[ $1 =~ ^[0-9]+$ ]]
}
# Public function
function process_number() {
if _validate_input "$1"; then
echo "Processing: $1"
else
echo "Invalid input" >&2
return 1
fi
}
```
```bash [tests/processor_test.sh]
#!/usr/bin/env bash
function set_up() {
source "src/processor.sh"
}
# Test private function directly after sourcing
function test_private_validate_input_accepts_numbers() {
assert_successful_code "_validate_input 123"
}
function test_private_validate_input_rejects_text() {
assert_general_error "_validate_input abc"
}
# Test through public interface
function test_process_number_with_valid_input() {
assert_contains "Processing: 42" "$(process_number 42)"
}
function test_process_number_with_invalid_input() {
local output
output=$(process_number "invalid" 2>&1 || true)
assert_contains "Invalid input" "$output"
}
```
:::
## Parameterized Testing
### Using Data Providers
::: code-group
```bash [tests/validation_test.sh]
#!/usr/bin/env bash
function set_up() {
source "src/validator.sh"
}
function data_provider_valid_emails() {
echo "user@example.com"
echo "test.user@example.co.uk"
echo "user+tag@example.org"
}
function test_valid_email_formats() {
assert_successful_code "validate_email '$1'"
}
function data_provider_invalid_emails() {
echo "invalid-email"
echo "@example.com"
echo "user@"
echo "user name@example.com"
}
function test_invalid_email_formats() {
assert_general_error "validate_email '$1'"
}
```
:::
### Testing Multiple Scenarios
::: code-group
```bash [tests/calculator_test.sh]
#!/usr/bin/env bash
function set_up() {
source "src/calculator.sh"
}
function data_provider_addition_cases() {
echo "2 3 5"
echo "0 0 0"
echo "-1 1 0"
echo "100 200 300"
echo "-5 -5 -10"
}
function test_addition() {
local a=$1
local b=$2
local expected=$3
local result
result=$(add "$a" "$b")
assert_same "$expected" "$result"
}
```
:::
## Best Practices Summary
1. **Keep tests independent**: Each test should run successfully in isolation
2. **Use descriptive names**: Test names should clearly describe what they test
3. **Follow AAA pattern**: Arrange, Act, Assert
4. **Clean up resources**: Always clean up temporary files and processes
5. **Test both success and failure**: Don't just test the happy path
6. **Use mocks wisely**: Mock external dependencies but don't over-mock
7. **One assertion per test**: When possible, focus each test on a single behavior
8. **Use lifecycle hooks**: Leverage `set_up` and `tear_down` for common setup
9. **Organize logically**: Group related tests in the same file or directory
10. **Document complex tests**: Add comments explaining why you're testing something unusual
## Next Steps
- Explore [Test Doubles](/test-doubles) for advanced mocking and spying
- Learn about [Data Providers](/data-providers) for parameterized testing
- Check out [Snapshots](/snapshots) for testing complex output
- Read about [Custom Asserts](/custom-asserts) for domain-specific testing
- Browse the [Assertions](/assertions) reference for every available check
- Use [Standalone](/standalone) assertions outside of test files
---
# Command line
**bashunit** uses a subcommand-based CLI. Each command has its own options and behavior.
## Quick Reference
```bash
bashunit test [path] [options] # Run tests (default)
bashunit bench [path] [options] # Run benchmarks
bashunit watch [path] [options] # Watch files, re-run tests on change
bashunit assert # Run standalone assertion
bashunit doc [filter] # Show assertion documentation
bashunit init [dir] # Initialize test directory
bashunit learn # Interactive tutorial
bashunit upgrade # Upgrade to latest version
bashunit --help # Show help
bashunit --version # Show version
```
## Argument Notation
| Syntax | Meaning |
|----------|------------------------------------------|
| `` | Required - must be provided |
| `[arg]` | Optional - can be omitted (uses default) |
## test
> `bashunit test [path] [options]`
> `bashunit [path] [options]`
Run test files. This is the default command - you can omit `test` for convenience.
::: code-group
```bash [Examples]
# Run all tests in directory
bashunit test tests/
# Shorthand (same as above)
bashunit tests/
# Run specific test file
bashunit test tests/unit/example_test.sh
# Run with filter
bashunit test tests/ --filter "user"
# Run with options
bashunit test tests/ --parallel --simple
```
:::
### Test Options
| Option | Description |
|--------------------------------|--------------------------------------------------|
| `-a, --assert ` | Run a standalone assert function |
| `-e, --env, --boot ` | Load custom env/bootstrap file (supports args) |
| `-f, --filter ` | Only run tests matching name |
| `--tag ` | Only run tests with matching `@tag` (repeatable) |
| `--exclude-tag ` | Skip tests with matching `@tag` (repeatable) |
| `--output ` | Output format (`tap` for TAP version 13) |
| `-w, --watch` | Watch files and re-run tests on change |
| `--log-junit, --report-junit ` | Write JUnit XML report |
| `--log-gha ` | Write GitHub Actions workflow-commands log |
| `-j, --jobs ` | Run tests in parallel with max N concurrent jobs (`auto` = CPU cores) |
| `-p, --parallel` | Run tests in parallel |
| `--no-parallel` | Run tests sequentially |
| `-r, --report-html ` | Write HTML report |
| `--report-tap ` | Write TAP version 13 report to a file |
| `--report-json ` | Write machine-readable JSON report to a file |
| `-R, --run-all` | Run all assertions (don't stop on first failure) |
| `-s, --simple` | Simple output (dots) |
| `--detailed` | Detailed output (default) |
| `-S, --stop-on-failure` | Stop on first failure |
| `--test-timeout ` | Fail a test if it runs longer than N seconds |
| `--retry ` | Re-run a failed test up to N extra times |
| `--random-order` | Randomize test execution order |
| `--seed ` | Seed for `--random-order` (reproducible shuffle) |
| `--shard /` | Run shard i of n (split suite across runners) |
| `--rerun-failed` | Replay only the tests that failed on the last run |
| `--show-skipped` | Show skipped tests summary at end |
| `--show-incomplete` | Show incomplete tests summary at end |
| `-vvv, --verbose` | Show execution details |
| `--debug [file]` | Enable shell debug mode |
| `--no-output` | Suppress all output |
| `--failures-only` | Only show failures |
| `--fail-on-risky` | Treat risky tests (no assertions) as failures |
| `--profile` | Report the slowest tests after a run |
| `--no-progress` | Suppress real-time progress, show only summary |
| `--show-output` | Show test output on failure (default) |
| `--no-output-on-failure` | Hide test output on failure |
| `--strict` | Enable strict shell mode |
| `--skip-env-file` | Skip `.env` loading, use shell environment only |
| `-l, --login` | Run tests in login shell context |
| `--no-color` | Disable colored output |
| `--coverage` | Enable code coverage tracking |
| `--coverage-paths ` | Paths to track (default: auto-discover) |
| `--coverage-exclude ` | Exclusion patterns |
| `--coverage-report [file]` | LCOV output path (default: `coverage/lcov.info`) |
| `--coverage-report-html [dir]` | Generate HTML report (default: `coverage/html`) |
| `--coverage-min ` | Minimum coverage threshold |
| `--no-coverage-report` | Console output only, no LCOV file |
### Standalone Assert
> `bashunit test -a|--assert function "arg1" "arg2"`
Run a core assert function standalone without a test context.
::: code-group
```bash [Example]
bashunit test --assert equals "foo" "bar"
```
```[Output]
✗ Failed: Main::exec assert
Expected 'foo'
but got 'bar'
```
:::
### Filter
> `bashunit test -f|--filter "test name"`
Run only tests matching the given name.
::: code-group
```bash [Example]
bashunit test tests/ --filter "user_login"
```
:::
### Tags
> `bashunit test --tag `
> `bashunit test --exclude-tag `
Filter tests by `# @tag` annotations. Both flags are repeatable. `--tag` uses OR
logic across names; `--exclude-tag` wins when a test matches both.
::: code-group
```bash [Annotate tests]
# @tag slow
function test_heavy_computation() {
...
}
# @tag integration
function test_api_call() {
...
}
```
```bash [Run by tag]
bashunit test tests/ --tag slow
bashunit test tests/ --tag slow --tag integration
bashunit test tests/ --exclude-tag integration
```
:::
### Output format
> `bashunit test --output `
Select an alternative output format. Currently supported:
- `tap` — [TAP version 13](https://testanything.org/tap-version-13-specification.html) for CI/CD integrations.
The `TAP version 13` header comes first, each test file is announced via a
`# ` diagnostic line, each test emits an `ok - ` or
`not ok - ` line (failures include a YAML `--- ... ...` block with
expected/actual), and the `1..N` plan line closes the report.
::: code-group
```bash [Example]
bashunit test tests/ --output tap
```
```[Output]
TAP version 13
# tests/example_test.sh
ok 1 - Should validate input
not ok 2 - Should handle errors
---
Expected 'foo'
but got 'bar'
...
1..2
```
:::
### Watch mode
> `bashunit test -w|--watch`
Watch the test path (plus `src/` if present) and re-run tests when files change.
The `-w`/`--watch` flag uses a lightweight **checksum polling loop** that works
on any system — no external tools required.
::: code-group
```bash [Example]
bashunit test tests/ --watch
```
:::
::: tip
For file-event-driven watching (no polling), use the dedicated
[`watch`](#watch) subcommand, which relies on `inotifywait` (Linux) or
`fswatch` (macOS).
:::
### Environment / Bootstrap
> `bashunit test -e|--env|--boot `
> `bashunit test --env "file arg1 arg2"`
Load a custom environment or bootstrap file before running tests.
::: code-group
```bash [Basic usage]
bashunit test --env tests/bootstrap.sh tests/
```
```bash [With arguments]
# Pass arguments to the bootstrap file
bashunit test --env "tests/bootstrap.sh staging verbose" tests/
```
:::
Arguments are available as positional parameters (`$1`, `$2`, etc.) in your bootstrap script:
```bash
#!/usr/bin/env bash
# tests/bootstrap.sh
ENVIRONMENT="${1:-production}"
VERBOSE="${2:-false}"
export API_URL="https://${ENVIRONMENT}.api.example.com"
```
You can also set arguments via environment variable:
```bash
BASHUNIT_BOOTSTRAP_ARGS="staging verbose" bashunit test tests/
```
See [Configuration: Bootstrap](/configuration#bootstrap) for more details.
### Inline Filter Syntax
You can also specify a filter directly in the file path using `::` or `:line` syntax:
**Run a specific test by function name:**
> `bashunit test path::function_name`
::: code-group
```bash [Exact match]
bashunit test tests/unit/example_test.sh::test_user_login
```
```bash [Partial match]
# Runs all tests containing "user" in their name
bashunit test tests/unit/example_test.sh::user
```
:::
**Run the test at a specific line number:**
> `bashunit test path:line_number`
This is useful when jumping to a test from your editor or IDE.
::: code-group
```bash [Example]
bashunit test tests/unit/example_test.sh:42
```
:::
::: tip
The line number syntax finds the test function that contains the specified line. If the line is before any test function, an error is shown.
:::
### Parallel
> `bashunit test -p|--parallel`
> `bashunit test --no-parallel`
Run tests in parallel or sequentially. Sequential is the default.
In parallel mode, both test files and individual test functions run concurrently
for maximum performance.
::: warning
Parallel mode is supported on **macOS**, **Ubuntu**, **Alpine**, and **Windows**.
On other systems the option is automatically disabled due to inconsistent results.
:::
::: tip Opt-out of test-level parallelism
If a test file has shared state or race conditions, you can disable test-level
parallelism by adding this directive as the second line:
```bash
#!/usr/bin/env bash
# bashunit: no-parallel-tests
function test_with_shared_state() {
# This test will not run in parallel with others in this file
}
```
The file will still run in parallel with other files, but tests within it will
run sequentially.
:::
### Jobs
> `bashunit test -j|--jobs `
Run tests in parallel with a maximum of N concurrent jobs. This implicitly
enables parallel mode.
Use this to limit CPU usage on CI or machines with constrained resources.
Pass `auto` to cap concurrency at the number of detected CPU cores.
::: code-group
```bash [Example]
bashunit test tests/ --jobs 4
bashunit test tests/ --jobs auto
```
:::
::: tip
`--jobs 0` (the default) means unlimited concurrency, which is equivalent to
`--parallel`. `--jobs auto` caps at the detected CPU core count.
:::
### Output Style
> `bashunit test -s|--simple`
> `bashunit test --detailed`
Choose between simple (dots) or detailed output.
::: code-group
```bash [Simple]
bashunit test tests/ --simple
```
```[Output]
........
```
:::
::: code-group
```bash [Detailed]
bashunit test tests/ --detailed
```
```[Output]
Running tests/unit/example_test.sh
✓ Passed: Should validate input
✓ Passed: Should handle errors
```
:::
### Reports
Generate test reports in different formats:
::: code-group
```bash [JUnit XML]
bashunit test tests/ --log-junit results.xml
```
```bash [HTML Report]
bashunit test tests/ --report-html report.html
```
```bash [GitHub Actions]
# Stream annotations straight to the runner log:
bashunit test tests/ --log-gha /dev/stdout
```
```bash [JSON]
bashunit test tests/ --report-json report.json
```
:::
The `--log-gha` flag writes GitHub Actions workflow commands (`::error`, `::warning`, `::notice`) for failed, risky and incomplete tests, including the failing test's `file` and `line`. Point it at `/dev/stdout` (or stream a log file to stdout) on a runner and the failures appear as inline annotations in the "Files changed" tab of a pull request.
The `--report-json` flag writes machine-readable results for scripts, dashboards and bots. Strings are escaped in pure Bash, so no `jq` is needed to produce it. Its schema is:
```json
{
"summary": { "total": 3, "passed": 2, "failed": 1, "skipped": 0, "incomplete": 0, "duration_ms": 42 },
"tests": [
{ "file": "tests/math_test.sh", "name": "it adds", "status": "passed", "duration_ms": 5, "message": "" },
{ "file": "tests/math_test.sh", "name": "it divides", "status": "failed", "duration_ms": 3, "message": "Expected 2 but got 3" }
]
}
```
`status` is one of `passed`, `failed`, `skipped`, `incomplete` (`snapshot` and `risky` are also emitted per test and counted as passed in the summary). Like the other file reporters, per-test rows come from a sequential run; under `--parallel` the file is still valid JSON.
### Show Output on Failure
> `bashunit test --show-output`
> `bashunit test --no-output-on-failure`
Control whether test output (stdout/stderr) is displayed when tests fail with runtime errors or assertion failures.
By default (`--show-output`), when a test fails due to a runtime error (command not found,
unbound variable, permission denied, etc.) or a failed assertion after the test printed
diagnostics, bashunit displays the captured output in an "Output:" section to help debug
the failure.
Use `--no-output-on-failure` to suppress this output.
::: code-group
```bash [Example]
bashunit test tests/ --no-output-on-failure
```
```[Output with --show-output (default)]
✗ Error: My test function
command not found
Output:
Debug: Setting up test
Running command: my_command
/path/to/test.sh: line 5: my_command: command not found
```
:::
### Profile
> `bashunit test --profile`
Report the slowest tests after a run. Each test's wall-clock duration is recorded
and, once the run finishes, the slowest ones are printed sorted from slowest to
fastest. Works in both sequential and parallel mode.
The number of entries shown defaults to `10` and can be changed with the
`BASHUNIT_PROFILE_COUNT` environment variable.
::: code-group
```bash [Example]
bashunit test tests/ --profile
```
```[Output]
Tests: 10 passed, 10 total
Assertions: 25 passed, 25 total
All tests passed
Slowest tests:
1.20s test_slow_database_query (tests/integration_test.sh)
340ms test_http_client_timeout (tests/http_test.sh)
12ms test_parse_config (tests/unit/config_test.sh)
Time taken: 1.60s
```
```bash [Custom count]
BASHUNIT_PROFILE_COUNT=3 bashunit test tests/ --profile
```
:::
### Test Timeout
> `bashunit test --test-timeout `
Abort an individual test if it runs longer than the given number of seconds and
report it as a failure, then continue with the remaining tests. This protects a
run from hanging forever on a blocked test — for example a mock that was never
given an implementation and waits on input that never arrives.
The timeout is **disabled by default** (`0`). It applies per test (set up and
tear down included) and is expressed in whole seconds. It needs no external
`timeout` command and works on Bash 3.2+ (including the default macOS Bash).
::: code-group
```bash [Example]
bashunit test tests/ --test-timeout 5
```
```[Output]
✗ Error: Test hangs forever
Test timed out after 5s
Tests: 1 passed, 1 failed, 2 total
```
:::
It can also be set via the `BASHUNIT_TEST_TIMEOUT` environment variable (see
[configuration](/configuration#test-timeout)).
### Retry
> `bashunit test --retry `
Re-run a **failed** test up to `n` extra times and report it as passed if any
attempt passes; it only fails once every attempt has failed. This mitigates
flaky tests (timing, network or filesystem races) in CI without hiding a test
that is consistently broken.
Retry is **disabled by default** (`0`). A test that recovered on retry is
annotated so the flakiness stays visible, retries apply per test, and it works
together with `--parallel` and `--stop-on-failure` (a test that recovers on
retry does not trigger stop-on-failure).
::: code-group
```bash [Example]
bashunit test tests/ --retry 2
```
```[Output]
✓ Passed: A flaky test (retry 1/2)
Tests: 1 passed, 1 total
```
:::
It can also be set via the `BASHUNIT_RETRY` environment variable (see
[configuration](/configuration#retry)).
### Random order
> `bashunit test --random-order [--seed ]`
Randomize the order in which test files and the tests within each file run, to
surface hidden inter-test coupling (leaked globals, shared temp files, ordering
dependencies). Disabled by default.
When enabled and no `--seed` is given, a seed is generated and printed in the
run header so a failing run can be replayed exactly with `--seed `. The same
seed always produces the same order, and it composes with `--parallel`. `--seed`
on its own (without `--random-order`) has no effect.
::: code-group
```bash [Example]
bashunit test tests/ --random-order
```
```[Output]
Randomized with seed: 12345
# replay the exact same order:
bashunit test tests/ --random-order --seed 12345
```
:::
It can also be set via the `BASHUNIT_SEED` environment variable (see
[configuration](/configuration#random-order)).
### Shard
> `bashunit test --shard /`
Run a deterministic subset (shard) of the test files, so a large suite can be
split across parallel CI machines. `index` is 1-based (`1 <= index <= total`);
invalid input exits non-zero with an error. Files are assigned round-robin, so
the union of all shards is the full suite with no overlap. Composes with
`--parallel` (shard first on each runner, then parallelize the slice).
::: code-group
```bash [Split across 4 runners]
bashunit test tests/ --shard 1/4
bashunit test tests/ --shard 2/4
bashunit test tests/ --shard 3/4
bashunit test tests/ --shard 4/4
```
```yaml [GitHub Actions matrix]
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: ./bashunit tests/ --shard ${{ matrix.shard }}/4
```
:::
### Rerun failed
> `bashunit test --rerun-failed`
Replay only the tests that failed on the **previous** run — the fastest
edit-run loop after a red suite.
Every run records its failing tests as `:` lines in
`.bashunit/last-failed` under the working directory (one write at the end of a
run, so a plain `fail`, then `--rerun-failed` works without planning ahead). A
fully green run clears the cache. With `--rerun-failed`, discovery is restricted
to the recorded files and each file is filtered to the recorded functions; if
the cache is missing or empty, bashunit prints a short notice and runs the full
suite.
Add `.bashunit/` to your `.gitignore`:
```bash [.gitignore]
.bashunit/
```
Notes:
- Works with `--parallel` (same cache format).
- Composes with `--filter`/`--tag` — both filters apply (intersection).
- Data-provider tests record the base function name once; replaying runs all its
data rows.
- Entries pointing at deleted files or functions are skipped, not fatal.
::: code-group
```bash [Rerun only what just failed]
bashunit test tests/ # some tests fail
bashunit test --rerun-failed # replay just those
```
```bash [Env variable]
BASHUNIT_RERUN_FAILED=true bashunit test tests/
```
:::
### No Progress
> `bashunit test --no-progress`
Suppress real-time progress display during test execution, showing only the final summary.
When enabled, bashunit hides:
- Per-test output (pass/fail messages or dots)
- File headers ("Running tests/...")
- Hook completion messages
- Spinner during parallel execution
The final summary with test counts and results is still displayed.
This is useful for:
- CI/CD pipelines where streaming output causes issues
- Log-restricted environments
- Reducing output noise when only the final result matters
::: code-group
```bash [Example]
bashunit test tests/ --no-progress
```
```[Output]
bashunit - 0.34.1 | Tests: 10
Tests: 10 passed, 10 total
Assertions: 25 passed, 25 total
All tests passed
Time taken: 1.23s
```
:::
### Strict Mode
> `bashunit test --strict`
Enable strict shell mode (`set -euo pipefail`) for test execution.
By default, tests run in permissive mode which allows:
- Unset variables without errors
- Non-zero return codes from commands
- Pipe failures to be ignored
With `--strict`, your tests run with bash strict mode enabled, catching
potential issues like uninitialized variables and unchecked command failures.
::: code-group
```bash [Example]
bashunit test tests/ --strict
```
:::
### Skip Env File
> `bashunit test --skip-env-file`
Skip loading the `.env` file and use the current shell environment only.
By default, bashunit loads variables from `.env` which can override environment
variables set in your shell. Use `--skip-env-file` when you want to:
- Run in CI/CD where environment is pre-configured
- Override `.env` values with shell environment variables
- Avoid `.env` interfering with your current settings
::: warning Important
Only environment variables are inherited from the parent shell. Shell functions
and aliases are NOT available in tests due to bashunit's subshell architecture.
Use a [bootstrap file](/configuration#bootstrap) to define functions needed by your tests.
:::
::: code-group
```bash [Example]
BASHUNIT_SIMPLE_OUTPUT=true ./bashunit test tests/ --skip-env-file
```
:::
### Login Shell
> `bashunit test -l|--login`
Run tests in a login shell context by sourcing profile files.
When enabled, bashunit sources the following files (if they exist) before each test:
- `/etc/profile`
- `~/.bash_profile`
- `~/.bash_login`
- `~/.profile`
Use this when your tests depend on environment setup from login shell profiles, such as:
- PATH modifications
- Shell functions defined in `.bash_profile`
- Environment variables set during login
::: code-group
```bash [Example]
bashunit test tests/ --login
```
:::
### Coverage
> `bashunit test --coverage`
Enable code coverage tracking for your tests. See the [Coverage](/coverage) documentation for comprehensive details.
::: code-group
```bash [Basic usage]
bashunit test tests/ --coverage
```
```bash [With options]
bashunit test tests/ --coverage --coverage-paths src/,lib/ --coverage-min 80
```
:::
**Coverage options:**
| Option | Description |
|---------------------------------|-----------------------------------------------------------------------------|
| `--coverage` | Enable coverage tracking |
| `--coverage-paths ` | Comma-separated paths to track (default: auto-discover from test files) |
| `--coverage-exclude ` | Comma-separated patterns to exclude (default: `tests/*,vendor/*,*_test.sh`) |
| `--coverage-report [file]` | LCOV output file path (default: `coverage/lcov.info`) |
| `--coverage-report-html [dir]` | Generate HTML report (default: `coverage/html`) |
| `--coverage-min ` | Minimum coverage percentage; fails if below |
| `--no-coverage-report` | Show console report only, don't generate LCOV file |
::: tip
Coverage works with parallel execution (`-p`). Each worker tracks coverage independently, and results are aggregated before reporting.
:::
## bench
> `bashunit bench [path] [options]`
Run benchmark functions prefixed with `bench_`. Use `@revs` and `@its` comments to control revolutions and iterations.
::: code-group
```bash [Examples]
# Run all benchmarks
bashunit bench
# Run specific benchmark file
bashunit bench benchmarks/parser_bench.sh
# With filter
bashunit bench --filter "parse"
```
:::
### Bench Options
| Option | Description |
|--------|-------------|
| `-e, --env, --boot ` | Load custom env/bootstrap file (supports args) |
| `-f, --filter ` | Only run benchmarks matching name |
| `-s, --simple` | Simple output |
| `--detailed` | Detailed output (default) |
| `-vvv, --verbose` | Show execution details |
| `--skip-env-file` | Skip `.env` loading, use shell environment only |
| `-l, --login` | Run in login shell context |
## watch
> `bashunit watch [path] [test-options]`
Dedicated watch subcommand that uses **OS file-event notifications** (no
polling) to re-run tests as soon as a `.sh` file changes. Any option accepted
by `bashunit test` is also accepted here.
When neither `inotifywait` nor `fswatch` is installed, it no longer fails:
it falls back to a **pure-shell polling loop** and prints a one-line notice.
Polling checks every `BASHUNIT_WATCH_INTERVAL` seconds (default `2`) using
`find -newer`, so it detects created and modified `.sh` files; deleted files
are not detected on the fallback path. Install one of the tools above for
instant, event-driven triggers.
::: code-group
```bash [Examples]
# Watch current directory
bashunit watch
# Watch the tests/ directory
bashunit watch tests/
# Watch and filter by name
bashunit watch tests/ --filter user
# Watch with simple output
bashunit watch tests/ --simple
```
:::
::: tip Recommended for instant triggers
- **Linux:** `inotifywait` (`sudo apt install inotify-tools`)
- **macOS:** `fswatch` (`brew install fswatch`)
Without either tool, bashunit degrades to polling (see above) instead of
failing. The portable [`-w/--watch`](#watch-mode) flag on `bashunit test`
also uses polling.
:::
## doc
> `bashunit doc [filter]`
Display documentation for assertion functions.
::: code-group
```bash [Examples]
# Show all assertions
bashunit doc
# Filter by name
bashunit doc equals
# Show file-related assertions
bashunit doc file
```
```[Output]
## assert_equals
--------------
> `assert_equals "expected" "actual"`
Reports an error if the two variables are not equal...
## assert_not_equals
--------------
...
```
:::
## init
> `bashunit init [directory]`
Initialize a new test directory with sample files.
::: code-group
```bash [Examples]
# Create tests/ directory (default)
bashunit init
# Create custom directory
bashunit init spec
```
```[Output]
> bashunit initialized in tests
```
:::
Creates:
- `bootstrap.sh` - Setup file for test configuration
- `example_test.sh` - Sample test file to get started
## learn
> `bashunit learn`
Start the interactive learning tutorial with 10 progressive lessons.
::: code-group
```bash [Example]
bashunit learn
```
```[Output]
bashunit - Interactive Learning
Choose a lesson:
1. Basics - Your First Test
2. Assertions - Testing Different Conditions
3. Setup & Teardown - Managing Test Lifecycle
4. Testing Functions - Unit Testing Patterns
5. Testing Scripts - Integration Testing
6. Mocking - Test Doubles and Mocks
7. Spies - Verifying Function Calls
8. Data Providers - Parameterized Tests
9. Exit Codes - Testing Success and Failure
10. Complete Challenge - Real World Scenario
p. Show Progress
r. Reset Progress
q. Quit
Enter your choice:
```
:::
::: tip
Perfect for new users getting started with bashunit.
:::
## upgrade
> `bashunit upgrade`
Upgrade bashunit to the latest version.
::: code-group
```bash [Example]
bashunit upgrade
```
```[Output]
> Upgrading bashunit to latest version
> bashunit upgraded successfully to latest version 0.34.1
```
:::
## Global Options
These options work without a subcommand:
### Version
> `bashunit --version`
Display the current version.
::: code-group
```bash [Example]
bashunit --version
```
```-vue [Output]
bashunit - {{ pkg.version }}
```
:::
### Help
> `bashunit --help`
Display help message with available commands.
::: code-group
```bash [Example]
bashunit --help
```
```[Output]
Usage: bashunit [arguments] [options]
Commands:
test [path] Run tests (default command)
bench [path] Run benchmarks
assert Run standalone assertion
doc [filter] Display assertion documentation
init [dir] Initialize a new test directory
learn Start interactive tutorial
watch [path] Watch files and re-run tests on change
upgrade Upgrade bashunit to latest version
Global Options:
-h, --help Show this help message
-v, --version Display the current version
Run 'bashunit --help' for command-specific options.
```
:::
Each subcommand also supports `--help`:
```bash
bashunit test --help
bashunit bench --help
bashunit watch --help
bashunit doc --help
```
## Related
- [Configuration](/configuration) — set the same options via env vars and config files
- [Test files](/test-files) — how bashunit discovers and names test files
- [Coverage](/coverage) — code coverage tracking
- [Benchmarks](/benchmarks) — performance benchmarks with `bashunit bench`
---
# Configuration
Environment variables and config files control **bashunit** behavior across your project.
It serves to configure the behavior of bashunit in your project.
You need to create a `.env` file in the root directory,
but you can give it another name if you pass it as an argument to the command with
`--env` [option](/command-line#environment-bootstrap).
## Config file (.bashunitrc)
As an alternative to a `.env` file, you can place a `.bashunitrc` file in the
project root with `KEY=value` lines (blank lines and `#` comments are ignored):
```bash
# .bashunitrc
BASHUNIT_SHOW_HEADER=false
BASHUNIT_PARALLEL_RUN=true
BASHUNIT_PROFILE=true
```
It is meant for committing sensible project defaults. Precedence, from highest
to lowest:
1. CLI flags (e.g. `--simple`)
2. Environment variables and the `.env` file
3. `.bashunitrc`
4. Built-in defaults
`.bashunitrc` only fills values that are not already set, so an exported
environment variable or a `.env` entry always wins. `--skip-env-file` skips
`.bashunitrc` as well.
## Default path
> `BASHUNIT_DEFAULT_PATH=directory|file`
Specifies the `directory` or `file` containing the tests to be run. `empty` by default.
If a directory is specified, it will execute tests within files ending in `test.sh`.
When running benchmarks (`--bench`), the same path is used to search for files ending in `bench.sh`.
If you use wildcards, **bashunit** will run any tests it finds.
::: code-group
```bash [Example]
# all tests inside the tests directory
BASHUNIT_DEFAULT_PATH=tests
# concrete test by full path
BASHUNIT_DEFAULT_PATH=tests/example_test.sh
# all test matching given wildcard
BASHUNIT_DEFAULT_PATH=tests/**/*_test.sh
```
:::
## Output
> `BASHUNIT_SIMPLE_OUTPUT=true|false`
Enables simplified output to the console. `false` by default.
Verbose is the default output, but it can be overridden by the environment configuration.
Similar as using `-s|--simple | -vvv|--detailed` option on the [command line](/command-line#output-style).
::: code-group
```bash [Simple output]
....
```
```bash [.env]
BASHUNIT_SIMPLE_OUTPUT=true
```
:::
::: code-group
```[Verbose output]
Running tests/functional/logic_test.sh
✓ Passed: Other way of using the exit code
✓ Passed: Should validate a non ok exit code
✓ Passed: Should validate an ok exit code
✓ Passed: Text should be equal
```
```bash [.env]
BASHUNIT_SIMPLE_OUTPUT=false
```
:::
## Parallel
> `BASHUNIT_PARALLEL_RUN=true|false`
Runs the tests in child processes with randomized execution, which may improve overall testing speed, especially for larger test suites.
::: warning
Parallel execution is supported on **macOS**, **Ubuntu**, **Alpine**, and
**Windows**. On other systems bashunit forces sequential execution to avoid
inconsistent results.
:::
Similar as using `-p|--parallel` option on the [command line](/command-line#parallel).
## Parallel Jobs
> `BASHUNIT_PARALLEL_JOBS=`
Limits the number of concurrent jobs when running in parallel mode. Set to `0` (default) for unlimited concurrency.
Similar as using `-j|--jobs` option on the [command line](/command-line#jobs).
## Stop on failure
> `BASHUNIT_STOP_ON_FAILURE=true|false`
Force to stop the runner right after encountering one failing test. `false` by default.
Similar as using `-S|--stop-on-failure` option on the [command line](/command-line#test-options).
::: tip Assertion behavior
By default, when an assertion fails within a test, subsequent assertions in the same test are skipped. Use `-R, --run-all` or set `BASHUNIT_STOP_ON_ASSERTION_FAILURE=false` to run all assertions even when one fails.
The `--stop-on-failure` flag is separate – it stops the entire test runner after a failing **test**, while assertion-level stopping happens within each test.
:::
## Test timeout
> `BASHUNIT_TEST_TIMEOUT=`
Abort an individual test if it runs longer than the given number of seconds,
report it as a failure and keep running the remaining tests. `0` (disabled) by
default. Useful to stop a run from hanging forever on a blocked test, such as a
mock left without an implementation.
The value is expressed in whole seconds and applies per test (set up and tear
down included). It needs no external `timeout` command and works on Bash 3.2+,
including the default macOS Bash.
Similar as using `--test-timeout` option on the [command line](/command-line#test-timeout).
::: code-group
```bash [Enable a 5s timeout]
BASHUNIT_TEST_TIMEOUT=5
```
```bash [Disabled (default)]
BASHUNIT_TEST_TIMEOUT=0
```
:::
## Retry
> `BASHUNIT_RETRY=`
Re-run a failed test up to `n` extra times and report it as passed if any
attempt passes; it fails only after every attempt fails. `0` (disabled) by
default. Mitigates flaky tests in CI without hiding a consistently broken one; a
test that recovers on retry is annotated so the flakiness stays visible.
Applies per test and works together with `--parallel` and `--stop-on-failure`.
Similar as using `--retry` option on the [command line](/command-line#retry).
::: code-group
```bash [Retry up to 2 times]
BASHUNIT_RETRY=2
```
```bash [Disabled (default)]
BASHUNIT_RETRY=0
```
:::
## Random order
> `BASHUNIT_RANDOM_ORDER=true|false` and `BASHUNIT_SEED=`
Randomize the order of test files and of the tests within each file to surface
hidden inter-test coupling. Disabled by default. `BASHUNIT_SEED` pins the
shuffle so a run can be replayed; when unset, a seed is generated and printed in
the run header. Composes with `--parallel`.
Similar as using `--random-order` / `--seed` options on the
[command line](/command-line#random-order).
::: code-group
```bash [Reproducible shuffle]
BASHUNIT_RANDOM_ORDER=true
BASHUNIT_SEED=12345
```
```bash [Disabled (default)]
BASHUNIT_RANDOM_ORDER=false
```
:::
## Rerun failed
> `BASHUNIT_RERUN_FAILED=true|false`
Replay only the tests that failed on the previous run instead of the whole
suite. Disabled by default. Every run records its failing tests to
`.bashunit/last-failed` (add `.bashunit/` to your `.gitignore`), so enabling
this replays exactly those; a fully green run clears the cache, and an empty
cache falls back to running everything. Composes with `--filter`, `--tag` and
`--parallel`.
Similar as using `--rerun-failed` option on the
[command line](/command-line#rerun-failed).
::: code-group
```bash [Replay last failures]
BASHUNIT_RERUN_FAILED=true
```
```bash [Disabled (default)]
BASHUNIT_RERUN_FAILED=false
```
:::
## Stop on assertion failure
> `BASHUNIT_STOP_ON_ASSERTION_FAILURE=true|false`
Controls whether to stop at the first failed assertion within a test. `true` by default.
When enabled (default), subsequent assertions in the same test are skipped after a failure.
When disabled, all assertions in the test run, showing all failures at once.
Similar as using `-R|--run-all` option on the [command line](/command-line).
::: code-group
```bash [Run all assertions]
BASHUNIT_STOP_ON_ASSERTION_FAILURE=false
```
```bash [Stop on first failure (default)]
BASHUNIT_STOP_ON_ASSERTION_FAILURE=true
```
:::
## Watch polling interval
> `BASHUNIT_WATCH_INTERVAL=`
Seconds between checks for the pure-shell polling loop used by watch mode when
neither `inotifywait` nor `fswatch` is installed. `2` by default. Must be a
positive integer; any other value falls back to the default.
::: code-group
```bash [Poll every 5 seconds]
BASHUNIT_WATCH_INTERVAL=5
```
:::
## Show header
> `BASHUNIT_SHOW_HEADER=true|false`
>
> `BASHUNIT_HEADER_ASCII_ART=true|false`
Specify if you want to show the bashunit header. `true` by default.
Additionally, you can use the env-var `BASHUNIT_HEADER_ASCII_ART` to display bashunit in ASCII. `false` by default.
::: code-group
``` [Without header]
✓ Passed: foo bar
```
```bash [.env]
BASHUNIT_SHOW_HEADER=false
```
:::
::: code-group
```-vue [Plain header]
bashunit - {{ pkg.version }} // [!code hl]
✓ Passed: foo bar
```
```bash [.env]
BASHUNIT_SHOW_HEADER=true
```
:::
::: code-group
```-vue [ASCII header]
__ _ _ // [!code hl]
| |__ __ _ ___| |__ __ __ ____ (_) |_ // [!code hl]
| '_ \ / _' / __| '_ \| | | | '_ \| | __| // [!code hl]
| |_) | (_| \__ \ | | | |_| | | | | | |_ // [!code hl]
|_.__/ \__,_|___/_| |_|\___/|_| |_|_|\__| // [!code hl]
{{ pkg.version }} // [!code hl]
✓ Passed: foo bar
```
```bash [.env]
BASHUNIT_SHOW_HEADER=true
BASHUNIT_HEADER_ASCII_ART=true
```
:::
## Show skipped
> `BASHUNIT_SHOW_SKIPPED=true|false`
Show a summary of skipped tests at the end of the run. Disabled by default.
Similar as using `--show-skipped` option on the [command line](/command-line).
::: code-group
```bash [Show skipped]
BASHUNIT_SHOW_SKIPPED=true
```
```bash [Disabled (default)]
BASHUNIT_SHOW_SKIPPED=false
```
:::
## Show incomplete
> `BASHUNIT_SHOW_INCOMPLETE=true|false`
Show a summary of incomplete tests at the end of the run. Disabled by default.
Similar as using `--show-incomplete` option on the [command line](/command-line).
::: code-group
```bash [Show incomplete]
BASHUNIT_SHOW_INCOMPLETE=true
```
```bash [Disabled (default)]
BASHUNIT_SHOW_INCOMPLETE=false
```
:::
## Show execution time
> `BASHUNIT_SHOW_EXECUTION_TIME=true|false|auto`
Specify if you want to display the per-test execution time after running **bashunit**. `auto` by default.
`auto` shows per-test times when the shell has a fork-free high-resolution clock (Bash 5.0+ via `EPOCHREALTIME`, or GNU `date`), and hides them when measuring a test would require forking an interpreter (for example the default Bash 3.2 on macOS, which falls back to `perl`). This keeps a plain run fast on those shells. Set `true` to always measure and show per-test times (paying that cost), or `false` to always hide them. `--profile`, `--verbose`, and the `--report-*`/`--log-junit` reports always measure regardless of this setting.
The time format adapts based on duration:
- Under 1 second: displayed in milliseconds (e.g., `14 ms`)
- 1-59 seconds: displayed in seconds (e.g., `5 s`)
- 60+ seconds: displayed in minutes and seconds (e.g., `2m 1s`)
::: code-group
```-vue [With execution time]
✓ Passed: foo bar
Tests: 1 passed, 1 total
Assertions: 3 passed, 3 total
All tests passed
Time taken: 14 ms // [!code hl]
```
```bash [.env]
BASHUNIT_SHOW_EXECUTION_TIME=true
```
:::
::: code-group
```[Without execution time]
✓ Passed: foo bar
Tests: 1 passed, 1 total
Assertions: 3 passed, 3 total
All tests passed
```
```bash [.env]
BASHUNIT_SHOW_EXECUTION_TIME=false
```
:::
## Output format
> `BASHUNIT_OUTPUT_FORMAT=tap`
Select an alternative output format. Currently supported: `tap` for
[TAP version 13](https://testanything.org/tap-version-13-specification.html),
useful for CI/CD integrations.
Similar as using `--output` option on the [command line](/command-line#output-format).
::: code-group
```bash [.env]
BASHUNIT_OUTPUT_FORMAT=tap
```
:::
## Log JUnit
> `BASHUNIT_LOG_JUNIT=file`
Create a report XML file that follows the JUnit XML format and contains information about the test results of your bashunit tests.
::: code-group
```bash [Example]
BASHUNIT_LOG_JUNIT=log-junit.xml
```
:::
## Log GitHub Actions
> `BASHUNIT_LOG_GHA=file`
Write GitHub Actions workflow commands (`::error`, `::warning`, `::notice`) to the given file, so failed, risky and incomplete tests show up as inline annotations in the "Files changed" tab of a pull request.
On a CI runner, stream the generated file to stdout so GitHub parses it:
::: code-group
```bash [Example]
BASHUNIT_LOG_GHA=gha.log
```
```yaml [GitHub Actions workflow]
- run: ./bashunit --log-gha gha.log tests/ || (cat gha.log && exit 1)
```
:::
## Report HTML
> `BASHUNIT_REPORT_HTML=file`
Create a report HTML file that contains information about the test results of your bashunit tests.
::: code-group
```bash [Example]
BASHUNIT_REPORT_HTML=report.html
```
:::
## Bootstrap
> `BASHUNIT_BOOTSTRAP=file`
Specifies an additional file to be loaded for all tests cases.
Useful to set up global variables or functions accessible in all your tests.
::: tip Using functions in tests
If you need shell functions available in your tests, define them in a bootstrap
file and use `export -f function_name` to make them available in test subshells.
This is the recommended pattern for sharing functions across tests.
:::
Similarly, you can use load an additional file via the [command line](/command-line#environment-bootstrap).
::: code-group
```bash [Example]
# a simple .env file
BASHUNIT_BOOTSTRAP=".env.tests"
# or a complete script file
BASHUNIT_BOOTSTRAP="tests/globals.sh"
# Default value
BASHUNIT_BOOTSTRAP="tests/bootstrap.sh"
```
:::
### Bootstrap arguments
> `BASHUNIT_BOOTSTRAP_ARGS=arguments`
Pass arguments to the bootstrap file. Arguments are space-separated and available
as positional parameters (`$1`, `$2`, etc.) in your bootstrap script.
::: code-group
```bash [.env]
BASHUNIT_BOOTSTRAP="tests/bootstrap.sh"
BASHUNIT_BOOTSTRAP_ARGS="staging verbose"
```
```bash [bootstrap.sh]
#!/usr/bin/env bash
ENVIRONMENT="${1:-production}"
VERBOSE="${2:-false}"
export API_URL="https://${ENVIRONMENT}.api.example.com"
```
:::
You can also pass arguments inline via the [--env](/command-line#environment-bootstrap) option:
```bash
bashunit --env "tests/bootstrap.sh staging verbose" tests/
```
## Dev log
> `BASHUNIT_DEV_LOG=file`
> See: [Globals > log](/globals#bashunit-log)
::: code-group
```bash [Setup]
BASHUNIT_DEV_LOG="dev.log"
```
```bash [Usage]
log "I am tracing something..."
log "error" "an" "error" "message"
log "warning" "different log level messages!"
```
```bash [Output: out.log]
2024-10-03 21:27:23 [INFO]: I am tracing something... #tests/sample.sh:11
2024-10-03 21:27:23 [ERROR]: an error message #tests/sample.sh:27
2024-10-03 21:27:24 [WARNING]: different log level messages! #tests/sample.sh:21
```
:::
When enabled, the selected log file path is printed in the header so you can
quickly `tail -f` it while the tests run.
> All internal messages emitted by bashunit are prefixed with `[INTERNAL]`.
> You can toggle internal messages with `BASHUNIT_INTERNAL_LOG=true|false`.
## Verbose
> `BASHUNIT_VERBOSE=bool`
Display internal details for each test.
Similarly, you can use the command line option for this: [command line](/command-line#test-options).
::: code-group
```bash [Example]
BASHUNIT_VERBOSE=true
```
:::
## No output
> `BASHUNIT_NO_OUTPUT=true|false`
Suppress all console output. Defaults to `false`.
Similar as using `--no-output` option on the [command line](/command-line#test-options).
::: code-group
```bash [Example]
BASHUNIT_NO_OUTPUT=true
```
:::
## Fail on risky
> `BASHUNIT_FAIL_ON_RISKY=true|false`
Treat risky tests (tests with zero assertions) as failures instead of warnings. `false` by default.
When enabled, a test that finishes without running any assertion is reported as failed, and the run exits with a non-zero status.
Similar as using `--fail-on-risky` option on the command line.
::: code-group
```bash [Example]
BASHUNIT_FAIL_ON_RISKY=true
```
:::
## Profile
> `BASHUNIT_PROFILE=true|false` · `BASHUNIT_PROFILE_COUNT=`
Report the slowest tests after a run. `false` by default; `BASHUNIT_PROFILE_COUNT` defaults to `10`.
When enabled, each test's duration is recorded and the slowest ones are printed at the end,
sorted from slowest to fastest. `BASHUNIT_PROFILE_COUNT` limits how many are shown.
Similar as using `--profile` option on the [command line](/command-line#profile).
::: code-group
```bash [Example]
BASHUNIT_PROFILE=true
BASHUNIT_PROFILE_COUNT=5
```
:::
## Failures only
> `BASHUNIT_FAILURES_ONLY=true|false`
Only show failures, suppressing passed, skipped, and incomplete tests. `false` by default.
When enabled, progress output is suppressed and only failing tests are displayed.
The final summary still shows all counts (passed/failed/skipped/incomplete).
Similar as using `--failures-only` option on the [command line](/command-line#test-options).
::: code-group
```bash [Example]
BASHUNIT_FAILURES_ONLY=true
```
:::
## No progress
> `BASHUNIT_NO_PROGRESS=true|false`
Suppress real-time progress display during test execution. `false` by default.
When enabled, bashunit hides per-test output, file headers, hook messages, and spinners,
showing only the final summary. Useful for CI/CD pipelines or log-restricted environments.
Similar as using `--no-progress` option on the [command line](/command-line#no-progress).
::: code-group
```bash [Example]
BASHUNIT_NO_PROGRESS=true
```
:::
## Show output on failure
> `BASHUNIT_SHOW_OUTPUT_ON_FAILURE=true|false`
Display captured stdout/stderr output when tests fail with runtime errors or assertion failures. `true` by default.
When a test fails due to a runtime error (command not found, unbound variable, etc.) or
a failed assertion after the test printed diagnostics, bashunit displays the test's output
in an "Output:" section to help debug the failure.
Similar as using `--show-output` or `--no-output-on-failure` options on the [command line](/command-line#show-output-on-failure).
::: code-group
```[Output example]
✗ Error: My test function
command not found
Output:
Debug: Setting up test
Running command: my_command
```
```bash [.env to disable]
BASHUNIT_SHOW_OUTPUT_ON_FAILURE=false
```
:::
## Diff on multiline failures
> `BASHUNIT_NO_DIFF=true|false`
When an `assert_equals`/`assert_same` failure involves a multiline value,
bashunit renders a git word-diff below the `Expected/but got` header so the
difference is easy to spot; the inline values are truncated to their first line
while the diff is shown. Requires git (falls back to the plain output when it is
unavailable), respects `--no-color`, and never affects single-line failures or
machine reports (JUnit/TAP/JSON). `false` by default — set `BASHUNIT_NO_DIFF=true`
to always print the full raw values instead.
::: code-group
```[Output example]
✗ Failed: My test function
Expected 'alpha…'
but got 'alpha…'
alpha
[-beta-]{+DELTA+}
gamma
```
```bash [.env to disable]
BASHUNIT_NO_DIFF=true
```
:::
## Color output
> `NO_COLOR=1`
Disables ANSI color codes in output. Follows the [no-color.org](https://no-color.org) standard.
When set to any value, bashunit will output plain text without color formatting.
Similar as using `--no-color` option on the [command line](/command-line).
::: code-group
```bash [Example]
NO_COLOR=1
```
:::
## Strict mode
> `BASHUNIT_STRICT_MODE=true|false`
Enable strict shell mode (`set -euo pipefail`) for test execution. `false` by default.
By default, tests run in permissive mode to maximize compatibility with
different coding styles. Enable strict mode to catch potential issues like
uninitialized variables and unchecked command failures.
Similar as using `--strict` option on the [command line](/command-line#strict-mode).
::: code-group
```bash [Example]
BASHUNIT_STRICT_MODE=true
```
:::
## Skip env file
> `BASHUNIT_SKIP_ENV_FILE=true|false`
Skip loading the `.env` file and use the current shell environment only. `false` by default.
By default, bashunit loads variables from `.env` which can override environment
variables set in your shell. Enable this option when running in CI/CD pipelines
or when you want shell environment variables to take precedence.
::: warning Important
Only environment variables are inherited from the parent shell. Shell functions
and aliases are NOT available in tests due to bashunit's subshell architecture.
Use a [bootstrap file](#bootstrap) to define functions needed by your tests.
:::
Similar as using `--skip-env-file` option on the [command line](/command-line#skip-env-file).
::: code-group
```bash [Example]
BASHUNIT_SKIP_ENV_FILE=true ./bashunit tests/
```
:::
## Login shell
> `BASHUNIT_LOGIN_SHELL=true|false`
Run tests in a login shell context by sourcing profile files. `false` by default.
When enabled, bashunit sources the following files (if they exist) before each test:
- `/etc/profile`
- `~/.bash_profile`
- `~/.bash_login`
- `~/.profile`
Use this when your tests depend on environment setup from login shell profiles.
Similar as using `-l|--login` option on the [command line](/command-line#login-shell).
::: code-group
```bash [Example]
BASHUNIT_LOGIN_SHELL=true
```
:::
## Coverage
### Enable coverage
> `BASHUNIT_COVERAGE=true|false`
Enable code coverage tracking. `false` by default.
When enabled, bashunit tracks which lines of your source code are executed during tests
and generates a coverage report.
Similar as using `--coverage` option on the [command line](/command-line#coverage).
::: code-group
```bash [.env]
BASHUNIT_COVERAGE=true
```
:::
### Coverage paths
> `BASHUNIT_COVERAGE_PATHS=paths`
Comma-separated list of paths to track for coverage.
By default, paths are auto-discovered from test file names (e.g., `tests/unit/assert_test.sh` discovers `src/assert.sh`).
::: code-group
```bash [.env]
# Single path (explicit)
BASHUNIT_COVERAGE_PATHS=src/
# Multiple paths (explicit)
BASHUNIT_COVERAGE_PATHS=src/,lib/,bin/
```
:::
### Coverage exclude
> `BASHUNIT_COVERAGE_EXCLUDE=patterns`
Comma-separated list of patterns to exclude from coverage tracking.
Default: `tests/*,vendor/*,*_test.sh,*Test.sh`
::: code-group
```bash [.env]
BASHUNIT_COVERAGE_EXCLUDE=tests/*,vendor/*,*_test.sh,*_mock.sh
```
:::
### Coverage report
> `BASHUNIT_COVERAGE_REPORT=file`
Path for the LCOV format coverage report. `coverage/lcov.info` by default.
Set to empty string to disable file generation (console report only).
::: code-group
```bash [.env]
# Custom path
BASHUNIT_COVERAGE_REPORT=reports/coverage.lcov
# Disable file output
BASHUNIT_COVERAGE_REPORT=
```
:::
### Coverage minimum
> `BASHUNIT_COVERAGE_MIN=percent`
Minimum coverage percentage required. Empty by default (no minimum).
When set, bashunit will exit with a failure code if coverage falls below this threshold.
::: code-group
```bash [.env]
BASHUNIT_COVERAGE_MIN=80
```
:::
### Coverage thresholds
> `BASHUNIT_COVERAGE_THRESHOLD_LOW=percent`
>
> `BASHUNIT_COVERAGE_THRESHOLD_HIGH=percent`
Thresholds for color-coding the coverage output. Defaults: `50` and `80`.
- Below `THRESHOLD_LOW`: Red
- Between thresholds: Yellow
- Above `THRESHOLD_HIGH`: Green
::: code-group
```bash [.env]
BASHUNIT_COVERAGE_THRESHOLD_LOW=60
BASHUNIT_COVERAGE_THRESHOLD_HIGH=90
```
:::
## Related
- [Command line](/command-line) — flags that mirror these settings
- [Test files](/test-files) — how tests are discovered and named
- [Coverage](/coverage) — measure how much code your tests exercise
- [Globals](/globals) — helper functions available in tests
---
# Code Coverage
Code coverage measures how much of your source code is executed when running tests. It helps identify untested code paths and ensures your test suite exercises the important parts of your application.
## Quick Start
Enable coverage tracking with the `--coverage` flag:
::: code-group
```bash [Basic usage]
bashunit tests/ --coverage
```
```bash [With custom paths]
bashunit tests/ --coverage-paths src/
```
```bash [Output]
bashunit - 0.37.0 | Tests: 5
.....
Tests: 5 passed, 5 total
Assertions: 12 passed, 12 total
All tests passed
Time taken: 1 s
Coverage Report
---------------
src/math.sh 2/ 3 lines ( 66%)
src/utils.sh 8/ 10 lines ( 80%)
---------------
Total: 10/13 (76%)
Coverage report written to: coverage/lcov.info
```
:::
## How It Works
bashunit uses Bash's built-in `DEBUG` trap mechanism to track line execution:
1. **Trap Setup**: When coverage is enabled, a DEBUG trap is set that fires before every command execution
2. **Line Recording**: Each executed line's file path and line number are recorded
3. **Filtering**: Only files matching your coverage paths (and not excluded) are tracked
4. **Aggregation**: After tests complete, hit data is aggregated and reported
::: tip Performance
The DEBUG trap adds overhead to test execution. For large test suites, consider running coverage periodically rather than on every test run.
:::
## Configuration
### Command Line Options
| Option | Description |
|--------|-------------|
| `--coverage` | Enable code coverage tracking |
| `--coverage-paths ` | Comma-separated paths to track (default: auto-discover from test files) |
| `--coverage-exclude ` | Comma-separated exclusion patterns |
| `--coverage-report ` | LCOV report output path (default: `coverage/lcov.info`) |
| `--coverage-report-html [dir]` | Generate HTML report (default: `coverage/html`) |
| `--coverage-min ` | Minimum coverage threshold (fails if below) |
| `--no-coverage-report` | Disable LCOV file generation (console only) |
::: tip Auto-enable
Coverage is automatically enabled when using `--coverage-report`, `--coverage-report-html`, or `--coverage-min`. You don't need to specify `--coverage` explicitly with these options.
:::
### Auto-Discovery
When `BASHUNIT_COVERAGE_PATHS` is not set, bashunit automatically discovers source files based on your test file names:
| Test File | Discovers |
|-----------|-----------|
| `tests/unit/assert_test.sh` | `src/assert.sh`, `src/assert_*.sh` |
| `tests/unit/helperTest.sh` | `src/helper.sh`, `src/helper*.sh` |
This convention follows the common pattern of naming test files after their source files with a `_test.sh` or `Test.sh` suffix.
::: tip Zero Configuration
For most projects following standard naming conventions, you can simply run `bashunit tests/ --coverage` without any path configuration.
:::
### Environment Variables
You can also configure coverage via [environment variables](/configuration) in your `.env` file:
```bash
# Enable coverage
BASHUNIT_COVERAGE=true
# Paths to track (comma-separated)
BASHUNIT_COVERAGE_PATHS=src/,lib/
# Patterns to exclude (comma-separated)
BASHUNIT_COVERAGE_EXCLUDE=tests/*,vendor/*,*_test.sh
# LCOV report output path
BASHUNIT_COVERAGE_REPORT=coverage/lcov.info
# HTML report output directory (generates line-by-line coverage view)
BASHUNIT_COVERAGE_REPORT_HTML=coverage/html
# Minimum coverage percentage (optional)
BASHUNIT_COVERAGE_MIN=80
# Color thresholds for console output
BASHUNIT_COVERAGE_THRESHOLD_LOW=50 # Red below this
BASHUNIT_COVERAGE_THRESHOLD_HIGH=80 # Green above this, yellow between
# Optional text-report blocks (off by default, opt-in for verbose runs)
BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true # Print per-function coverage
BASHUNIT_COVERAGE_SHOW_UNCOVERED=true # Print missed line ranges per file
```
## Examples
### Basic Coverage
Track coverage for the default `src/` directory:
::: code-group
```bash [Command]
bashunit tests/ --coverage
```
:::
### Custom Source Paths
Track multiple directories:
::: code-group
```bash [Command]
bashunit tests/ --coverage --coverage-paths "src/,lib/,bin/"
```
```bash [.env]
BASHUNIT_COVERAGE_PATHS=src/,lib/,bin/
```
:::
### Exclusion Patterns
Exclude specific files or directories:
::: code-group
```bash [Command]
bashunit tests/ --coverage --coverage-exclude "vendor/*,*_mock.sh,deprecated/"
```
```bash [.env]
BASHUNIT_COVERAGE_EXCLUDE=vendor/*,*_mock.sh,deprecated/
```
:::
### Setting Minimum Threshold
Fail the test run if coverage drops below a threshold:
::: code-group
```bash [Command]
bashunit tests/ --coverage-min 80
```
```[Output - Passing]
Coverage Report
---------------
src/math.sh 10/ 12 lines ( 83%)
---------------
Total: 10/12 (83%)
```
```[Output - Failing]
Coverage Report
---------------
src/math.sh 5/ 12 lines ( 41%)
---------------
Total: 5/12 (41%)
Coverage 41% is below minimum 80%
```
:::
### Console-Only Output
Skip generating the LCOV file:
::: code-group
```bash [Command]
bashunit tests/ --coverage --no-coverage-report
```
:::
### HTML Coverage Report
Generate a detailed HTML report showing line-by-line coverage:
::: code-group
```bash [Command]
bashunit tests/ --coverage-report-html coverage/html
```
```bash [.env]
BASHUNIT_COVERAGE_REPORT_HTML=coverage/html
```
:::
This creates a directory with:
- `index.html` - Summary page with per-file coverage percentages
- `files/*.html` - Individual source file views with line highlighting
**Line highlighting:**
- **Green background**: Lines executed during tests (covered)
- **Red background**: Executable lines not executed (uncovered)
- **No background**: Non-executable lines (comments, function declarations, etc.)
Each line also shows the number of times it was executed, helping identify hot paths and dead code.
### CI/CD Integration
Generate coverage for CI tools like Codecov or Coveralls:
::: code-group
```yaml [GitHub Actions]
- name: Run tests with coverage
run: bashunit tests/ --coverage-min 80
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
fail_ci_if_error: true
```
```yaml [GitLab CI]
test:
script:
- bashunit tests/ --coverage
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/lcov.info
```
:::
## Understanding the Console Report
The console report shows coverage per file with color coding:
```
Coverage Report
---------------
src/math.sh 10/ 12 lines ( 83%) # Green (>= 80%)
src/parser.sh 7/ 10 lines ( 70%) # Yellow (50-79%)
src/legacy.sh 2/ 15 lines ( 13%) # Red (< 50%)
---------------
Total: 19/37 (51%)
```
**Color thresholds** (configurable via environment variables):
- **Green**: Coverage >= 80% (`BASHUNIT_COVERAGE_THRESHOLD_HIGH`)
- **Yellow**: Coverage 50-79%
- **Red**: Coverage < 50% (`BASHUNIT_COVERAGE_THRESHOLD_LOW`)
## Understanding LCOV Format
The `coverage/lcov.info` file uses the industry-standard LCOV format, compatible with most CI coverage tools.
### File Structure
```
TN:
SF:/path/to/source/file.sh
DA:2,5
DA:3,0
LF:2
LH:1
end_of_record
```
### Field Reference
| Field | Description | Example |
|-------|-------------|---------|
| `TN:` | Test Name (usually empty) | `TN:` |
| `SF:` | Source File path | `SF:/home/user/project/src/math.sh` |
| `FN:` | Function: `start_line,name` | `FN:5,multiply` |
| `FNDA:` | Function call data: `count,name` (1 if any line in body was hit, else 0) | `FNDA:1,add` |
| `FNF:` | Functions Found | `FNF:2` |
| `FNH:` | Functions Hit | `FNH:1` |
| `BRDA:` | Branch data: `decision_line,block,arm,taken` | `BRDA:12,0,1,1` |
| `BRF:` | Branches Found | `BRF:6` |
| `BRH:` | Branches Hit | `BRH:4` |
| `DA:` | Line Data: `line_number,hit_count` | `DA:15,3` (line 15 hit 3 times) |
| `LF:` | Lines Found (total executable lines) | `LF:25` |
| `LH:` | Lines Hit (lines with hits > 0) | `LH:20` |
| `end_of_record` | Marks end of file entry | `end_of_record` |
### Example Breakdown
Given this source file `src/math.sh`:
```bash
#!/usr/bin/env bash # Line 1 - not executable (comment/shebang)
function add() { # Line 2 - not executable (function declaration)
echo $(($1 + $2)) # Line 3 - executable
} # Line 4 - not executable (closing brace)
function multiply() { # Line 5 - not executable (function declaration)
echo $(($1 * $2)) # Line 6 - executable
} # Line 7 - not executable (closing brace)
```
If tests call `add` twice but never call `multiply`, the LCOV output would be:
```
TN:
SF:/path/to/src/math.sh
DA:3,2
DA:6,0
LF:2
LH:1
end_of_record
```
**Interpretation:**
- Line 3 (`add` body): 2 hits
- Line 6 (`multiply` body): 0 hits
- 2 executable lines found, 1 line was hit (50% coverage)
## Parallel Execution
Coverage works seamlessly with parallel test execution (`-p` flag):
::: code-group
```bash [Command]
bashunit tests/ --coverage -p
```
:::
**How it works:**
- Each parallel worker writes to its own coverage file
- After all tests complete, coverage data is aggregated
- The final report combines hits from all workers
::: tip
Coverage percentages should be identical whether running in parallel or sequential mode.
:::
## What Gets Tracked
### Executable Lines
bashunit counts these as executable lines:
- Commands and statements
- Single-line function bodies (`function foo() { echo "hi"; }`)
### Non-Executable Lines (Skipped)
These lines are not counted toward coverage:
- Empty lines
- Comment lines (including shebang `#!/usr/bin/env bash`)
- Function declaration lines (`function foo() {`)
- Lines with only braces (`{` or `}`)
- Control flow keywords (`then`, `else`, `fi`, `do`, `done`, `esac`, `in`)
- Case statement patterns (`--option)`, `*)`) and terminators (`;;`, `;&`, `;;&`)
## Branch Coverage
Beyond line and function coverage, bashunit emits **branch coverage** records in the LCOV report so reviewers can see whether each `else`/`elif` arm and each `case` pattern was exercised. Branch records are produced automatically; no extra flags are needed.
### What Counts as a Branch
| Construct | Arms |
|-----------|------|
| `if X; then ... fi` | 1 (the `then` body) |
| `if X; then ... else ... fi` | 2 (`then` + `else`) |
| `if X; then ... elif Y; then ... else ... fi` | 3 (one per arm) |
| `case X in a) ... ;; b) ... ;; *) ... ;; esac` | one per pattern |
An arm is reported as **taken** iff at least one executable line inside its range was hit by tests.
### Verbose Output Helpers
Two opt-in environment variables enrich the text report when investigating coverage gaps:
::: code-group
```bash [Per-function block]
BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true bashunit tests/ --coverage
```
```bash [Uncovered lines block]
BASHUNIT_COVERAGE_SHOW_UNCOVERED=true bashunit tests/ --coverage
```
```bash [Both]
BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true \
BASHUNIT_COVERAGE_SHOW_UNCOVERED=true \
bashunit tests/ --coverage
```
:::
The default text report stays compact; opt in only when triaging.
### Worked Example
Given `src/route.sh`:
```bash
#!/usr/bin/env bash
function route() {
if [ "$1" = "GET" ]; then
echo "fetch"
elif [ "$1" = "POST" ]; then
echo "create"
else
echo "405"
fi
}
```
If tests only call `route GET`, the LCOV record looks like:
```
TN:
SF:/path/to/src/route.sh
FN:2,route
FNDA:1,route
FNF:1
FNH:1
BRDA:3,0,0,1
BRDA:3,0,1,0
BRDA:3,0,2,0
BRF:3
BRH:1
DA:3,1
DA:4,1
DA:5,0
DA:6,0
DA:7,0
DA:8,0
LF:6
LH:2
end_of_record
```
**Reading the branch records:**
- `BRDA:3,0,0,1`: decision on line 3, block 0, arm 0 (`then`/GET), taken.
- `BRDA:3,0,1,0`: same decision, arm 1 (`elif`/POST), not taken.
- `BRDA:3,0,2,0`: same decision, arm 2 (`else`/405), not taken.
- `BRF:3` `BRH:1`: 3 branches found, 1 taken.
### Visualizing with genhtml
LCOV's `genhtml` renders branch coverage alongside line and function coverage:
::: code-group
```bash [Generate]
bashunit tests/ --coverage
genhtml --branch-coverage coverage/lcov.info -o coverage/html
```
:::
The resulting site shows a red/green diamond next to each branch decision, mirroring `gcov`'s C/C++ output.
### CI Integration
Codecov and Coveralls pick up the new records without configuration. To require branch coverage in PR gates:
::: code-group
```yaml [Codecov]
coverage:
status:
project:
default:
target: 80%
patch:
default:
target: 80%
threshold: 0%
flags:
- branch
```
:::
### Limitations
- An arm whose body has no executable lines (only comments or braces) registers as not-taken even when the conditional fired.
- Implicit `else` (an `if`/`elif` chain without an explicit `else`) reports only the explicit arms; the synthetic fall-through outcome is omitted.
- Compound conditionals (`if A && B`) are reported as a single binary decision, not per sub-expression.
- `&&`/`||` short-circuit branches outside `if` and loop-entry decisions (`while`/`until`) are not tracked.
See `adrs/adr-007-branch-coverage-mvp.md` for the design rationale and the rejected alternatives.
## This repo's own coverage
bashunit dogfoods its own coverage engine. The
[`coverage.yml`](https://github.com/TypedDevs/bashunit/blob/main/.github/workflows/coverage.yml)
workflow runs nightly (and on manual dispatch), measuring `--coverage` over the unit
suite, uploading the `coverage/lcov.info` report as a build artifact, and publishing
the total percentage as a [shields.io endpoint](https://shields.io/badges/endpoint-badge)
badge (shown in the README). It is an optional, non-blocking job — it never runs on push
or pull requests, so it is not a status check on any commit. A real-world example of
wiring the coverage engine into CI without a third-party coverage service.
Note the workflow excludes the engine's own meta-tests (`tests/unit/coverage_*_test.sh`)
from the measured run: executing them under `--coverage` double-instruments
`src/coverage.sh` and corrupts their assertions.
## Related
- [Command-Line](/command-line) — full reference for CLI flags and options
- [Configuration](/configuration) — environment variables and config files
- [Benchmarks](/benchmarks) — measure performance alongside coverage
## Limitations
### External Commands
Coverage only tracks Bash code. External commands (like `grep`, `sed`, etc.) are not tracked, though the lines that call them are.
### Subshell Behavior
Due to Bash's process model, hits produced inside a subshell are written to the subshell's in-memory buffer, which is discarded when the subshell exits. The pinned behavior is:
- `$( ... )` command substitution: the outer line is recorded; commands inside the substitution are not.
- `( ... )` explicit subshells: the same applies; only the outer line is tracked.
- Pipelines (`a | b`): each stage is recorded as a single hit on its source line.
- Process substitution `< <( ... )`: the consumer side is fully tracked; producer lines are not.
- Functions invoked from `$( ... )`: the call site and surrounding lines are hit, but the function body lines are lost when called inside a subshell.
These contracts are pinned by `tests/unit/coverage_subshell_test.sh`.
---
# Benchmarks
Benchmarks measure the execution time of your scripts with dedicated functions, helping you identify performance bottlenecks and ensure your code meets performance requirements.
## Quick Start
Create a benchmark file with functions prefixed with `bench`:
::: code-group
```bash [tests/benchmark/example_bench.sh]
#!/usr/bin/env bash
# @revs=100 @its=5
function bench_my_function() {
my_function_under_test
}
```
```bash [Running]
./bashunit bench tests/benchmark/
```
:::
## Annotations
Control benchmark behavior with comment annotations placed before the function:
| Annotation | Description | Default |
|------------|-------------|---------|
| `@revs=N` | Number of revolutions (function calls per iteration) | 1 |
| `@its=N` | Number of iterations (separate processes) | 1 |
| `@max_ms=N` | Maximum allowed average time in milliseconds | - |
::: code-group
```bash [Basic benchmark]
# @revs=1000 @its=5
function bench_string_operations() {
local result="${text//foo/bar}"
}
```
```bash [With threshold]
# @revs=10 @its=3 @max_ms=50
function bench_api_call() {
curl -s "$API_URL/health" > /dev/null
}
```
:::
::: tip
Each iteration runs in a separate process, providing isolated timing measurements. Higher `@its` values give more reliable averages.
:::
## Running Benchmarks
Run benchmarks using the `bench` command:
::: code-group
```bash [Run all benchmarks]
./bashunit bench tests/benchmark/
```
```bash [Run specific file]
./bashunit bench tests/benchmark/string_bench.sh
```
```bash [With simple output]
./bashunit bench --simple tests/benchmark/
```
:::
If no file is provided, bashunit uses [`BASHUNIT_DEFAULT_PATH`](/configuration) to locate all `*bench.sh` files.
## Output Formats
### Simple Output
Shows progress dots during execution, followed by a summary table:
::: code-group
```bash [Command]
./bashunit bench --simple
```
```[Output]
.........
Benchmark Results (avg ms)
======================================================================
Name Revs Its Avg(ms) Status
bench_string_operations 100 5 12
bench_api_call 10 3 45 ≤ 50
bench_slow_function 50 2 150 > 100
```
:::
### Detailed Output
Shows timing for each iteration as it runs:
::: code-group
```bash [Command]
./bashunit bench
```
```[Output]
Running tests/benchmark/example_bench.sh
Bench string operations [1/5] 13 ms
Bench string operations [2/5] 11 ms
Bench string operations [3/5] 12 ms
Bench string operations [4/5] 12 ms
Bench string operations [5/5] 11 ms
Bench api call [1/3] 43 ms
Bench api call [2/3] 47 ms
Bench api call [3/3] 45 ms
Benchmark Results (avg ms)
=====================================================================
Name Revs Its Avg(ms) Status
bench_string_operations 100 5 12
bench_api_call 10 3 45 ≤ 50
```
:::
## Status Column
The status column indicates threshold results:
| Status | Meaning |
|--------|---------|
| (empty) | No threshold set |
| `≤ N` | Average time is at or below `@max_ms` threshold (pass) |
| `> N` | Average time exceeds `@max_ms` threshold (fail) |
## Setting Thresholds
Use `@max_ms` to fail benchmarks that exceed a time limit:
::: code-group
```bash [Example]
# @revs=10 @its=3 @max_ms=100
function bench_critical_path() {
process_request "$test_data"
}
```
:::
::: warning
Thresholds are checked against the average time across all iterations. A single slow iteration won't cause failure if the average remains acceptable.
:::
## Best Practices
### Isolate the Code Under Test
Minimize setup code inside the benchmark function:
::: code-group
```bash [Good]
function set_up() {
TEST_DATA=$(generate_large_dataset)
}
# @revs=100 @its=5
function bench_process_data() {
process "$TEST_DATA"
}
```
```bash [Avoid]
# @revs=100 @its=5
function bench_process_data() {
local data=$(generate_large_dataset) # Measured!
process "$data"
}
```
:::
### Choose Appropriate Revolutions
- **I/O operations** (network, disk): Lower `@revs` (1-10)
- **CPU operations** (string processing, math): Higher `@revs` (100-1000)
### Run Multiple Iterations
Use `@its` >= 3 for more reliable averages, especially for operations with variable timing.
## Related
- [Command-Line](/command-line) — full reference for CLI flags and options
- [Configuration](/configuration) — environment variables such as `BASHUNIT_DEFAULT_PATH`
- [Coverage](/coverage) — measure which code paths your tests exercise
---
# Standalone
Use bashunit assertions outside of test files for integration testing, end-to-end testing, or validating entire applications and executables.
## Quick Start
Execute assertions directly from the command line using the `assert` command:
::: code-group
```bash [Command]
./bashunit assert equals "expected" "expected"
```
```[Output]
# No output - exit code 0 (success)
```
:::
## Exit Codes
| Exit Code | Description |
|-----------|-------------|
| `0` | Assertion passed |
| `1` | Assertion failed |
| `127` | Non-existing function |
## Basic Usage
### With or Without Prefix
The `assert_` prefix is optional when calling assertions:
::: code-group
```bash [With prefix]
./bashunit assert assert_same "foo" "foo"
./bashunit assert assert_not_equals "foo" "bar"
```
```bash [Without prefix]
./bashunit assert same "foo" "foo"
./bashunit assert not_equals "foo" "bar"
```
:::
### Success and Failure
::: code-group
```bash [Success]
./bashunit assert equals "hello" "hello"
# Exit code: 0
```
```bash [Failure]
./bashunit assert equals "hello" "world"
```
```[Failure Output]
✗ Failed: assert equals
Expected 'hello'
but got 'world'
```
:::
## Lazy Evaluation
Evaluate exit codes without executing commands directly with `$(...)`. This prevents CI interruption when commands return non-zero exit codes:
::: code-group
```bash [Command]
./bashunit assert exit_code "1" "grep -q 'pattern' /nonexistent/file"
```
```bash [PHPStan example]
./bashunit assert exit_code "1" "$PHPSTAN_PATH analyze \
--no-progress --level 8 \
--error-format raw ./"
```
:::
::: tip
Pass the command as a raw string instead of executing it. bashunit will run the command and capture its exit code.
:::
### Capturing Output
Capture command output for further assertions:
::: code-group
```bash [Example]
OUTPUT=$(./bashunit assert exit_code "1" "grep -q 'pattern' ./file.txt")
./bashunit assert line_count 1 "$OUTPUT"
```
:::
### Stdout and Stderr Separation
Command output goes to stdout, bashunit messages go to stderr. Use this to redirect outputs independently:
::: code-group
```bash [Command]
./bashunit assert exit_code "0" "some_command" 2> /tmp/error.log
```
```[stdout]
# Command output appears here
```
```[/tmp/error.log]
✗ Failed: assert exit_code
Expected '0'
but got '1'
```
:::
## Multiple Assertions
Chain multiple assertions on a single command using the `assert` subcommand:
::: code-group
```bash [Syntax]
./bashunit assert "command" assertion1 args... assertion2 args...
```
```bash [Example]
./bashunit assert "echo 'error message' && exit 1" \
exit_code "1" \
contains "error"
```
```[Output]
error message
# Exit code 0 (all assertions passed)
```
:::
### Chaining Multiple Assertions
::: code-group
```bash [Example]
./bashunit assert "./my_script.sh" \
exit_code "0" \
contains "success" \
not_contains "error" \
line_count "5"
```
:::
This is equivalent to running assertions separately:
```bash
OUTPUT=$(./bashunit assert exit_code "0" "./my_script.sh")
./bashunit assert contains "success" "$OUTPUT"
./bashunit assert not_contains "error" "$OUTPUT"
./bashunit assert line_count "5" "$OUTPUT"
```
::: info
Exit code assertions (`exit_code`, `successful_code`, `general_error`, etc.) receive the command's exit code. All other assertions (`contains`, `equals`, `matches`, etc.) receive the command's stdout output.
:::
## Practical Examples
### Validating Script Output
::: code-group
```bash [Example]
./bashunit assert "./build.sh" \
exit_code "0" \
contains "Build successful" \
not_contains "ERROR"
```
:::
### Testing API Responses
::: code-group
```bash [Example]
./bashunit assert "curl -s http://localhost:8080/health" \
exit_code "0" \
contains '"status":"healthy"'
```
:::
### Checking File Operations
::: code-group
```bash [Example]
./bashunit assert "./deploy.sh --env staging" \
exit_code "0" \
contains "Deployed to staging"
./bashunit assert file_exists "/var/www/staging/index.html"
```
:::
## Available Assertions
All standard bashunit [assertions](/assertions) are available in standalone mode. Common ones include:
| Assertion | Description |
|-----------|-------------|
| `equals` | Check string equality |
| `contains` | Check substring presence |
| `matches` | Match against regex pattern |
| `exit_code` | Check command exit code |
| `successful_code` | Check for exit code 0 |
| `file_exists` | Check file existence |
| `line_count` | Check number of lines |
See the full [assertions reference](/assertions) for all available options.
## Related
- [Assertions](/assertions) — full assertion reference
- [Command-Line](/command-line) — full reference for CLI flags and options
- [Common Patterns](/common-patterns) — real-world testing recipes
---
# Examples
## Demo example
**bashunit** includes a sample script and its associated test file in the examples folder of its repository.
In there, you'll find various tests showcasing the file and different **bashunit** functionalities.
[Take a look](https://github.com/TypedDevs/bashunit/tree/main/example).
## Real examples
If you're interested in real-world examples, below is a list of actual projects that use **bashunit** to test their Bash scripts.
::: info
If your project utilizes **bashunit**, you can add it to this list by submitting a pull request to us.
:::
### [TypedDevs/bashunit](https://github.com/TypedDevs/bashunit/tree/main/tests)
**bashunit** uses itself to test the proper operation of each one of its features.
### [phpstan/phpstan-src](https://github.com/phpstan/phpstan-src/blob/2.0.x/.github/workflows/e2e-tests.yml)
PHPStan is a widely used static analysis tool for PHP. It uses **bashunit** [standalone assertions](/standalone) to check for errors in their CI e2e tests.
### [Chemaclass/create-pr](https://github.com/Chemaclass/create-pr)
A bash script to create a PR based on your branch name.
### [Chemaclass/conventional-commits](https://github.com/Chemaclass/conventional-commits)
A specification for adding human and machine-readable meaning to commit messages.
### [antonio-gg-dev/fizzbuzz-bashunit](https://github.com/antonio-gg-dev/fizzbuzz-bashunit)
Popular introductory FizzBuzz kata solved in Bash with **bashunit** as the testing framework.
### [kndndrj/shload](https://github.com/kndndrj/shload)
Loading bar for your shell scripts.
### [phpctl](https://github.com/opencodeco/phpctl)
It is a Docker (containers) based development environment for PHP.
### [SherpaCLI/sherpa](https://sherpa-cli.netlify.app/tools/unit-tests)
Sh:erpa is a tool for simplifying script creation. It integrates **bashunit** to ensure reliable, tested Bash scripts for its users.
### [sqlh](https://gitlab.com/prodigal.knight/sqlite-history.sh)
sqlh is a SQLite-backed shell history alternative with shared history features which works across multiple shells.
## Related
- [Quickstart](/quickstart) - write and run your first test
- [Common patterns](/common-patterns) - real-world testing patterns
- [Standalone](/standalone) - assertions outside test files
- [Assertions](/assertions) - assertion reference
---
# Support
If you encounter any issues, require clarification, or wish to suggest improvements, the primary avenue for support is through [our GitHub repository's issue tracking system](https://github.com/TypedDevs/bashunit/issues).
How to Get Support:
1. **Navigate to our Issues Page**:
Visit the issues section of our repository.
2. **Search for Existing Issues**:
Before creating a new issue, please search to ensure that your concern hasn't been addressed already.
3. **Create a New Issue**:
If your concern isn't previously reported, click on the 'New issue' button.
Please provide as much detail as possible, including error messages, steps to reproduce, and expected outcomes.
4. **Engage Constructively**:
When interacting on issues, please be respectful and constructive, understanding that the community aims to help and enhance the tool collaboratively.
We value our community's feedback and aim to address all concerns in a timely and effective manner.
Your active participation and constructive feedback play a pivotal role in the continuous improvement of **bashunit**.
## Related
- [Quickstart](/quickstart) - write and run your first test
- [Installation](/installation) - install bashunit
- [Project overview](/project-overview) - repo layout and contributor workflow