SkillAgentSearch skills...

Bach

Unit testing framework for developing cross-platform Bash unit tests

Install / Use

/learn @bach-sh/Bach

README

Bach Unit Testing Framework

Build Status GitHub Actions License: GPL v3 License: MPL v2

Run on Repl.it

Bach

Bach is a Bash testing framework, can be used to test scripts that contain dangerous commands like rm -rf /. No surprises, no pain.

Getting Started

Bach Unit Testing Framework is a real unit testing framework. All commands in the PATH environment variable become external dependencies of bash scripts being tested. No commands can be actually executed. In other words, all commands in Bach test cases are dry run. Because that unit tests should verify the behavior of bash scripts, not test commands. Bach Testing Framework also provides APIs to mock commands.

Prerequisites

Installing

Installing Bach Testing Framework is very simple. Download bach.sh to your project, use the source command to import bach.sh.

For example:

source path/to/bach.sh

A complete example

#!/usr/bin/env bash
source bach.sh

test-rm-rf() {
    # Write your test case

    project_log_path=/tmp/project/logs
    rm -rf "$project_log_ptah/" # Typo here!
}
test-rm-rf-assert() {
    # Verify your test case
    rm -rf /   # This is the actual command to run on your host!
               # DO NOT PANIC! By using Bach Testing Framework it won't actually run.
}

test-rm-your-dot-git() {
    # Mock `find` command with certain parameters, will output two directories

    @mock find ~ -type d -name .git === @stdout ~/src/your-awesome-project/.git \
                                                ~/src/code/.git

    # Do it, remove all .git directories
    find ~ -type d -name .git | xargs -- rm -rf
}
test-rm-your-dot-git-assert() {
    # Verify the actual command

    rm -rf ~/src/your-awesome-project/.git ~/src/code/.git
}

See tests/bach-testing-framework.test.sh for more examples.

How to run tests?

It's recommended to use a shebang at the top of your test file:

#!/usr/bin/env bash

Using #!/usr/bin/env bash is preferable to #!/bin/bash because it looks for the bash executable in the user's PATH. This is particularly important on operating systems like macOS, where the default /bin/bash is an outdated version 3.x, while Bach requires Bash v4.3 or newer. A newer version of Bash installed via Homebrew (brew install bash) is typically located in a path like /usr/local/bin/bash, and #!/usr/bin/env bash will ensure this correct, newer version is used.

After setting the shebang, make the test file executable and run it directly (recommended):

chmod +x your-test-file.test.sh
./your-test-file.test.sh

Alternatively, you can run the test script directly with the bash command:

bash your-test-file.test.sh

Important: Do not use the source command to run your test files (e.g., source your-test-file.test.sh). Sourcing the script runs it within your current shell session, which can pollute your environment and lead to unexpected side effects. Test files should be executed in a separate subshell process to ensure a clean, isolated testing environment.

On Windows

Make sure to use for shebang

#!/bin/bash

and not

#!/bin/sh

If on Cygwin (as opposed to Git Bash), the end of line sequence of bach.sh should be LF.

Write test cases

Unlike the other testing frameworks, A standard test case of Bach is composed of two Bash functions. One is for running tests, the other is for asserting. Bach will run the two functions separately and then compare whether the same sequence of commands will be executed in both functions. The name of a testing function must start with test-, the name of the corresponding asserting function ends with -assert.

For example:

source bach.sh

test-rm-rf() {
    project_log_path=/tmp/project/logs
    sudo rm -rf "$project_log_ptah/" # Typo!
    # An undefined bash variable is an empty string, which can be a serious problem!
}
test-rm-rf-assert() {
    sudo rm -rf /
}

Bach will run the two functions separately, test-rm-rf and test-rm-rf-assert. In the testing function, test-rm-rf, the final actual command to be executed is sudo rm -rf "/". It's the same as the asserting function test-rm-rf-assert. So this test case passes.

If Bach does not find the asserting function for a testing function. It will try to use a traditional test method. In this case, the testing function must have a call to assert the APIs. Otherwise, the test case will fail.

For example:

test-single-function-style() {
    declare i=2
    @assert-equals 4 "$((i*2))"
}

If Bach does not find the corresponding asserting function and there is no assertion API call in the testing function, the test case must fail.

If the name of a test case starts with test-ASSERT-FAIL, it means that the asserting result of this test case is reversed. That is, if the asserting result is successful, the test case fails, if the asserting result fails, the test case is successful.

The assertion APIs of Bach Testing Framework:

  • @assert-equals
  • @assert-fail
  • @assert-success

Mock commands

There are mock APIs in the Bach test framework that can be used to mock commands and scripts.

The Mock APIs:

  • @mock
  • @ignore
  • @mockall
  • @mocktrue
  • @mockfalse
  • @@mock

But it doesn't allow to mock the following built-in commands in Bach Testing Framework:

  • builtin
  • declare
  • eval
  • set
  • unset
  • true
  • false
  • read

Test cases will fail if you attempt to mock these built-in commands. If they are needed in the script under test, we can extract a new function which contains the built-in commands in our scripts, and then use Bach to mock this new function.

Run the actual commands in Bach

In order to make test cases fast, stable, repetitive, and run in random order. We should write unit-testing cases and avoid calling real commands. But Bach also provides a set of APIs for executing real commands.

Bach mocks all commands by default. If it is unavoidable to execute a real command in a test case, Bach provides an API called @real to execute the real command, just put @real at the beginning of commands.

Bach also provides APIs for commonly used commands. The real commands for these APIs are obtained from the system's PATH environment variable before Bach starts.

These common used APIs are:

  • @cd
  • @command
  • @echo
  • @exec
  • @false
  • @popd
  • @pushd
  • @pwd
  • @set
  • @trap
  • @true
  • @type
  • @unset
  • @eval
  • @source
  • @cat
  • @chmod
  • @cut
  • @diff
  • @find
  • @env
  • @grep
  • @ls
  • @shasum
  • @mkdir
  • @mktemp
  • @rm
  • @rmdir
  • @sed
  • @sort
  • @tee
  • @touch
  • @which
  • @xargs

command and xargs are a bit special. Bach mocks both commands by default to make the similar behavior of themselves.

In Bach Testing Framework the xargs is a mock function. It's behavior is similar to the real xargs command if you put -- between xargs and the command. But the commands to be executed by xargs are dry run.

For examples:

test-xargs-no-dash-dash() {
    @mock ls === @stdout foo bar

    ls | xargs -n1 rm -v
}
test-xargs-no-dash-dash-assert() {
    xargs -n1 rm -v
}


test-xargs() {
    @mock ls === @stdout foo bar

    ls | xargs -n1 -- rm -v
}
test-xargs-assert() {
    rm -v foo
    rm -v bar
}


test-xargs-0() {
    @mock ls === @stdout foo bar

    ls | xargs -- rm -v
}
test-xargs-0-assert() {
    rm -v foo bar
}

We can also mock the test command [ ... ]. But it will keep the original behavior if we don't mock it.

For examples:

test-if-string-is-empty() {
    if [ -n "original behavior" ] # We did not mock it, so this test keeps the original behavior
    then
        It keeps the original behavior by default # We should see this
    else
        It should not be empty
    fi

    @mockfalse [ -n "Non-empty string" ] # We can reverse the test result by mocking it

    if [ -n "Non-empty string" ]
    then
        Non-empty string is not empty # No, we cannot see this
    else
        Non-empty string should not be empty but we reverse its result
    fi
}
test-if-string-is-empty-assert() {
    It keeps the original behavior by default

    Non-empty string should not be empty but we reverse its result
}

# Mocking the test command `[ ... ]` is useful
# when we want to check whether a file with absolute path exists or not
test-a-file-exists() {
    @mocktrue [ -f /etc/an-awesome-config.conf ]
    if [ -f /etc/an-awesome-config.conf ]; then
        Found this awesome config file
    else
        Even though this config file does not exist
    fi
}
test-a-file-exists-assert() {
    Found this awesome config file
}

Configure Bach

There are some environment variables starting with BACH_ for configuring Ba

Related Skills

View on GitHub
GitHub Stars560
CategoryDevelopment
Updated2d ago
Forks23

Languages

Shell

Security Score

85/100

Audited on Mar 31, 2026

No findings