Java, Ruby, Python 같은 언어로 애플리케이션을 개발하는 개발자들은 소프트웨어의 무결성을 장기적으로 유지할 수 있도록 돕는 정교한 테스트 라이브러리를 활용하고 있습니다. 이들은 구조화된 환경에서 일련의 실행 과정을 거치며 애플리케이션을 검증하는 테스트를 작성해, 소프트웨어의 모든 측면이 기대대로 동작하는지 확인합니다.
이러한 테스트는 지속적 통합(CI) 시스템에서 자동화될 때 더욱 강력해집니다. 소스 저장소에 코드를 푸시할 때마다 테스트가 실행되고, 실패 시 개발자에게 즉시 알림이 전달되기 때문입니다. 이러한 빠른 피드백은 개발자가 애플리케이션의 기능적 무결성에 대해 갖는 확신을 크게 높여줍니다.
Bash 자동 테스트 시스템인 BATS(Bash Automated Testing System)는 Bash 스크립트와 라이브러리를 개발하는 개발자들도 Java, Ruby, Python 개발자들과 동일한 테스트 방식을 자신의 Bash 코드에 적용할 수 있게 해줍니다.
BATS 설치하기
BATS의 GitHub 페이지에는 설치 방법이 안내되어 있습니다. 더 강력한 어서션(assertion)을 제공하거나 BATS가 사용하는 TAP(Test Anything Protocol) 출력 형식을 재정의할 수 있게 해주는 헬퍼 라이브러리 두 가지(bats-assert, bats-support)도 함께 활용하면 좋습니다.
이러한 라이브러리들을 표준 위치에 설치해 모든 스크립트에서 불러올 수도 있지만, 테스트 대상 스크립트나 라이브러리별 Git 저장소에 BATS 전체 버전과 헬퍼 라이브러리를 함께 포함시키는 편이 더 편리할 수 있습니다. 이때 git submodule 시스템을 활용하면 됩니다.
다음 명령은 Git 저장소의 test 디렉터리에 BATS와 헬퍼 라이브러리들을 설치합니다.
git submodule init
git submodule add https://github.com/sstephenson/bats test/libs/bats
git submodule add https://github.com/ztombol/bats-assert test/libs/bats-assert
git submodule add https://github.com/ztombol/bats-support test/libs/bats-support
git add .
git commit -m 'installed bats'
Git 저장소를 클론하면서 서브모듈까지 한 번에 설치하려면 git clone 명령에 --recurse-submodules 플래그를 사용하세요.
각 BATS 테스트 스크립트는 반드시 bats 실행 파일을 통해 실행되어야 합니다. BATS를 소스 코드 저장소의 test/libs 디렉터리에 설치했다면 다음과 같이 테스트를 실행할 수 있습니다.
./test/libs/bats/bin/bats <테스트 스크립트 경로>또는 각 BATS 테스트 스크립트 맨 앞에 다음 내용을 추가합니다.
#!/usr/bin/env ./test/libs/bats/bin/bats
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
그리고 chmod +x <테스트 스크립트 경로>로 실행 권한을 부여합니다. 이렇게 하면 첫째, ./test/libs/bats에 설치된 BATS로 스크립트를 직접 실행할 수 있고, 둘째, 헬퍼 라이브러리들이 포함됩니다.
BATS 테스트 스크립트는 보통 test 디렉터리에 저장하며, 테스트 대상 스크립트 이름에 .bats 확장자를 붙여 명명하는 것이 관례입니다. 예를 들어 bin/build를 테스트하는 BATS 스크립트라면 test/build.bats라고 이름 짓는 식입니다.
정규 표현식을 BATS에 전달하면 여러 BATS 테스트 파일을 한꺼번에 실행할 수도 있습니다. 예: ./test/lib/bats/bin/bats test/*.bats
BATS 커버리지를 위한 라이브러리·스크립트 구조화
Bash 스크립트와 라이브러리는 BATS가 내부 동작을 효율적으로 검사할 수 있도록 구조화되어야 합니다. 일반적으로 호출 또는 실행 시 많은 명령을 한꺼번에 처리하는 라이브러리 함수나 셸 스크립트는 효율적인 BATS 테스트에 적합하지 않습니다.
예를 들어 build.sh는 많은 사람들이 흔히 작성하는 전형적인 스크립트로, 본질적으로 하나의 거대한 코드 덩어리입니다. 누군가는 이 코드 덩어리를 라이브러리의 함수 안에 넣기도 합니다. 하지만 하나의 거대한 코드 블록을 BATS 테스트로 실행하면서 발생 가능한 모든 오류 유형을 개별 테스트 케이스로 커버하는 것은 불가능합니다. 충분한 커버리지로 이 코드를 테스트하는 유일한 방법은 이를 잘게 나누어 재사용 가능하고, 무엇보다 독립적으로 테스트 가능한 여러 개의 작은 함수로 분해하는 것입니다.
라이브러리에 함수를 추가하는 것은 어렵지 않으며, 추가된 함수들이 의외로 그 자체로 유용하게 활용되는 부수적 이점도 있습니다. 라이브러리 함수를 작은 함수들로 분해한 후에는 BATS 테스트에서 해당 라이브러리를 source로 불러오고, 다른 명령을 실행하듯 각 함수를 호출해 테스트할 수 있습니다.
Bash 스크립트 역시 여러 개의 함수로 분해하고, 스크립트 실행 시 메인 부분이 이 함수들을 호출하도록 만들어야 합니다. 여기에 BATS로 Bash 스크립트 테스트를 한층 쉽게 만들어 주는 매우 유용한 트릭이 있습니다. 바로 메인 부분에서 실행되는 모든 코드를 run_main 같은 이름의 함수로 옮긴 뒤, 스크립트 끝에 다음 코드를 추가하는 것입니다.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]
then
run_main
fi이 짧은 코드는 특별한 역할을 합니다. 스크립트가 직접 실행될 때와 source로 환경에 불러와질 때 서로 다르게 동작하게 만드는 것입니다. 이 트릭 덕분에 스크립트도 라이브러리처럼 source로 불러온 뒤 개별 함수를 테스트하는 방식으로 검증할 수 있습니다. 아래는 BATS 테스트 용이성을 높이기 위해 리팩토링된 build.sh의 예입니다.
테스트 작성과 실행
앞서 언급했듯이 BATS는 TAP 호환 테스트 프레임워크로, JUnit, RSpec, Jest 같은 TAP 호환 테스트 스위트를 사용해 본 사람이라면 익숙한 문법과 출력 형식을 제공합니다. 테스트는 개별 테스트 스크립트 단위로 구성되며, 각 테스트 스크립트는 테스트 대상 애플리케이션의 단위를 설명하는 하나 이상의 @test 블록으로 이루어집니다.
각 @test 블록은 테스트 환경을 준비하는 명령들, 테스트할 명령 실행, 그리고 실행 결과의 종료 상태와 출력에 대한 어서션으로 구성됩니다. 많은 어서션 함수는 bats, bats-assert, bats-support 라이브러리에서 가져오며, 이들은 BATS 테스트 스크립트 시작 부분에서 환경에 로드됩니다. 다음은 전형적인 BATS 테스트 블록의 예입니다.
@test "requires CI_COMMIT_REF_SLUG environment variable" {
unset CI_COMMIT_REF_SLUG
assert_empty "${CI_COMMIT_REF_SLUG}"
run some_command
assert_failure
assert_output --partial "CI_COMMIT_REF_SLUG"
}BATS 스크립트에 setup 및/또는 teardown 함수가 포함되어 있으면, BATS가 각 테스트 블록 실행 전후에 이를 자동으로 호출합니다. 이를 통해 환경 변수 설정, 테스트 파일 생성 등 하나 혹은 모든 테스트에 필요한 준비 작업을 하고, 각 테스트 실행 후 정리할 수 있습니다. Build.bats는 새롭게 구조화된 build.sh 스크립트에 대한 완전한 BATS 테스트 예제입니다. (테스트의 mock_docker 명령은 뒤의 목(mock)/스텁(stub) 섹션에서 설명합니다.)
테스트 스크립트가 실행되면 BATS는 exec을 사용해 각 @test 블록을 별도의 서브프로세스로 실행합니다. 덕분에 한 @test에서 내보낸(export) 환경 변수나 함수가 다른 @test에 영향을 주거나 현재 셸 세션을 오염시키지 않습니다. 테스트 실행 결과는 사람이 읽을 수 있는 표준 형식으로 출력되며, TAP 컨슈머가 프로그램적으로 파싱하거나 가공할 수도 있습니다. 다음은 CI_COMMIT_REF_SLUG 테스트 블록이 실패했을 때의 출력 예입니다.
✗ requires CI_COMMIT_REF_SLUG environment variable
(from function `assert_output' in file test/libs/bats-assert/src/assert.bash, line 231,
in test file test/ci_deploy.bats, line 26)
`assert_output --partial "CI_COMMIT_REF_SLUG"' failed
-- output does not contain substring --
substring (1 lines):
CI_COMMIT_REF_SLUG
output (3 lines):
./bin/deploy.sh: join_string_by: command not found
oc error
Could not login
--
** Did not delete , as test failed **
1 test, 1 failure
다음은 성공한 테스트의 출력입니다.
✓ requires CI_COMMIT_REF_SLUG environment variable헬퍼(Helper)
다른 셸 스크립트나 라이브러리와 마찬가지로, BATS 테스트 스크립트도 헬퍼 라이브러리를 포함해 공통 코드를 여러 테스트에서 공유하거나 기능을 확장할 수 있습니다. bats-assert, bats-support 같은 헬퍼 라이브러리조차 BATS로 테스트할 수 있습니다.
라이브러리는 BATS 스크립트와 같은 테스트 디렉터리에 둘 수 있고, 테스트 디렉터리의 파일 수가 너무 많아 관리가 어려워지면 test/libs 디렉터리에 배치할 수 있습니다. BATS는 load 함수를 제공하는데, 이 함수는 테스트 대상 스크립트 위치(이 글의 경우 test)를 기준으로 한 상대 경로의 Bash 파일을 찾아 source 합니다. 파일명은 반드시 .bash 접미사로 끝나야 하지만, load 함수에 전달하는 경로에는 이 접미사를 포함하지 않습니다. build.bats는 인터프리터 매직 라인 아래에 다음 코드를 두어 bats-assert, bats-support 라이브러리와 작은 helpers.bash 라이브러리, 그리고 docker_mock.bash 라이브러리(아래에서 설명)를 로드합니다.
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
load 'helpers'
load 'docker_mock'
테스트 입력 스텁(stub)과 외부 호출 목(mock)
대부분의 Bash 스크립트와 라이브러리는 실행 중에 함수나 실행 파일을 호출합니다. 그리고 이들 함수·실행 파일의 종료 상태나 출력(stdout, stderr)에 따라 특정 방식으로 동작하도록 프로그래밍되어 있는 경우가 많습니다. 이런 스크립트를 제대로 테스트하려면 특정 테스트 상황에서 의도된 방식으로 동작하는 가짜(fake) 버전의 명령을 만드는 과정, 즉 "스텁(stubbing)"이 필요할 때가 많습니다. 또한 테스트 대상 프로그램이 특정 명령을 호출하는지, 혹은 특정 인수와 함께 호출하는지 감시해야 할 수도 있는데, 이를 "목(mocking)"이라고 합니다. Ruby RSpec의 목/스텁에 대한 좋은 논의가 있으니 참고하면 어떤 테스트 시스템에도 적용할 수 있는 통찰을 얻을 수 있습니다.
Bash 셸은 BATS 테스트 스크립트에서 목/스텁을 구현할 수 있는 몇 가지 트릭을 제공합니다. 모두 -f 플래그와 함께 Bash export 명령을 사용해 원래의 함수나 실행 파일을 덮어쓰는 함수를 내보내는 방식입니다. 이 작업은 반드시 테스트 대상 프로그램을 실행하기 전에 수행해야 합니다. 다음은 cat 실행 파일을 덮어쓰는 간단한 예입니다.
function cat() { echo "THIS WOULD CAT ${*}" }
export -f cat같은 방식으로 함수도 덮어쓸 수 있습니다. 테스트에서 테스트 대상 스크립트나 라이브러리 내부의 함수를 덮어써야 한다면, 함수를 스텁/목 처리하기 전에 해당 스크립트나 라이브러리를 먼저 source 하는 것이 중요합니다. 그렇지 않으면 source 시점에 실제 함수가 스텁/목을 대체해 버립니다. 또한 테스트하려는 명령을 실행하기 전에 스텁/목을 설정해야 한다는 점도 잊지 마세요. 다음은 build.bats에서 build.sh의 raise 함수를 목 처리해 로그인 함수가 특정 오류 메시지를 발생시키는지 확인하는 예입니다.
@test ".login raises on oc error" {
source ${profile_script}
function raise() { echo "${1} raised"; }
export -f raise
run login
assert_failure
assert_output -p "Could not login raised"
}일반적으로는 테스트 후 스텁/목 함수를 unset 할 필요가 없습니다. export는 현재 @test 블록의 exec 중에만 유효한 현재 서브프로세스에만 영향을 주기 때문입니다. 하지만 BATS의 assert* 함수가 내부적으로 사용하는 명령(예: cat, sed 등)을 목/스텁 처리하는 경우에는 주의해야 합니다. 이러한 목/스텁 함수는 assert 명령이 실행되기 전에 반드시 unset 해야 정상 동작합니다. 다음은 build.bats에서 sed를 목 처리하고, build_deployable 함수를 실행한 뒤, 어서션을 수행하기 전에 sed를 unset 하는 예입니다.
@test ".build_deployable prints information, runs docker build on a modified Dockerfile.production and publish_image when its not a dry_run" {
local expected_dockerfile='Dockerfile.production'
local application='application'
local environment='environment'
local expected_original_base_image="${application}"
local expected_candidate_image="${application}-candidate:${environment}"
local expected_deployable_image="${application}:${environment}"
source ${profile_script}
mock_docker build --build-arg OAUTH_CLIENT_ID --build-arg OAUTH_REDIRECT --build-arg DDS_API_BASE_URL -t "${expected_deployable_image}" -
function publish_image() { echo "publish_image ${*}"; }
export -f publish_image
function sed() {
echo "sed ${*}" >&2;
echo "FROM application-candidate:environment";
}
export -f sed
run build_deployable "${application}" "${environment}"
assert_success
unset sed
assert_output --regexp "sed.*${expected_dockerfile}"
assert_output -p "Building ${expected_original_base_image} deployable ${expected_deployable_image} FROM ${expected_candidate_image}"
assert_output -p "FROM ${expected_candidate_image} piped"
assert_output -p "build --build-arg OAUTH_CLIENT_ID --build-arg OAUTH_REDIRECT --build-arg DDS_API_BASE_URL -t ${expected_deployable_image} -"
assert_output -p "publish_image ${expected_deployable_image}"
}때로는 동일한 명령(예: foo)이 테스트 중인 함수 안에서 서로 다른 인수로 여러 번 호출되기도 합니다. 이런 경우에는 다음과 같은 함수 세트를 만들어야 합니다.
- mock_foo: 기대되는 인수를 입력받아 TMP 파일에 저장합니다.
- foo: 목 처리된 명령 버전으로, 호출될 때마다 저장된 기대 인수 목록을 처리합니다. 반드시 export -f로 내보내야 합니다.
- cleanup_foo: TMP 파일을 제거하며, teardown 함수에서 사용합니다. 삭제 전 @test 블록이 성공했는지 검사하는 데 활용할 수도 있습니다.
이런 기능은 여러 테스트에서 반복적으로 재사용되므로, 다른 라이브러리처럼 load 할 수 있는 헬퍼 라이브러리로 만드는 것이 합리적입니다.
좋은 예가 docker_mock.bash입니다. 이 라이브러리는 build.bats에 로드되어 Docker 실행 파일을 호출하는 함수를 테스트하는 모든 테스트 블록에서 사용됩니다. docker_mock을 사용하는 전형적인 테스트 블록은 다음과 같습니다.
@test ".publish_image fails if docker push fails" {
setup_publish
local expected_image="image"
local expected_publishable_image="${CI_REGISTRY_IMAGE}/${expected_image}"
source ${profile_script}
mock_docker tag "${expected_image}" "${expected_publishable_image}"
mock_docker push "${expected_publishable_image}" and_fail
run publish_image "${expected_image}"
assert_failure
assert_output -p "tagging ${expected_image} as ${expected_publishable_image}"
assert_output -p "tag ${expected_image} ${expected_publishable_image}"
assert_output -p "pushing image to gitlab registry"
assert_output -p "push ${expected_publishable_image}"
}이 테스트는 Docker가 서로 다른 인수로 두 번 호출될 것이라는 기대를 설정합니다. 두 번째 Docker 호출이 실패하도록 만든 뒤, 테스트 대상 명령을 실행하고 종료 상태와 기대된 Docker 호출 여부를 검증합니다.
mock_docker.bash가 소개하는 BATS의 한 요소가 ${BATS_TMPDIR} 환경 변수입니다. BATS는 시작 시점에 이 변수를 설정해, 테스트와 헬퍼가 표준 위치에서 TMP 파일을 생성·삭제할 수 있도록 합니다. mock_docker.bash 라이브러리는 테스트가 실패하면 저장된 목 파일을 삭제하지 않지만, 파일 위치를 출력해 확인하고 삭제할 수 있게 해줍니다. 이 디렉터리의 오래된 목 파일은 주기적으로 정리해야 할 수 있습니다.
목/스텁에 관한 한 가지 주의 사항이 있습니다. build.bats 테스트는 "소유하지 않은 것은 목 처리하지 말라(Don't mock what you don't own!)"는 테스트 원칙을 의도적으로 위반합니다. 이 원칙에 따르면 테스트 작성자가 직접 작성하지 않은 명령(예: docker, cat, sed)에 대한 호출은 별도의 래퍼(wrapper) 라이브러리로 감싸야 하며, 해당 명령을 사용하는 스크립트를 테스트할 때는 이 래퍼 라이브러리를 목 처리해야 합니다. 그리고 래퍼 라이브러리 자체는 외부 명령을 목 처리하지 않은 상태로 테스트해야 합니다.
이는 좋은 조언이며, 이를 무시하면 대가가 따릅니다. Docker CLI API가 변경되면 테스트 스크립트는 이 변경을 감지하지 못해 거짓 양성(false positive)이 발생하고, 새 버전의 Docker와 함께 build.sh가 프로덕션 환경에서 실제로 실행되기 전까지는 문제가 드러나지 않습니다. 테스트 개발자는 이 표준을 얼마나 엄격하게 지킬지 스스로 판단해야 하며, 그 결정에 따르는 트레이드오프를 이해해야 합니다.
결론
어떤 소프트웨어 개발 프로젝트든 테스트 체계를 도입하면, a) 코드와 테스트를 개발·유지하는 데 필요한 시간과 조직적 노력의 증가와 b) 애플리케이션 수명 전반에 걸친 무결성에 대한 개발자의 확신 증가 사이에서 트레이드오프가 발생합니다. 모든 스크립트와 라이브러리에 테스트 체계가 적합한 것은 아닙니다.
일반적으로 다음 조건 중 하나 이상을 충족하는 스크립트와 라이브러리는 BATS로 테스트할 가치가 있습니다.
- 소스 컨트롤에 저장할 가치가 있는 경우
- 핵심 프로세스에서 사용되며 장기간 안정적으로 동작해야 신뢰받는 경우
- 기능을 주기적으로 추가/제거/변경해야 하는 경우
- 다른 사람들이 사용하는 경우
하나 이상의 Bash 스크립트나 라이브러리에 테스트 규율을 적용하기로 결정했다면, BATS는 다른 소프트웨어 개발 환경에서 제공되는 포괄적인 테스트 기능을 그대로 활용할 수 있게 해줍니다.
감사의 말: 저를 BATS 테스트의 세계로 안내해 준 Darrin Mann에게 깊이 감사드립니다.