Bash 腳本的單元測試

{"type":"doc","content":[{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"爲什麼要爲 Bash 腳本寫單元測試?"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"因爲 Bash 腳本通常都是在執行一些與操作系統有關的操作,可能會對運行環境造成一些不可逆的操作,比如修改或者刪除文件、升級系統中的軟件包等。所以爲了確保 Bash 腳本的安全可靠,在生產環境中部署之前一定需要做好足夠的測試以確保其行爲符合我們的預期。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如何能夠安全可靠的去測試 Bash 腳本呢?有人可能會說我們可以用 Docker 容器。是的,這樣做即安全又方便。在容器隔離出來的環境中不用擔心腳本會破壞我們的系統,而且也能非常簡單的快速重建出一個可用的測試環境。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"不過呢,請考慮以下的幾個常見的場景:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"場景一:在執行 Bash 腳本測試前,我們需要需要事先安裝好所有在 Bash 腳本中會用到的第三方工具,否則這些測試將會因爲命令找不到而執行失敗。例如,我們在腳本中使用了 Bazel 這個構建工具。我們必須提前安裝並配置好 Bazel,而且不要忘記爲了能夠正常使用 Bazel 還得需要一個支持使用 Bazel 構建的工程。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"場景二:測試結果的穩定性可能取決於腳本中訪問的第三方服務的穩定性。比如,我們在腳本中使用 curl 命令從一個網絡服務中獲取數據,但這個服務有時候可能會訪問失敗。有可能是因爲網絡不穩定導致的,也可能是因爲這個服務本身不穩定。再或者如果我們需要第三方服務返回不同的數據以便測試腳本的不同分支邏輯,但我們可能很難去修改這個第三方服務的數據。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"場景三:Bash 腳本的測試用例的執行時間取決於腳本中使用的命令的執行時間。例如,如果我們中腳本中使用了 Gradle 來構建一個工程,由於不同的工程大小 Gradle 的一個構建可能要執行3分鐘或者3個小時。這還只是一個測試用例,如果我們還有20個或者100個測試用例呢?我們是否還能在幾秒內獲得測試報告呢?"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"即使使用了容器來執行 Bash 腳本測試,也一樣無法避免上面的幾個問題。環境的準備過程可能會隨着測試用例的增多而變的繁瑣,測試用例的穩定性和執行時長取決於第三方命令和服務的穩定性和執行時長,還可能很難做到使用不同數據來覆蓋不同的測試場景。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"對於測試 Bash 腳本來說,我們真正要驗證的是 Bash 腳本的執行邏輯。比如在 Bash 腳本中可能會根據傳入的參數來組合出內部所調用的命令的選項和參數,我們要驗證的是這些選項和參數確實如我們預期的。至於調用的命令在接受了這些選項和參數後由於什麼原因而失敗,可能我們並不關心這所有的可能原因。因爲這會有更多的外部影響因素,比如硬件和網絡都是否工作正常、第三方服務是否正常運行、構建工程所需的編譯器是否安裝並配置妥當、授權和認證信息是否都有效、等等。但對於 Bash 腳本來說,這些外部原因導致的結果就是所調用的命令執行成功或者失敗了。所以 Bash 腳本只要關注的是腳本中調用的命令是否能夠成功執行,以及命令輸出了哪些,並決定隨後執行腳本中的哪些不同分支邏輯。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如果說我們就是想知道這個命令搭配上這些選項參數是否能按我們預期的那樣工作呢?很簡單,那就單獨在命令行裏面去執行一下。如果在命令行中也不能按預期的工作,放到 Bash 腳本里面也一樣不會按預期的工作。這種錯誤和 Bash 腳本幾乎沒什麼關係了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"所以,爲了儘量去除影響 Bash 腳本驗證的那些外部因素,我們應該考慮爲 Bash 腳本編寫單元測試,以關注在 Bash 腳本的執行邏輯上。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"什麼樣的測試纔是 Bash 腳本的單元測試?"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"首先,所有存在於 "},{"type":"codeinline","content":[{"type":"text","text":"PATH"}]},{"type":"text","text":" 環境變量的路徑中的命令都不應該在單元測試中被執行。對 Bash 腳本來說,被調用的這些命令可以正常運行,有返回值,有輸出。但腳本中調用的這些命令都是被模擬出來的,用於模擬對應的真實命令的行爲。這樣,我們在 Bash 腳本的單元測試中就避免了很大一部分的外部依賴,而且測試的執行速度也不會受到真實命令的影響了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"其次,每個單元測試用例之間都應該是獨立的。這意味着,這些測試用例可以獨立執行或者被任意亂序執行,而不會影響驗證結果。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"最後,這些測試用例可以在不同的操作系統上執行,且都應該得到相同的驗證結果。比如 Bash 腳本中使用了只有 GNU/Linux 上纔有的命令,對應的單元測試也可以在 Windows 或者 macOS 上執行,且結果一致。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"怎樣爲 Bash 腳本寫單元測試?"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"與其他編程語言一樣,Bash 也有多個測試框架,比如 "},{"type":"link","attrs":{"href":"https://github.com/sstephenson/bats","title":""},"content":[{"type":"text","text":"Bats"}]},{"type":"text","text":"、"},{"type":"link","attrs":{"href":"https://github.com/kward/shunit2/","title":""},"content":[{"type":"text","text":"Shunit2"}]},{"type":"text","text":" 等,但這些框架實際上並不能隔離所有 PATH 環境變量中的命令。有一個名爲 "},{"type":"link","attrs":{"href":"https://bach.sh","title":""},"content":[{"type":"text","text":"Bach Testing Framework"}]},{"type":"text","text":" 的測試框架是目前唯一一個可以爲 Bash 腳本編寫真正的單元測試的框架。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Bach Testing Framework 的最獨特的特性就是默認不會執行任何位於 "},{"type":"codeinline","content":[{"type":"text","text":"PATH"}]},{"type":"text","text":" 環境變量中的命令,因此 Bach Testing Framework 非常適用於驗證 Bash 腳本的執行邏輯。並且還帶來了以下好處:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"簡單"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"什麼也不用安裝。我們就可以執行這些測試。比如可以在一個全新的環境中執行一個調用了大量第三方命令的 Bash 腳本。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"快"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"因爲所有的命令都不會被真正執行,所以每一個測試用例的執行都非常快。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"安全"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"因爲不會執行任何外部的命令,所以即使因爲 Bash 腳本中的某些錯誤導致執行了一個危險的命令,比如 rm -rf *。Bach 會保證這些危險命令不會被執行。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"與運行環境無關"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"可以在 Windows 上去執行只能工作在 GNU/Linux 上的腳本的測試。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"由於操作系統和 Bash 的一些限制,Bach Testing Framework 無法做到:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"攔截使用絕對路徑調用的命令"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"事實上我們應該避免在 Bash 腳本中使用絕對路徑,如果不可避免的要使用,我們可以把這個絕對路徑抽取爲一個變量,或者放入到一個函數中,然後用 @mock API 去模擬這個函數。"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"攔截諸如 >、>>、<< 等等這樣的 I/O 重定向"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"是的,無法攔截 I/O 重定向。我們也同樣可以把這些重定向操作隔離到一個函數中,然後再模擬這個函數。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"Bach Testing Framework 的使用"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Bach Testing Framework 需要 Bash v4.3 或更高版本。在 GNU/Linux 上還需要 Coreutils 和 Diffutils,在常用的發行版中都已經默認安裝好了。Bach 在 Linux/macOS/Cygwin/Git Bash/FreeBSD 等操作系統或者運行環境中驗證通過。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Bash v4.3+"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Coreutils (GNU/Linux)"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Diffutils (GNU/Linux)"}]}]}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"安裝 Bach Testing Framework"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Bach Testing Framework 的安裝很簡單,只需要下載 https://github.com/bach-sh/bach/raw/master/bach.sh 到你的項目中,在測試腳本中用 source 命令導入 Bach Testing Framework 的 bach.sh 即可。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"比如:"},{"type":"codeinline","content":[{"type":"text","text":"source path/to/bach.sh"}]}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"一個簡單的例子"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"與其它的測試框架不同,Bach Testing Framework 的每一個測試用例都是由兩個 Bash 函數組成,一個是以 "},{"type":"codeinline","content":[{"type":"text","text":"test-"}]},{"type":"text","text":" 開頭的測試執行函數,另一個是同名的以 "},{"type":"codeinline","content":[{"type":"text","text":"-assert"}]},{"type":"text","text":" 結尾的測試驗證函數。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"比如在下面的例子中,有兩個測試用例,分別是"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"test-rm-rf"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"test-rm-your-dot-git"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"一個完整的測試用例:"}]},{"type":"codeblock","attrs":{"lang":"shell"},"content":[{"type":"text","text":"#!/usr/bin/env bash\nset -euo pipefail\n\nsource bach.sh # 導入 Bach Testing Framework\n\ntest-rm-rf() {\n # Bach 的標準測試用例是由兩個方法組成\n # - test-rm-rf\n # - test-rm-rf-assert\n # 這個方法 `test-rm-rf` 是測試用例的執行\n\n project_log_path=/tmp/project/logs\n sudo rm -rf \"$project_log_ptah/\" # 注意,這裏有個筆誤!\n}\ntest-rm-rf-assert() {\n # 這個方法 `test-rm-rf-assert` 是測試用例的驗證\n sudo rm -rf / # 這就是真實的將會執行的命令\n # 不要慌!使用 Bach 測試框架不會讓這個命令真的執行!\n}\n\ntest-rm-your-dot-git() {\n # 模擬 `find` 命令來查找你的主目錄下的所有 `.git` 目錄,假設會找到兩個目錄\n\n @mock find ~ -type d -name .git === @stdout ~/src/your-awesome-project/.git \\\n ~/src/code/.git\n\n # 開始執行!刪除你的主目錄下的所有 `.git` 目錄!\n find ~ -type d -name .git | xargs -- rm -rf\n}\ntest-rm-your-dot-git-assert() {\n # 驗證在 `test-rm-your-dot-git` 這個測試執行方法中最終是否會執行以下這個命令。\n\n rm -rf ~/src/your-awesome-project/.git ~/src/code/.git\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Bach 會分別運行每一個測試用例的兩個方法,去驗證兩個方法中執行的命令及其參數是否是一致的。比如,第一個方法 "},{"type":"codeinline","content":[{"type":"text","text":"test-rm-rf"}]},{"type":"text","text":" 是 Bach 的測試用例的執行,與之對應的測試驗證方法就是 "},{"type":"codeinline","content":[{"type":"text","text":"test-rm-rf-assert"}]},{"type":"text","text":" 這個方法"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在第二個測試用例 "},{"type":"codeinline","content":[{"type":"text","text":"test-rm-your-dot-git"}]},{"type":"text","text":" 中使用了 "},{"type":"codeinline","content":[{"type":"text","text":"@mock"}]},{"type":"text","text":" API 來模擬了命令 "},{"type":"codeinline","content":[{"type":"text","text":"find ~ type d -name .git"}]},{"type":"text","text":" 的行爲,這個命令用來找出用戶目錄下的所有 "},{"type":"codeinline","content":[{"type":"text","text":".git"}]},{"type":"text","text":" 目錄。模擬之後,這個命令並不會真的執行,而是利用了 "},{"type":"codeinline","content":[{"type":"text","text":"@stdout"}]},{"type":"text","text":" API 在標準終端上輸出了兩個虛擬的目錄名。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"然後我們就可以執行真正的命令了,將 "},{"type":"codeinline","content":[{"type":"text","text":"find"}]},{"type":"text","text":" 命令的輸出結果傳遞給 "},{"type":"codeinline","content":[{"type":"text","text":"xargs"}]},{"type":"text","text":" 命令,並組合到 "},{"type":"codeinline","content":[{"type":"text","text":"rm -rf"}]},{"type":"text","text":" 命令之後。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在對應的測試驗證函數 "},{"type":"codeinline","content":[{"type":"text","text":"test-rm-your-dot-git-assert"}]},{"type":"text","text":" 裏面就驗證是 "},{"type":"codeinline","content":[{"type":"text","text":"find ~ -type d -name .git | xargs -- rm -rf"}]},{"type":"text","text":" 的運行結果是否等同於命令 "},{"type":"codeinline","content":[{"type":"text","text":"rm -rf ~/src/your-awesome-project/.git ~/src/code/.git"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"@mock"}]},{"type":"text","text":" 是 Bach Testing Framework 中很重要的一個 API,利用這個 API 我們就可以模擬 Bash 腳本中所使用的任意命令的行爲或者輸出。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"比如"}]},{"type":"codeblock","attrs":{"lang":"shell"},"content":[{"type":"text","text":"@mock curl --silent google.com === \\\n @stdout \"baidu.com\""}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"模擬了命令 "},{"type":"codeinline","content":[{"type":"text","text":"curl --silent google.com"}]},{"type":"text","text":" 的執行結果是輸出 "},{"type":"codeinline","content":[{"type":"text","text":"baidu.com"}]},{"type":"text","text":"。在真實的正常場景下,我們是無法做到訪問 "},{"type":"codeinline","content":[{"type":"text","text":"google.com"}]},{"type":"text","text":" 得到的是 "},{"type":"codeinline","content":[{"type":"text","text":"baidu.com"}]},{"type":"text","text":"。這樣模擬之後就可以用來驗證 Bash 腳本中處理一個命令不同響應時的行爲了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"@mock"}]},{"type":"text","text":" API 甚至還支持更復雜的行爲模擬,我們可以自定義一個複雜的模擬邏輯,比如:"}]},{"type":"codeblock","attrs":{"lang":"shell"},"content":[{"type":"text","text":"\n@mock ls <
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章