Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> การเขียนโปรแกรม Bash

Automated Bash Testing ด้วย BATS: รับประกันความน่าเชื่อถือ

Software Developers เขียน Application ด้วย Java, Ruby, Python มี Libraries Sophisticated ช่วยรักษาความสมบูรณ์ของ Software ตลอดเวลา สร้าง Tests รัน Application ผ่าน Series ของ Executions ใน Structured Environments ให้แน่ใจว่า Aspects ทั้งหมดทำงานเป็นที่คาดไว้

Tests เหล่านี้ยิ่ง powerful เมื่อ Automated ใน Continuous Integration (CI) System ทุก Push ไป Source Repository ทำให้ Tests รัน และ Developers ได้รับ Notification ทันทีเมื่อ Test Fail Fast Feedback เพิ่มความมั่นใจใน Functional Integrity

Bash Automated Testing System (BATS) ช่วยให้ Developers เขียน Bash Scripts และ Libraries นำ Practices เดียวกันของ Java, Ruby, Python Developers มาใช้กับ Bash Code ได้

ติดตั้ง BATS

GitHub Page ของ BATS มี Installation Instructions มี BATS Helper Libraries 2 ตัว ให้ Assertions อันทรงพลังมากขึ้น หรือ Allow Override TAP (Test Anything Protocol) Output Format ที่ BATS ใช้ สามารถติดตั้งใน Standard Location และ Source โดย Scripts ทั้งหมด อาจสะดวกกว่ารวม BATS และ Helper Libraries 版本ครบใน Git Repository สำหรับแต่ละชุด Scripts/Libraries ที่ทดสอบ ทำได้ด้วยระบบ git submodule

คำสั่งต่อไปนี้ติดตั้ง BATS และ Helper Libraries ใน Directory test ใน Git Repository:

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'

Clone Git Repository และติดตั้ง Submodules พร้อมกัน ใช้ Flag --recurse-submodules กับ git clone

แต่ละ BATS Test Script ต้อง Execute ด้วย Executable bats หากติดตั้ง BATS ใน test/libs ของ Source Code Repo เรียก Test ด้วย:

./test/libs/bats/bin/bats <path to test script>

หรือเพิ่มด้านบนของ BATS Test Scripts:

#!/usr/bin/env ./test/libs/bats/bin/bats
load 'libs/bats-support/load'
load 'libs/bats-assert/load'

และ chmod +x <path to test script> จะทำให้ 1) Executable ด้วย BATS ติดตั้งใน ./test/libs/bats และ 2) Include Helper Libraries BATS Test Scripts มักเก็บใน Directory test ชื่อตาม Script ที่ทดสอบ แต่副檔名 .bats เช่น Test bin/build ชื่อ test/build.bats

รันชุด BATS Test Files ทั้งหมดด้วย Regular Expression ให้ BATS เช่น ./test/lib/bats/bin/bats test/*.bats

จัดระเบียบ Libraries และ Scripts สำหรับ BATS Coverage

Bash Scripts และ Libraries ต้องจัดระเบียบให้ Expose Inner Workings ให้ BATS ได้อย่างมีประสิทธิภาพ โดยทั่วไป Library Functions และ Shell Scripts ที่รันคำสั่งเยอะตอน Call หรือ Execute ไม่เหมาะกับ BATS Testing ที่มีประสิทธิภาพ

ตัวอย่าง build.sh Script 典型ที่หลายคนเขียน คือกองโค้ดใหญ่ บางคนเอาไปไว้ใน Function ใน Library แต่รันกองโค้ดใหญ่ใน BATS Test และ Cover ทุก Failure Types ใน Test Cases แยกกัน ไม่ได้ วิธีเดียว Test ได้ครบคือ แยกเป็น Functions เล็กๆ Reusable และสำคัญคือ Independently Testable

เพิ่ม Functions ใน Library ได้ง่าย Benefit เพิ่มคือบาง Functions กลายเป็น Useful เองได้ เมื่อแยก Library Function เป็น Functions เล็กมากแล้ว สามารถ source Library ใน BATS Test และรัน Functions เหมือนคำสั่งอื่นเพื่อ Test

Bash Scripts ต้องแยกเป็น Multiple Functions ซึ่ง Main Part ของ Script ควร Call เมื่อ Script Executed นอกจากนี้มี Trick มีประโยชน์มาก ทำให้ Test Bash Scripts ด้วย BATS ง่ายขึ้น: เอา Code ที่ Execute ใน Main Part ย้ายไป Function ชื่อ run_main เพิ่มต่อท้าย Script:

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]
then
run_main
fi

โค้ดเพิ่มเติมนี้ทำอย่างพิเศษ: ทำให้ Script พฤติกรรมต่างกันเมื่อ Execute เป็น Script เทียบกับเมื่อ Bring เข้า Environment ด้วย source Trick นี้ทำให้ Test Script ได้เหมือน Library โดย Source มัน และ Test Individual Functions ตัวอย่าง build.sh ที่ Refactor สำหรับ BATS Testability ดีกว่า

เขียนและรัน Tests

เหมือนที่กล่าว BATS เป็น TAP-compliant Testing Framework Syntax และ Output คุ้นเคยสำหรับผู้ใช้ TAP-compliant Testing Suites อื่นๆ เช่น JUnit, RSpec, Jest Tests จัดเป็น Individual Test Scripts Test Scripts จัดเป็น @test Blocks ที่อธิบาย Unit ของ Application ที่ทดสอบ แต่ละ @test Block รัน Series ของ Commands เตรียม Test Environment รันคำสั่งที่ทดสอบ และ Assert เกี่ยวกับ Exit และ Output ของคำสั่งที่ทดสอบ Assertion Functions หลายตัว Import ด้วย Libraries bats, bats-assert, bats-support ที่ Load เข้า Environment ตอนต้น BATS Test Script ตัวอย่าง @test Block ปกติ:

@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 Script มี Functions setup และ/หรือ teardown จะ Execute อัตโนมัติโดย BATS ก่อนและหลังแต่ละ Test Block ทำให้สร้าง Environment Variables, Test Files และสิ่งอื่นที่ Test ต้องการ แล้ว Tear Down หลัง Test รัน Build.bats เป็น Full BATS Test ของ build.sh ใหม่ (คำสั่ง mock_docker ใน Test นี้อธิบายด้านล่างใน Mocking/Stubbing)

ตอนรัน Test Script BATS ใช้ exec รันแต่ละ @test Block เป็น Subprocess แยก ทำให้ Export Environment Variables และ Functions ใน @test หนึ่ง ไม่กระทบ @test อื่น หรือ Pollute Shell Session ปัจจุบัน Output ของ Test Run เป็น Standard Format อ่านได้โดยมนุษย์ และ Parse/Manipulate ได้โดย TAP Consumers ตัวอย่าง Output CI_COMMIT_REF_SLUG Test Block เมื่อ Fail:

 ✗ 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

Output ของ Test สำเร็จ:

✓ requires CI_COMMIT_REF_SLUG environment variable

Helpers

เหมือน Shell Script หรือ Library อื่น BATS Test Scripts สามารถ Include Helper Libraries แชร์ Code ระหว่าง Tests หรือเพิ่มความสามารถ Libraries เหล่านี้ เช่น bats-assert และ bats-support สามารถ Test ด้วย BATS ได้

Libraries วางใน Test Directory เดียวกับ BATS Scripts หรือ test/libs หากไฟล์ใน Test Directory เยอะ BATS มี Function load รับ Path ไป Bash File Relative กับ Script ที่ Test (เช่น test) และ Source File นั้น Files ต้องจบด้วย Prefix .bash แต่ Path ส่งให้ load ไม่ต้อง Include Prefix build.bats Load bats-assert, bats-support, helpers.bash เล็ก, docker_mock.bash (อธิบายด้านล่าง) ด้วยโค้ดต้น Script ใต้ Magic Line:

load 'libs/bats-support/load'
load 'libs/bats-assert/load'
load 'helpers'
load 'docker_mock'

Stubbing Test Input และ Mocking External Calls

Bash Scripts และ Libraries ส่วนใหญ่ Execute Functions และ/หรือ Executables ตอนรัน มัก запрограмม์ ให้พฤติกรรมเฉพาะตาม Exit Status หรือ Output (stdout, stderr) ของ Functions หรือ Executables นั้น เพื่อ Test ให้เหมาะสม จำเป็นต้องทำ Fake Versions ของ Commands นั้น ที่ออกแบบพฤติกรรมเฉพาะใน Test เฉพาะ เรียกว่า "Stubbing" อาจต้อง Spy โปรแกรมที่ทดสอบ ให้แน่ใจว่า Call คำสั่งเฉพาะ หรือ Call ด้วย Arguments เฉพาะ เรียกว่า "Mocking" ดูเพิ่มเติม Mocking และ Stubbing ใน Ruby RSpec ใช้ได้กับ Testing System ใดๆ

Bash Shell มี Tricks ใช้ใน BATS Test Scripts ทำ Mocking และ Stubbing ทั้งหมดต้องใช้ Bash export Command พร้อม Flag -f Export Function ที่ Override Function หรือ Executable ต้นฉบับ ต้องทำก่อน Execute โปรแกรมที่ทดสอบ ตัวอย่างง่าย Override cat Executable:

function cat() { echo "THIS WOULD CAT ${*}" }
export -f cat

Method นี้ Override Function ในลักษณะเดียวกัน หาก Test ต้อง Override Function ใน Script/Library ที่ทดสอบ สำคัญคือ Source Script/Library ที่ทดสอบ ก่อน Stub/Mock Function มิฉะนั้น Stub/Mock จะถูกแทนที่ด้วย Actual Function ตอน Source Script ยังต้อง Stub/Mock ก่อน รันคำสั่งที่ทดสอบ ตัวอย่างจาก build.bats Mock Function raise ใน build.sh ให้ Error Message เฉพาะถูก Raise โดย Login Function:

@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 Stub/Mock Function หลัง Test เพราะ export แค่กระทบ Subprocess ปัจจุบันระหว่าง exec ของ @test Block ปัจจุบัน แต่ Mock/Stub Commands (เช่น cat, sed) ที่ BATS assert* Functions ใช้ภายใน Mock/Stub Functions นั้น ต้อง unset ก่อนรัน Assert Commands ไม่งั้นจะทำงานผิด ตัวอย่างจาก build.bats Mock sed รัน build_deployable Function Unset sed ก่อน Assertions:

@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}"
}

บางครั้ง Command เดียวกัน เช่น foo เรียกหลายครั้ง Arguments ต่างกันใน Function เดียวที่ทดสอบ ต้องสร้าง Set ของ Functions:

  • mock_foo: รับ Expected Arguments Input Persist ไป TMP File
  • foo: Mocked Version ของ Command Process แต่ละ Call ด้วย List Expected Arguments ที่ Persist ต้อง Export ด้วย export -f
  • cleanup_foo: ลบ TMP File ใช้ใน Teardown Functions สามารถ Test ให้แน่ใจว่า @test Block สำเร็จก่อนลบ

เนื่องจาก Functionality นี้ Reuse ใน Tests ต่างๆ จึงสมควรสร้าง Helper Library Load ได้เหมือน Libraries อื่น

ตัวอย่างดีคือ docker_mock.bash Load เข้า build.bats ใช้ใน Test Blocks ที่ Test Function เรียก Docker Executable Test Block 典型用 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}"
}

Test ตั้งค่าคาดหวัง Docker จะถูก Call 2 ครั้ง Arguments ต่างกัน Call ที่ 2 Fail รันคำสั่งที่ทดสอบ แล้ว Test Exit Status และ Expected Calls ไป Docker

ด้านหนึ่งของ BATS ที่ mock_docker.bash นำมา คือ Environment Variable ${BATS_TMPDIR} ที่ BATS Set ตอนต้น ให้ Tests และ Helpers สร้างและลบ TMP Files ใน Standard Location mock_docker.bash Library จะไม่ลบ Mocks File ที่ Persist หาก Test Fail แต่จะ Print ตำแหน่งเพื่อ View และ Delete อาจต้อง Clean Mock Files เก่าออกจาก Directory นี้เป็นครั้งคราว

ข้อควรระวัง Mocking/Stubbing: Test build.bats ละเมิด Dictum ของ Testing ว่า: Don't mock what you don't own! Dictum ต้องการว่า Calls ไป Commands ที่ Developer Test ไม่ได้เขียน เช่น docker, cat, sed ควร Wrap ใน Libraries ของตัวเอง ซึ่ง Mock ใน Tests ของ Scripts ที่ใช้ Libraries นั้นควร Test โดยไม่ Mock External Commands

คำแนะนำดี การละเลยมี Cost หาก Docker CLI API เปลี่ยน Test Scripts จะไม่ Detect การเปลี่ยนแปลง Result False Positive ไม่ Manifest จน build.sh รันใน Production ด้วย Docker Version ใหม่ Test Developers ตัดสินใจเองว่าจะยึดมั่นมากแค่ไหน แต่ควรเข้าใจ Tradeoffs

สรุป

นำ Testing Regime เข้า Software Development Project สร้าง Tradeoff ระหว่าง a) การเพิ่ม Time และ Organization ที่ต้อง Develop และ Maintain Code และ Tests และ b) ความมั่นใจที่เพิ่มขึ้นของ Developers ใน Integrity ของ Application ตลอด Lifetime Testing Regimes อาจไม่เหมาะกับ Scripts และ Libraries ทั้งหมด

โดยทั่วไป Scripts และ Libraries ที่ตรงเงื่อนไขข้างล่าง 1 ขึ้นไป ควร Test ด้วย BATS:

  • สมควรเก็บใน Source Control
  • ใช้ใน Critical Processes และพึ่งพาให้ Run Consistently นาน
  • ต้อง Modify เป็นระยะ เพิ่ม/ลด/แก้ Function
  • ถูกใช้โดยผู้อื่น

เมื่อตัดสินใจ Apply Testing Discipline บน Bash Scripts หรือ Libraries 1 ตัวขึ้นไป BATS ให้ Comprehensive Testing Features ที่มีใน Software Development Environments อื่น

Acknowledgment: ผมขอขอบคุณ Darrin Mann ที่แนะนำ BATS Testing