Shell-скрипты ломаются на пробелах в именах, неверных кодах возврата и неожиданном пустом вводе. Проверять их вручную каждый раз после правки бессмысленно: нужен набор тестов, который запускается одной командой. Bats, или Bash Automated Testing System, даёт привычный синтаксис Bash, изоляцию тестов и вывод в TAP или JUnit. Разберём рабочий процесс на примере скрипта резервного копирования.
Зачем тестировать shell
- Тест фиксирует поведение: код возврата, текст ошибки, созданные файлы.
- Регрессии ловятся до выкладки, а не в три часа ночи на сервере.
- Тесты служат документацией: видно, какие аргументы скрипт принимает и что делает.
- В CI один и тот же набор проверяет изменения в Pull Request.
Bats не пытается быть фреймворком на другом языке. Файл теста — обычный Bash с конструкцией @test, внутри которой каждая команда должна завершиться успешно. Это близко к тому, как скрипт работает в реальности, и не требует переписывать код на Python.
Установка
В Ubuntu и Linux Mint пакет bats есть в репозитории. Для параллельного запуска нужен GNU parallel, для статического анализа — shellcheck.
sudo apt update
sudo apt install bats shellcheck parallel
bats --version
mkdir -p tests/test_helper
Библиотеки assert и support удобнее подключить как git-сабмодули или обычные каталоги. Они не обязательны, но дают читаемые сообщения об ошибках вместо голого кода возврата.
git clone --depth 1 https://github.com/bats-core/bats-support.git \
tests/test_helper/bats-support
git clone --depth 1 https://github.com/bats-core/bats-assert.git \
tests/test_helper/bats-assert
ls tests/test_helper
Первый тест
Пусть backup.sh принимает каталог и создаёт рядом архив. Чтобы скрипт можно было и запускать, и подключать в тестах, заверните точку входа в условие.
#!/usr/bin/env bash
set -euo pipefail
main() {
if [[ $# -ne 1 ]]; then
echo "usage: backup.sh DIR" >&2
return 2
fi
local src="$1"
[[ -d "$src" ]] || { echo "no such directory: $src" >&2; return 2; }
[[ -e "$src/archive.tar.gz" ]] && { echo "already exists" >&2; return 3; }
tar -czf "$src/archive.tar.gz" --exclude=archive.tar.gz -C "$src" .
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
Теперь тест может проверить и код возврата, и побочный эффект. Bats создаёт для каждого теста отдельный временный каталог в $BATS_TEST_TMPDIR — используйте его вместо самодельного mktemp.
#!/usr/bin/env bats
bats_require_minimum_version 1.7.0
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
setup() {
mkdir -p "$BATS_TEST_TMPDIR/data"
echo "hello" > "$BATS_TEST_TMPDIR/data/file.txt"
}
@test "создаёт архив и завершается успешно" {
run ./backup.sh "$BATS_TEST_TMPDIR/data"
assert_success
[ -f "$BATS_TEST_TMPDIR/data/archive.tar.gz" ]
}
@test "без аргумента возвращает код 2" {
run -2 ./backup.sh
assert_failure
assert_output --partial "usage"
}
Конструкция run запускает команду, отключая errexit, и записывает результат в $status, $output и $lines. Без run любая ошибка сразу прервала бы тест, и проверить текст сообщения не получилось бы. Флаг run -2 означает ожидаемый код возврата и появился в bats 1.5, а функция проверки версии — в 1.7, поэтому в шапке указано 1.7.0.
Изоляция и фикстуры
Каждый тест выполняется в отдельном процессе, поэтому переменные и текущий каталог не протекают между тестами. Для общих ресурсов есть setup_file и teardown_file, а для всего набора — setup_suite в отдельном файле setup_suite.bash.
setup_file() {
export SHARED_DIR="$BATS_FILE_TMPDIR/shared"
mkdir -p "$SHARED_DIR"
}
teardown_file() {
rm -rf "$SHARED_DIR"
}
@test "видит общий каталог файла" {
[ -d "$SHARED_DIR" ]
}
Правило простое: всё, что можно создать заново, создавайте в setup; всё, что долго готовить, — в setup_file. Не полагайтесь на порядок тестов и не оставляйте файлы в /tmp: параллельный запуск быстро покажет, где не хватает изоляции.
Ассерты и моки
Библиотека bats-assert добавляет assert_success, assert_failure, assert_output и refute_output. Они печатают фактический вывод при падении, что экономит время на разборе.
@test "выводит справку по --help" {
run ./backup.sh --help
assert_success
assert_output --partial "usage: backup.sh DIR"
}
@test "не перезаписывает существующий архив" {
touch "$BATS_TEST_TMPDIR/data/archive.tar.gz"
run ./backup.sh "$BATS_TEST_TMPDIR/data"
assert_failure
assert_output --partial "already exists"
}
Внешние команды подменяются через PATH. Создайте исполняемый файл-заглушку, который пишет свои аргументы в журнал, и передайте изменённый PATH через env — так переменная не утечёт в текущий shell.
@test "передаёт curl флаг повтора" {
mkdir -p "$BATS_TEST_TMPDIR/bin"
cat > "$BATS_TEST_TMPDIR/bin/curl" <<'EOF'
#!/bin/sh
echo "curl $*" >> "$MOCK_LOG"
exit 0
EOF
chmod +x "$BATS_TEST_TMPDIR/bin/curl"
export MOCK_LOG="$BATS_TEST_TMPDIR/calls.log"
run env PATH="$BATS_TEST_TMPDIR/bin:$PATH" ./fetch.sh
assert_success
grep -q -- "--retry" "$MOCK_LOG"
}
Отдельно проверяйте потоки. По умолчанию run складывает stdout и stderr в $output, поэтому тест может пройти, даже если скрипт пишет данные не туда. Для строгой проверки используйте run —separate-stderr и смотрите $stderr и $stderr_lines. Для файлов подключите bats-file: assert_file_exist и assert_dir_exist читаются лучше, чем голый тест [[ -f … ]].
Запуск и CI
Локально достаточно рекурсивного запуска. Для отладки полезны —print-output-on-failure и —trace, а для быстрого прогона только упавших тестов — —filter-status failed.
bats -r tests/
bats --filter "архив" -r tests/
bats --print-output-on-failure -r tests/
mkdir -p reports
bats --report-formatter junit --output reports -r tests/
В CI тот же набор запускается после установки пакетов. Не забудьте, что —jobs требует GNU parallel: без него bats честно завершится ошибкой, а не сделает вид, что распараллелил.
name: shell-tests
on: [push, pull_request]
jobs:
bats:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- run: sudo apt-get update && sudo apt-get install -y bats shellcheck
- run: shellcheck backup.sh
- run: bats -r tests/
Подводные камни
- run скрывает errexit: после него всегда проверяйте $status, иначе тест пройдёт при любой ошибке.
- Присваивание перед run вроде PATH=… run cmd может остаться в оболочке; используйте run env.
- Тесты, которые пишут в общий файл, ломаются при —jobs. Изолируйте данные по $BATS_TEST_TMPDIR.
- Не тестируйте вывод echo ради самого вывода: проверяйте контракт, который важен пользователю.
- Проверяйте пути с пробелами и пустой ввод: чаще всего ломается именно quoting, а не логика.
- Для свежих возможностей вроде bats::on_failure указывайте минимальную версию через bats_require_minimum_version.
Итог
Bats превращает проверку shell-скриптов из ручной рутины в обычный набор тестов: несколько файлов, понятные ассерты и запуск одной командой. Начните с трёх сценариев — успешный путь, ошибка аргументов и побочный эффект вроде созданного файла. Затем добавьте мок внешней команды и подключите набор в CI. Такой минимум уже ловит большинство регрессий, а поддерживать его проще, чем каждый раз вручную перепроверять сценарии после правки.