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. Такой минимум уже ловит большинство регрессий, а поддерживать его проще, чем каждый раз вручную перепроверять сценарии после правки.

Насколько публикация полезна?

Нажмите на звезду, чтобы оценить!

Средняя оценка 0 / 5. Количество оценок: 0

Оценок пока нет. Поставьте оценку первым.

Поделиться: Telegram ВКонтакте