0Pricing
DevOps Bootcamp · Урок

Тестовые данные, временные окружения и покрытие

Создавайте изолированные тестовые данные и измеряйте, какие ветви скрипта действительно выполняются в ваших тестах

«Тестовые данные, временные окружения и покрытие» — бесплатный урок DevOps Bootcamp на CoddyKit. Это урок 3 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения DevOps Bootcamp, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс DevOps Bootcamp содержит 4 уроков всего.

Почему важны тестовые фикстуры и изоляция

При тестировании скриптов Bash наибольшую опасность представляют побочные эффекты: тесты случайно изменяют настоящие файлы, базы данных или состояние системы. Тест, который проходит на Вашем компьютере, но повреждает рабочие данные, хуже, чем отсутствие теста.

Решение — тестовые фикстуры: управляемые одноразовые окружения, воспроизводящие реальные условия и не затрагивающие ничего настоящего. Хорошие фикстуры обеспечивают:

  • Воспроизводимость — тесты дают одинаковый результат при каждом запуске
  • Изоляцию — тесты не мешают друг другу и основной системе
  • Безопасность — разрушающие операции затрагивают только ненужные данные
  • Скорость — никаких сетевых вызовов и тяжёлых операций ввода-вывода без крайней необходимости

При тестировании Bash фикстуры обычно представляют собой временные каталоги с заранее известными файлами, поддельными исполняемыми файлами в начале $PATH и переменными окружения, область действия которых ограничена процессом теста.

Создание и очистка временных каталогов

Стандартный шаблон временного каталога для каждого теста использует mktemp -d: эта команда создаёт уникальный каталог в /tmp и выводит его путь. Сохраните путь и зарегистрируйте trap, чтобы автоматически удалить каталог при завершении оболочки — даже в случае ошибки.

Этот идиоматический фрагмент из двух строк должен присутствовать в каждом файле теста, который работает с файловой системой:

#!/usr/bin/env bash
set -euo pipefail

# Create an isolated temp directory
TMPDIR=$(mktemp -d)
# Always clean up, even if the script exits early or errors out
trap 'rm -rf "$TMPDIR"' EXIT

echo "Working in: $TMPDIR"

# Simulate creating fixture files
mkdir -p "$TMPDIR/project/{src,tests,logs}"
echo 'version=1.2.3' > "$TMPDIR/project/.env"
echo 'Hello fixture' > "$TMPDIR/project/src/main.sh"

ls -R "$TMPDIR/project"
echo 'Temp dir will be removed automatically on exit'

Структурирование дерева каталогов фикстуры

Хорошо организованная фикстура повторяет структуру каталогов, которую фактически ожидает проверяемый скрипт. Представьте её как миниатюрный поддельный корень проекта. Функция настройки фикстуры создаёт эту структуру перед каждым тестом, а функция очистки удаляет её.

Основные рекомендации:

  • Используйте функцию setup(), которую тестовая платформа вызывает перед каждым тестом
  • Используйте teardown() или cleanup(), выполняемую после каждого теста, даже при ошибке
  • Делайте файлы фикстуры минимальными — включайте только то, что фактически считывает скрипт
  • Давайте файлам фикстуры описательные имена, чтобы причины сбоев было легко определить
#!/usr/bin/env bash
# fixture_helpers.bash — source this from your test files

FIXTURE_ROOT=''

setup_fixture() {
  FIXTURE_ROOT=$(mktemp -d)
  # Build the directory tree the deploy script expects
  mkdir -p "$FIXTURE_ROOT"/{dist,config,logs}
  echo '{"version":"2.0"}' > "$FIXTURE_ROOT/config/app.json"
  echo 'console.log("app")' > "$FIXTURE_ROOT/dist/index.js"
  touch "$FIXTURE_ROOT/logs/.gitkeep"
  export FIXTURE_ROOT
  echo "[setup] Fixture ready at $FIXTURE_ROOT"
}

teardown_fixture() {
  if [[ -n "$FIXTURE_ROOT" && -d "$FIXTURE_ROOT" ]]; then
    rm -rf "$FIXTURE_ROOT"
    echo '[teardown] Fixture removed'
  fi
}

# Self-test
setup_fixture
ls "$FIXTURE_ROOT"
teardown_fixture

Имитация исполняемых файлов с помощью поддельного PATH

Многие скрипты Bash вызывают внешние инструменты, например curl, aws, docker или git. При тестировании Вы не хотите обращаться к настоящим сервисам, поэтому заменяете эти инструменты поддельными исполняемыми файлами.

Приём прост:

  1. Создайте временный каталог bin/ внутри фикстуры
  2. Запишите туда небольшие скрипты оболочки с теми же именами, что и у настоящих инструментов
  3. Добавьте этот каталог в начало $PATH перед вызовом проверяемого скрипта

Поскольку поиск в $PATH выполняется слева направо, поддельный файл будет найден первым. Настоящий двоичный файл не будет вызван.

#!/usr/bin/env bash
set -euo pipefail

FIXTURE=$(mktemp -d)
trap 'rm -rf "$FIXTURE"' EXIT

# Create a fake 'curl' that records calls and returns canned data
FAKE_BIN="$FIXTURE/bin"
mkdir -p "$FAKE_BIN"

cat > "$FAKE_BIN/curl" << 'SCRIPT'
#!/usr/bin/env bash
# Log every argument for later inspection
echo "curl $*" >> "$FIXTURE_BIN_LOG"
# Return a canned HTTP 200 response body
echo '{"status":"ok","id":42}'
SCRIPT
chmod +x "$FAKE_BIN/curl"

# Export the log path so the fake can find it
export FIXTURE_BIN_LOG="$FIXTURE/curl_calls.log"

# Prepend fake bin directory to PATH
export PATH="$FAKE_BIN:$PATH"

# Now any call to 'curl' hits our fake
curl -s https://api.example.com/health
curl -X POST https://api.example.com/deploy

echo '--- Recorded curl calls ---'
cat "$FIXTURE_BIN_LOG"

Перехват и проверка вывода команд

Фикстура полезна только в том случае, если Вы можете проверить, что сделал Ваш скрипт. Стандартные приёмы:

  • Сохранять stdout/stderr в переменные с помощью $() или подстановки процесса
  • Проверять файлы журналов, записанные поддельными двоичными файлами
  • Явно проверять коды завершения с помощью $? или условной логики
  • Проверять, были ли определённые файлы созданы, изменены или оставлены без изменений

Небольшие специализированные вспомогательные средства для проверок делают тесты понятными и позволяют выдавать точные сообщения об ошибках.

#!/usr/bin/env bash
set -euo pipefail

# Minimal assertion helpers
assert_eq() {
  local desc="$1" expected="$2" actual="$3"
  if [[ "$expected" == "$actual" ]]; then
    echo "PASS: $desc"
  else
    echo "FAIL: $desc"
    echo "  expected: $expected"
    echo "  actual:   $actual"
    return 1
  fi
}

assert_file_exists() {
  local desc="$1" file="$2"
  if [[ -f "$file" ]]; then
    echo "PASS: $desc"
  else
    echo "FAIL: $desc — file not found: $file"
    return 1
  fi
}

assert_contains() {
  local desc="$1" needle="$2" haystack="$3"
  if [[ "$haystack" == *"$needle"* ]]; then
    echo "PASS: $desc"
  else
    echo "FAIL: $desc — '$needle' not found in output"
    return 1
  fi
}

# Demo usage
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
echo 'hello world' > "$TMPDIR/greeting.txt"
OUT=$(cat "$TMPDIR/greeting.txt")
assert_eq   'file content matches'     'hello world' "$OUT"
assert_file_exists 'greeting file created' "$TMPDIR/greeting.txt"
assert_contains    'output has hello'     'hello'       "$OUT"

Ограничение области действия переменных окружения в тестах

Скрипты часто считывают переменные окружения, например $HOME, $CONFIG_PATH или $DATABASE_URL. В тестах необходимо переопределять их, не изменяя настоящее окружение.

Самый безопасный подход — запускать проверяемый скрипт в дочерней оболочке, передавая только явно заданные переменные. Команда env позволяет очистить окружение и добавить обратно только необходимое:

  • env -i VAR=val ./script.sh — полностью чистое окружение
  • (export VAR=val; ./script.sh) — дочерняя оболочка наследует окружение родительского процесса и применяет Ваши переопределения

Использование дочерних оболочек также означает, что если скрипт изменит $IFS, $PWD или другое глобальное состояние, эти изменения не попадут обратно в средство запуска тестов.

#!/usr/bin/env bash
set -euo pipefail

FIXTURE=$(mktemp -d)
trap 'rm -rf "$FIXTURE"' EXIT

# Write a tiny script under test that reads env vars
cat > "$FIXTURE/deploy.sh" << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
ENV_NAME=${DEPLOY_ENV:-unknown}
CFG=${CONFIG_DIR:-/etc/app}
echo "Deploying to $ENV_NAME using config from $CFG"
SCRIPT
chmod +x "$FIXTURE/deploy.sh"

echo '--- Test 1: staging env ---'
# Run in a clean subshell with explicit vars
(
  export DEPLOY_ENV=staging
  export CONFIG_DIR="$FIXTURE/config"
  "$FIXTURE/deploy.sh"
)

echo '--- Test 2: production env ---'
(
  export DEPLOY_ENV=production
  export CONFIG_DIR=/etc/prod-config
  "$FIXTURE/deploy.sh"
)

echo '--- Test 3: defaults ---'
# No env vars set — script should use its own defaults
env -i PATH="$PATH" "$FIXTURE/deploy.sh"

Введение в покрытие Bash с помощью kcov

Покрытие отвечает на вопрос: какие строки (и ветви) моего скрипта тесты действительно выполнили? Высокий показатель покрытия не гарантирует правильность, но низкий выявляет непроверенные пути, в которых с большой вероятностью могут скрываться ошибки.

Основной инструмент для измерения покрытия Bash — kcov. Он работает, инструментируя скрипт на уровне ОС с помощью PTRACE (Linux) или dtrace (macOS), поэтому не требует изменений исходного кода. Инструмент создаёт отчёт HTML с красными (непокрытыми) и зелёными (покрытыми) строками.

Базовое использование:

  • kcov --include-path=./src coverage-out/ ./src/myscript.sh
  • Откройте coverage-out/index.html в браузере, чтобы изучить результаты
  • В непрерывной интеграции разбирайте coverage-out/myscript.sh/coverage.json, чтобы получить процент в машиночитаемом виде

Примечание: kcov необходимо устанавливать отдельно (brew install kcov в macOS, apt install kcov в Ubuntu 20.04 и новее).

Запуск kcov для настоящего скрипта

Ниже приведён сквозной пример: разворачиваемый скрипт, тест, который его проверяет, и вызов kcov для измерения покрытия. Обратите внимание: выходной каталог создаётся отдельно для каждого теста, поэтому позже результаты нескольких запусков можно объединить.

#!/usr/bin/env bash
# This demo shows the *structure* of a kcov workflow.
# It will not run kcov itself (not guaranteed to be installed),
# but the script under test and test runner are fully runnable.
set -euo pipefail

FIXTURE=$(mktemp -d)
trap 'rm -rf "$FIXTURE"' EXIT

# 1. Script under test
cat > "$FIXTURE/process.sh" << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
INPUT="$1"
if [[ ! -f "$INPUT" ]]; then
  echo "ERROR: file not found" >&2
  exit 1
fi
LINE_COUNT=$(wc -l < "$INPUT")
if (( LINE_COUNT == 0 )); then
  echo "WARNING: file is empty"
else
  echo "Processed $LINE_COUNT lines"
fi
SCRIPT
chmod +x "$FIXTURE/process.sh"

# 2. Happy path test (exercises line 6 and 11)
echo -e 'one\ntwo\nthree' > "$FIXTURE/data.txt"
OUT=$("$FIXTURE/process.sh" "$FIXTURE/data.txt")
echo "Happy path: $OUT"

# 3. Empty file test (exercises line 9)
touch "$FIXTURE/empty.txt"
OUT=$("$FIXTURE/process.sh" "$FIXTURE/empty.txt")
echo "Empty file: $OUT"

# 4. Missing file test (exercises line 5-6)
if ! OUT=$("$FIXTURE/process.sh" "$FIXTURE/missing.txt" 2>&1); then
  echo "Missing file (expected error): $OUT"
fi

# To measure coverage, wrap each call with kcov:
# kcov --include-path="$FIXTURE" "$FIXTURE/cov/happy" "$FIXTURE/process.sh" ...
# kcov --merge "$FIXTURE/cov/all" "$FIXTURE/cov/happy" "$FIXTURE/cov/empty"

Объединение покрытия нескольких запусков тестов

Один тест редко охватывает все ветви. Вы запускаете несколько тестов — каждый в собственном каталоге с результатами покрытия, — а затем объединяете их. Флаг --merge инструмента kcov объединяет несколько запусков в один сводный отчёт.

Типичный шаблон в конвейере непрерывной интеграции:

  • Запустите тест A → сохраните результат в cov/test_a/
  • Запустите тест B → сохраните результат в cov/test_b/
  • Объедините результаты → kcov --merge cov/all/ cov/test_a/ cov/test_b/
  • Разберите cov/all/<script>/coverage.json, чтобы получить итоговый процент

Вы также можете задать минимальный порог и завершить сборку непрерывной интеграции с ошибкой, если покрытие опустится ниже него:

#!/usr/bin/env bash
# extract_coverage.sh — parse kcov JSON and fail below threshold
set -euo pipefail

MIN_COVERAGE=80   # percent
COV_JSON="${1:-coverage-out/myscript.sh/coverage.json}"

if [[ ! -f "$COV_JSON" ]]; then
  echo "ERROR: coverage JSON not found at $COV_JSON" >&2
  exit 1
fi

# kcov JSON contains a key like: "percent_covered": "87.50"
PERCENT=$(grep -oP '"percent_covered":\s*"\K[0-9.]+' "$COV_JSON")
PERCENT_INT=${PERCENT%%.*}   # truncate decimal

echo "Coverage: ${PERCENT}%  (minimum: ${MIN_COVERAGE}%)"

if (( PERCENT_INT < MIN_COVERAGE )); then
  echo "FAIL: coverage ${PERCENT}% is below threshold ${MIN_COVERAGE}%" >&2
  exit 1
fi

echo "PASS: coverage threshold met"

Покрытие ветвей и покрытие строк

Вы встретите два основных показателя покрытия:

  • Покрытие строк — была ли эта строка выполнена хотя бы один раз? Этот показатель легко искусственно завысить: один тест может затронуть множество строк, пропустив важные условные пути.
  • Покрытие ветвей — была ли выполнена каждая ветвь каждого if, case и &&/||? Это гораздо более надёжный показатель. Необходимы тесты как для истинной, так и для ложной стороны каждого решения.

kcov сообщает оба показателя. Главное: 100% покрытия строк не означает 100% покрытия ветвей. Рассмотрим этот скрипт: один тест с непустым файлом охватит каждую строку, но ветвь для пустого файла (строка 9 ниже) никогда не будет достигнута:

#!/usr/bin/env bash
# Illustrates line vs branch coverage gap
set -euo pipefail

check_file() {
  local f="$1"
  if [[ -f "$f" ]]; then        # branch A (true) OR branch B (false)
    local lines
    lines=$(wc -l < "$f")
    if (( lines > 0 )); then    # branch C (true) OR branch D (false)
      echo "File has $lines lines"
    else
      echo "File is empty"     # branch D — unreached if only tested with non-empty file
    fi
  else
    echo "File missing"        # branch B — unreached if only tested with existing file
  fi
}

# Only one test: covers lines 5-10 (4 of 6 branches)
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
echo 'data' > "$TMPDIR/sample.txt"
check_file "$TMPDIR/sample.txt"

# To reach 100% branch coverage you also need:
# check_file "/nonexistent/path"
# check_file "$TMPDIR/empty.txt" (after: touch "$TMPDIR/empty.txt")

Интеграция фикстур и покрытия в CI

Собирая всё вместе: надёжный конвейер CI для проектов на Bash объединяет настройку фикстур, выполнение тестов с помощью kcov, слияние результатов и проверку порога в одном скрипте. Этот скрипт становится точкой входа в CI — одной командой запускается всё.

Принципы проектирования средства запуска тестов в CI:

  • Каждый тестовый случай вызывает setup_fixture и регистрирует teardown_fixture через trap
  • Проверяемый скрипт запускается с фиктивным $PATH и переменными окружения с ограниченной областью действия
  • kcov оборачивает каждый запуск и записывает результаты в пронумерованный подкаталог
  • После всех тестов kcov объединяет результаты, а скрипт проверки порога не даёт сборке продолжиться при недостаточном покрытии
  • Средство запуска CI завершается с ненулевым кодом, если завершается с ошибкой хотя бы один тест или проверка покрытия
#!/usr/bin/env bash
# ci_runner.sh — full fixture + coverage pipeline entry point
set -euo pipefail

SRC="./src/deploy.sh"
COV_ROOT="$(mktemp -d)/coverage"
trap 'rm -rf "$COV_ROOT"' EXIT
mkdir -p "$COV_ROOT"

PASS=0
FAIL=0
RUN_NUM=0

run_test() {
  local name="$1" test_fn="$2"
  local fixture
  fixture=$(mktemp -d)
  local cov_out="$COV_ROOT/run_$((++RUN_NUM))"

  if (
    trap 'rm -rf "$fixture"' EXIT
    export FIXTURE="$fixture"
    # Fake bin directory shadowing real tools
    mkdir -p "$fixture/bin"
    export PATH="$fixture/bin:$PATH"
    "$test_fn" "$fixture"
  ); then
    echo "PASS: $name"
    (( PASS++ )) || true
  else
    echo "FAIL: $name"
    (( FAIL++ )) || true
  fi
}

# Example test function
test_happy_path() {
  local fx="$1"
  mkdir -p "$fx/dist"
  echo 'app.js' > "$fx/dist/index.js"
  # Would normally run: kcov "$cov_out" "$SRC" --env=staging "$fx"
  echo "[test] happy path executed in $fx"
}

run_test 'happy_path' test_happy_path

echo "Results: $PASS passed, $FAIL failed"
(( FAIL == 0 ))

Проверка знаний: метод фиктивного PATH

Проверьте, насколько хорошо Вы понимаете метод фиктивного PATH, используемый в фикстурах тестов Bash.

Итоги: фикстуры, временные окружения и покрытие

В этом уроке рассмотрен полный набор инструментов для надёжного изолированного тестирования Bash:

  • Временные каталоги — сочетание mktemp -d и trap ... EXIT гарантирует автоматическую очистку независимо от того, как завершается тест.
  • Структура фикстур — пара setup_fixture / teardown_fixture создаёт и удаляет минимальное дерево каталогов, отражающее реальные входные данные скрипта.
  • Фиктивный PATH — разместите подставные исполняемые файлы в $FIXTURE/bin/ и добавьте этот каталог в начало $PATH, чтобы перехватывать вызовы curl, aws, docker и любых внешних инструментов, не затрагивая системные двоичные файлы.
  • Ограничение области действия окружения — запускайте проверяемый скрипт во вложенной оболочке (() или env -i), чтобы изменённые переменные никогда не попадали обратно в средство запуска тестов.
  • Проверки — небольшие вспомогательные функции (assert_eq, assert_file_exists, assert_contains) выводят понятный результат выполнения и содержательные сообщения об ошибках.
  • Покрытие с помощью kcov — оборачивает выполнение скрипта без изменения исходного кода и создаёт отчёты в форматах HTML и JSON о покрытии строк и ветвей.
  • Слияние и порог — объединяйте несколько запусков kcov с помощью --merge, анализируйте JSON и завершайте CI с ошибкой, если покрытие падает ниже минимального значения.
  • Покрытие ветвей и строк — всегда ориентируйтесь на покрытие ветвей: одно лишь покрытие строк может пропустить целые условные пути и создать ложную уверенность.

Благодаря этим методам тесты Bash становятся столь же строгими, как тесты любого компилируемого языка.

Часто задаваемые вопросы

Урок «Тестовые данные, временные окружения и покрытие» бесплатный?

Да — полный текст урока «Тестовые данные, временные окружения и покрытие» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс DevOps Bootcamp, подпишись на CoddyKit PRO. Курс DevOps Bootcamp содержит 4 уроков всего.

Чему я научусь в уроке «Тестовые данные, временные окружения и покрытие»?

Создавайте изолированные тестовые данные и измеряйте, какие ветви скрипта действительно выполняются в ваших тестах Ты практикуешь DevOps Bootcamp с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать DevOps Bootcamp?

Предыдущий опыт не требуется. DevOps Bootcamp на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 3 из 4.

Сколько времени занимает урок «Тестовые данные, временные окружения и покрытие»?

Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.

Можно ли писать и запускать код в этом уроке DevOps Bootcamp?

Да. Каждый урок DevOps Bootcamp включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.

Все уроки этого курса

  1. Модульное тестирование функций с Bats-core
  2. Имитация команд и заглушки внешних инструментов
  3. Тестовые данные, временные окружения и покрытие
  4. Запуск тестов Shell в CI-конвейерах
← Назад к DevOps Bootcamp