Giter VIP home page Giter VIP logo

bach's Introduction

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.

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 Bach Testing Framework.

  • BACH_DEBUG   The default is false. true to enable Bach's @debug API.
  • BACH_COLOR   The default is auto. It can be always or no.
  • BACH_TESTS   It is empty to allow all test cases. You can use glob wildcards to match the test cases to execute.
  • BACH_DISABLED   The default is false. true to disable Bach Testing Framework.
  • BACH_ASSERT_DIFF   The default is the first diff command found in the original PATH environment variable of the system. Used to compare the execution results of testing functions and asserting functions.
  • BACH_ASSERT_DIFF_OPTS   The default is -u for the $BACH_ASSERT_DIFF command.

Limitation of Bach

Cannot block absolute path command calls

In this case, the OS runs the command directly, and does not interact with Bash(or Shell). Bach cannot intercept such commands. We can wrap this kind of commands in a new function, and then use the @mock API to mock the function.

Prohibit resetting the PATH environment variable

Because Bach wants to intercept all command calls, the PATH is set to read-only to avoid resetting its value.

In the case that PATH needs to be re-assigned, it is recommended to use the declare builtin command in our scripts to avoid errors caused by resetting a read-only environment variable.

Bach is unable to intercept I/O redirection

Bach already support mock functions to read from pipelines. But for the use of operators such as >, >>, the solution is to wrap the redirected command in a function. Another way is to use the sed command to put > or >> in quotation marks, convert the I/O redirected operation to a normal argument.

All command in the pipeline must be mocked

The pipeline commands in Bash are running in sub-processes. Test cases may not be stable if we don't use @mock API to mock these pipeline commands.

Using unicode character (empty set) to indicate an empty string

Because there is no way to display an empty string on a terminal. Bach chooses the red empty set symbol to indicate it's an empty string.

When we see this red in test results, it means that the parameter is actually an empty string.

-foobar  ∅
+foobar

Bach APIs

The names of all APIs provided in the Bach testing framework start with @.

@assert-equals

@assert-equals "hello world" "HELLO WORLD"
@assert-equals 1 1

@assert-fail

[[ 1 -eq 3 ]]
@assert-fail

@assert-success

[[ 0 -eq 0 ]]
@assert-success

@comment

Output comments in the test output, but Bach will ignore these comments.

@debug

@die

Terminate the current run immediately

@do-not-panic

Don't panic.

This API has the following aliases:

  • donotpanic
  • dontpanic
  • do-not-panic
  • dont-panic
  • do_not_panic
  • dont_panic

@do-nothing

Do nothing.

Usually this API is used only in asserting functions to verify that no any commands to be executed in testing functions.

For example:

test-nothing() {
    declare i=9
    if [[ "$i" -eq 0 ]]; then
        do-something
    fi
}
test-nothing-assert() {
    @do-nothing
}

@dryrun

Bach uses @dryrun API to dry run commands by default. But if you want to dry run a mocked command, just put @dryrun in front of this mocked command.

For example:

test-dryrun() {
    @mock ls === @stdout file1 file2 # mock `ls` command
    ls # outputs file1 file2
    @dryrun ls # Dry run `ls` command
}
test-dryrun-assert() {
    @out file1
    @out file2
    ls # @dryrun ls
}

@err

Output error message on stderr console

@ignore

test-ignore-echo() {
    @ignore echo

    echo Updating APT caches
    apt-get update
}
test-ignore-echo-assert() {
    apt-get update
}

@load_function

Loading a function definition from a script.

test-gp() {
    @load_function ./examples/example-functions gp

    gp -f
}
test-gp-assert() {
    git push -f origin master
}

@mock

Mock commands or scripts.

Note:

  • cannot mock commands that have absolute paths.
  • If a command is mocked multiple times, only the last mock takes effect

Use === to split commands and output

For example:

Mock a command that followed by parameters

test-mock-ls() {
    @mock ls file1 === @stdout file2

    ls file1

    ls foo bar
}
test-mock-ls-assert() {
    @out file2 # To list file1, but got file2, It's strange, right?

    ls foo bar
}

Mock commands with complex implementations

For example:

test-mock-foobar() {
  @mock foobar <<<\CMD
    if [[ "$var" -eq 1 ]]; then
      @stdout one
    else
      @stdout others
    fi
CMD

  var=1 foobar
  foobar
}
test-mock-foobar() {
  @out one
  @out others
}

@@mock

Mock the same command multiple times and return different values for each run.

For example:

test-mock-function-multiple-times() {
    @@mock random numbers === @out num 1
    @@mock random numbers === @out num 22
    @@mock random numbers === @out num 333

    random
    random hello
    random numbers
    random numbers
    random numbers
    random numbers
}
test-mock-function-multiple-times-assert() {
    @dryrun random
    @dryrun random hello

    @cat << EOF
num 1
num 22
num 333
num 333
EOF
}

@mockall

Mock many simple commands

@mocktrue

Mock the return code of a command as successful.

@mockfalse

Mock the return code of a command as non-zero value.

@out

Output to the stdout console.

@real

Executing the real command.

@run

Executing the script to be tested.

@setup

Executed at the beginning of the testing functions and the asserting functions.

Note: It doesn't make sense to run mock in asserting functions, so it's forbidden to mock any commands in asserting functions.

We cannot mock commands in @setup API.

example:

@setup {
    @echo executing in both the testing function and the asserting function.
}

@setup-assert

Executing at the beginning of all asserting functions.

Note: the test cases will fail if we mock any commands inside @setup-assert

For example:

@setup-assert {
    @echo executing in the asserting functions
}

@setup-test

Executed at the beginning of all testing functions.

This is the only place that allows mock commands outside testing functions.

For example:

@setup-test {
    @echo executing in the testing functions
}

@stderr

Output content to the stderr console, one line per parameter.

@stdout

Output content to the stdout console, one line per parameter.

Learn Bash Programming with Bach

test-learn-bash-no-double-quote-star() {
    @touch bar1 bar2 bar3 "bar*"

    function cleanup() {
        rm -rf $1
    }

    # We want to remove the file "bar*", not the others
    cleanup "bar*"
}
test-learn-bash-no-double-quote-star-assert() {
    # Without double quotes, all bar files are removed!
    rm -rf "bar*" bar1 bar2 bar3
}

test-learn-bash-double-quote-star() {
    @touch bar1 bar2 bar3 "bar*"

    function cleanup() {
        rm -rf "$1"
    }

    # We want to remove the file "bar*", not the others
    cleanup "bar*"
}
test-learn-bash-double-quote-star-assert() {
    # Yes, with double quotes, only the file "bar*" is removed
    rm -rf "bar*"
}

Roadmap

  • a command line tool
  • run inside docker containers n

Clients

  • BMW Group
  • Huawei (华为)

Versioning

The latest version of Bach is 0.5.0, See Bach Releases for more.

Author

Licenses

Bach Testing Framework is dual licensed under:

See LICENSE for more.

bach's People

Contributors

chaifeng avatar guoyiz23 avatar hyperupcall avatar mgalos avatar mihaigalos avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

bach's Issues

Fix for @mock command -v doesn't work for conditions

Hi, after the fix 71471d0,

@mock command -v example === @echo "Example"

works perfectly, but I have seen it doesn't work when used in a condition as so:

test-script () {
    @mock command -v example === @echo "Example"
    if [ -n "$(command -v example)" ]
        echo "Works!"
    fi
}
test-script-assert() {
    echo "Works!"
}

It does work when using command example, without -v

Unable to mock executable when filename contains dot

While writing a test on a bash script that calls Python, I'm getting an error when trying to @mock python3.9.

I've reduced this to the minimum example using a.b:

test-dot() {
    @mock a.b === @stdout TEST

    a.b
}
test-dot-assert() {
    TEST
}

This gives:

/home/james/bin/bach.sh: line 381: ${mock_exec_a.b_9705119_SEQ:-0}: bad substitution
--- actual-stdout.txt   2021-05-16 21:23:48.912484029 +0100
+++ expected-stdout.txt 2021-05-16 21:23:48.936483778 +0100
@@ -1 +1,2 @@
-# Exit code: 1
+TEST
+# Exit code: 0

I've not been able to solve the problem by using single or double quotes: @mock 'python.39' or "python3.9".

Is there something I'm doing wrong? Or some tweak I need to make to the test? Or some bug in bach?

How to assert exit code 1 ?

I used @assert-fail to assert exit code 1 When something run fail, but it does't work.
This is my example:

source bach.sh
test-something-fail(){
    run_something_fail
    @assert-fail
}

run_something_fail(){
    exit 1
}

And I got the error:

1..1
not ok 1 - something fail


# -----
# All tests: 1, failed: 1, skipped: 0

So, what's wrong with this? what should I do?

Getting "line 33: `@out': not a valid identifier"

I am running Git Bash (version 4.4) in Windows. source bach.sh within the shell works fine, sometimes it gives

bash: BACH_OS_NAME: readonly variable

However, when try to source this file within a .sh file, I get

bach.sh: line 33: `@out': not a valid identifier

How to resolve this? Thank you!

As an FYI, uname on my OS gives MINGW64_NT-10.0-22621.

Given that I would much interested in using this bash testing framework, I would also be willing to assist in the process of testing it on Windows (assuming it hasn't already been done to some extent). 也可以用中文跟我私下沟通。

'unmock' binaries?

Really cool project, love the approach and simplicity.

As I understand it, when you run a test, all commands are mocked, including those found on the path, by default.
This is great 99% of the time, but there is a case where I do want the original command to be fully run within the script I am testing.

Here is my situation.
I am testing some docker script automation. I used @mock to mock the docker scripts with fixtures pulled from real execution of docker. Now, I want to test that piping those fixtures from the mocked docker into my script's grep and jq produces the expected results. In this case, I need the script being tested to actually run grep and jq.

I know I can execute @real grep or even @grep within a test, but I cannot wire the script subject I am testing to include @real at the front of all its binary calls. Basically, I want a configurably mixed mode of mocks and real command executions.

I tried creating a function grep and function jq that executes @real grep $@ but this does not seem to work, as the piping of stdin and stdout and processing of args does not work exactly right. @unmock only reverses a mock, it does not seem to make an executable 'available' to be run within the target script.

Is there a technique that can be used to allow scripts being tested to actually execute certain commands using PATH and no mock interactions?

The `sudo rm -rf /` example from the docs is dangerous

This is the introductory example in the docs:

A complete example

#!/usr/bin/env bash
set -euo pipefail
source bach.sh

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

    project_log_path=/tmp/project/logs
    sudo rm -rf "$project_log_ptah/" # Typo here!
}
test-rm-rf-assert() {
    # Verify your test case
    sudo 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
}

https://bach.sh/

I understand that you are trying to make the point that the command is safe when you use bach, but I can just see someone who isn't very experienced in shell scripting commenting out the set -e line to try to get it to work for them.. Or getting the source patch wrong by accident.

Only one or two lines in this script need to fail, and the user's machine is potentially destroyed.

I respectfully suggest that you try to make it a bit safer. One idea would be to consider removing sudo. It seems like you could make the same point without that.

"No Arguments" descriptor is annoying to use

Since 447edb6
there is a visualization for "no arguments passed".

Some tests written against this library before this commit now fail with things like:
Bildschirmfoto 2022-04-21 um 19 26 55

Making those tests pass again is annoying, because the descriptor that is to be pushed
into the verification is using a lot of escape sequences.

The only way I found to make those test pass is to

BACH_NO_ARGUMENTS_SIGN=$'\x1b\x5b31m\u2205\x1b\x5b0m'
pushd"  "$BACH_NO_ARGUMENTS_SIGN

Maybe I'm missing something, but this I don't find ergonomic. Copy&pasting the
sign from terminal into the editor did not work due to the "red" escape sequence
not being copied.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.