構(gòu)建和測(cè)試環(huán)境即代碼和基礎(chǔ)設(shè)施即代碼,流水線即代碼的概念相似,就是將你的代碼構(gòu)建和測(cè)試環(huán)境使用代碼來(lái)表達(dá),以達(dá)到代碼在本機(jī)正常工作...同時(shí)在任何地方都可以正常工作。
薛定諤的測(cè)試
回想這樣一個(gè)場(chǎng)景,當(dāng)你加入一個(gè)新公司或者加入一個(gè)新項(xiàng)目的時(shí)候,是不是需要安裝很多軟件以使得新項(xiàng)目的服務(wù)或者測(cè)試可以在你本機(jī)運(yùn)行?
假設(shè)這個(gè)新項(xiàng)目使用的編程語(yǔ)言是NodeJs,使用Mysql存儲(chǔ)數(shù)據(jù),那么很可能會(huì)出現(xiàn)如下情況,每個(gè)開(kāi)發(fā)人員本機(jī)安裝的軟件版本都不太相同:
- 開(kāi)發(fā)小A本機(jī)環(huán)境:NodeJS 8.1, MySQL 5.4
- 開(kāi)發(fā)小B本機(jī)環(huán)境:NodeJS 8.1, MySQL 5.4
- 開(kāi)發(fā)小C本機(jī)環(huán)境:NodeJS 9.2, MySQL 5.2
- 開(kāi)發(fā)小D本機(jī)環(huán)境:NodeJS 12.4, MySQL 5.1
這就是為什么有時(shí)候你編寫(xiě)好的代碼或者測(cè)試在你本機(jī)運(yùn)行成功,但到了別人的機(jī)器上就會(huì)出問(wèn)題,原因很可能是你的代碼依賴了你本地開(kāi)發(fā)環(huán)境的軟件版本。即便你依賴的軟件版本相同,依然可能會(huì)出現(xiàn)錯(cuò)誤,因?yàn)槟惚镜卮a編譯環(huán)境很可能會(huì)依賴于你的操作系統(tǒng)的某些軟件。
這個(gè)問(wèn)題甚至曾經(jīng)在Google也非常嚴(yán)重。在2016的時(shí)候,Google就發(fā)現(xiàn)84%新的失敗測(cè)試都是由于flakiness。什么是flakiness呢?
一個(gè)flaky測(cè)試就是在相同的配置的情況下這個(gè)測(cè)試可能會(huì)成功也可能會(huì)失敗.
這個(gè)測(cè)試是不是很像薛定諤的貓,不運(yùn)行的時(shí)候測(cè)試狀態(tài)處于疊加狀態(tài),既可以是成功也可以是失敗,當(dāng)你運(yùn)行之后才知道是成功還是失敗。自動(dòng)化測(cè)試重要性不必多說(shuō),但flaky測(cè)試顯然令開(kāi)發(fā)人員很疑惑,或者說(shuō)是沮喪。產(chǎn)生flaky測(cè)試的原因有很多,而不一致的測(cè)試環(huán)境常常會(huì)導(dǎo)致測(cè)試的flakiness。
如何解決測(cè)試環(huán)境不一致的問(wèn)題?
自動(dòng)化一切事情
將所有手動(dòng)的步驟使用腳本將其過(guò)程自動(dòng)化起來(lái)使得構(gòu)建和測(cè)試的步驟都是一致的。但光使用自動(dòng)化腳本是不夠的,它的問(wèn)題在于:
- 所依賴的運(yùn)行環(huán)境的操作系統(tǒng)不一致可能會(huì)導(dǎo)致一些問(wèn)題。
- 端口沖突問(wèn)題。
- ...
虛擬機(jī)
使用虛擬機(jī)(如Vagrant)可以解決只使用自動(dòng)腳本所存在的問(wèn)題。但是使用虛擬機(jī)是非常消耗資源的,對(duì)于運(yùn)行在虛擬機(jī)里的任務(wù)的性能影響是比較大的。所以通常虛擬機(jī)并不被推薦在持續(xù)集成服務(wù)器上使用。
共享的測(cè)試和集成環(huán)境
我經(jīng)歷過(guò)一些項(xiàng)目,項(xiàng)目中只有單元測(cè)試是可以在本地運(yùn)行的,而其他的測(cè)試都需要將代碼部署到云環(huán)境后才會(huì)運(yùn)行。這種方式有幾個(gè)特別大的缺點(diǎn):
- 從代碼更改到在測(cè)試環(huán)境中運(yùn)行的周期時(shí)間太長(zhǎng)。
- 由于測(cè)試和集成環(huán)境是共享的,所以你對(duì)測(cè)試環(huán)境會(huì)影響會(huì)影響到其他開(kāi)發(fā)人員的測(cè)試。這就好比你的測(cè)試依賴于一個(gè)全局變量,而其他人可以隨時(shí)修改這個(gè)變量,那么你的測(cè)試狀態(tài)就是不穩(wěn)定的。
而且,實(shí)現(xiàn)持續(xù)集成的一個(gè)前提條件就是:確保自動(dòng)化測(cè)試可以在一個(gè)隔離的環(huán)境中運(yùn)行。
Docker
使用Docker容器技術(shù)可以解決使用虛擬機(jī)所存在的問(wèn)題。使用docker可以輕松解決一個(gè)容器的問(wèn)題,但是現(xiàn)實(shí)項(xiàng)目中的情況是:
- 一個(gè)應(yīng)用由多個(gè)容器組成。
- 一個(gè)容器會(huì)依賴于另一個(gè)容器。
- 容器的啟動(dòng)順序是有依賴的。
- ...
舉個(gè)栗子:Docker就像是一個(gè)獨(dú)奏的音樂(lè)家,而現(xiàn)實(shí)項(xiàng)目需要的一個(gè)合奏團(tuán)。

Docker + 基礎(chǔ)設(shè)施即代碼 + 工具
基礎(chǔ)設(shè)施即代碼的概念 + Docker + Docker工具(比如:Docker Compose, Batect, Dojo), 將這些柔和到一起并運(yùn)用到本地開(kāi)發(fā)和測(cè)試環(huán)境中即可實(shí)現(xiàn)構(gòu)建和測(cè)試環(huán)境即代碼,它可以給我們帶來(lái)一些好處:
- 給自動(dòng)化測(cè)試提供一個(gè)隔離的運(yùn)行環(huán)境。
- 云環(huán)境和本地只需要安裝Docker,和Git便可拉取代碼并進(jìn)行構(gòu)建和測(cè)試。
- 由于配置以可執(zhí)行代碼而不是文檔的形式表示,因此與應(yīng)用程序一起分發(fā)和版本化配置更加容易。
- 測(cè)試一次運(yùn)行成功,在不修改任何代碼的情況下任何地方都可以運(yùn)行成功。
構(gòu)建和測(cè)試環(huán)境即代碼工具
現(xiàn)在比較流行的容器編排工具有: Docker Compose, Dojo和Batect。我們?cè)陧?xiàng)目中選擇使用了Batect,Batect的全名是:Build And Test Environment as Code Tools。選擇它的原因是:
- 執(zhí)行速度比Docker Compose快17%。主要是對(duì)Mac Docker和Window Docker的優(yōu)化。
- 提供了development task的一種概念。
- 非常便于將你的development task配置分享給其他的項(xiàng)目。
- 提供了一種簡(jiǎn)單的方式可以幫助開(kāi)發(fā)者發(fā)現(xiàn)當(dāng)前項(xiàng)目都有哪些可以使用的task.
- 無(wú)需安裝。
示例
假設(shè)我們已經(jīng)建立了一個(gè)國(guó)際轉(zhuǎn)賬服務(wù)(international transfer service),該服務(wù)依賴于另一個(gè)團(tuán)隊(duì)維護(hù)的匯率服務(wù)(Exchange Rate Service),并且國(guó)際轉(zhuǎn)賬服務(wù)擁有自己的數(shù)據(jù)存儲(chǔ)區(qū)來(lái)跟蹤客戶的轉(zhuǎn)賬。如果要端到端測(cè)試,我們需要四個(gè)組件:
- International transfers service。
- fake的Exchange rate service。
- fake的database。
- Test driver。

我們期望項(xiàng)目的測(cè)試包含:
- unit test,unit test直接在test dirver容器中運(yùn)行(此項(xiàng)目使用java,所以這個(gè)test driver是一個(gè)擁有java構(gòu)建環(huán)境的docker容器)。
- integration test,integration test需要提前啟動(dòng)Exchange rate service和database容器,等待Exchange rate service和database容器啟動(dòng)成功,并正常運(yùn)行后,在test dirver容器中運(yùn)行集成測(cè)試。
- journey test,journey test需要提前啟動(dòng)Exchange rate service, database和International transfers service容器,等待Exchange rate service, database和International transfers service容器啟動(dòng)成功,并正常運(yùn)行后,在test driver容器中運(yùn)行journey測(cè)試。
如何使用Batect作為構(gòu)建和測(cè)試環(huán)境即代碼工具呢?完整代碼可以到Github代碼庫(kù)中查看。
這里我們主要看看Batect的配置文件:batect.yml
project_name: international-transfers-service
containers:
database:
build_directory: .batect/database
environment:
POSTGRES_USER: international-transfers-service
POSTGRES_PASSWORD: TheSuperSecretPassword
POSTGRES_DB: international-transfers-service
exchange-rate-service:
build_directory: .batect/exchange-rate-service-fake
international-transfers-service:
build_directory: .batect/international-transfers-service
dependencies:
- database
- exchange-rate-service
tasks:
build:
description: Build the application.
group: Build tasks
run:
container: java-build-env
command: ./gradlew shadowJar
unitTest:
description: Run the unit tests.
group: Test tasks
run:
container: java-build-env
command: ./gradlew test
integrationTest:
description: Run the integration tests.
group: Test tasks
dependencies:
- database
- exchange-rate-service
run:
container: java-build-env
command: ./gradlew integrationTest
journeyTest:
description: Run the journey tests.
group: Test tasks
prerequisites:
- build
dependencies:
- international-transfers-service
run:
container: java-build-env
command: ./gradlew journeyTest
run:
description: Run the application.
group: Test tasks
prerequisites:
- build
run:
container: international-transfers-service
ports:
- local: 6001
container: 6001
include:
- type: git
repo: https://github.com/batect/java-bundle.git
ref: 0.1.0
這個(gè)yml中主要分3大部分:containers, tasks and include
containers
containers配置,顧名思義就是用來(lái)配重容器的地方。這里我們配置了3個(gè)容器,這里的配置基本和Docker Compose是一樣的:
- international-transfers-service, 對(duì)應(yīng)圖中的International transfers service。.
- database, 對(duì)應(yīng)到圖中的database。
- exchange-rate-service,對(duì)應(yīng)到圖中Exchange rate service。
tasks
task是Batect中最小的工作單元。通過(guò)在項(xiàng)目中運(yùn)行./batect --list-tasks可以非常方便的發(fā)現(xiàn)所有tasks:
$ ./batect --list-tasks
Build tasks:
- build: Build the application.
Test tasks:
- integrationTest: Run the integration tests.
- journeyTest: Run the journey tests.
- run: Run the application.
- unitTest: Run the unit tests.
通過(guò)./batect integrationTest便可執(zhí)行integrationTest:
$ ./batect integrationTest
Running integrationTest...
database: running
exchange-rate-service: running
java-build-env: running ./gradlew integrationTest
BUILD SUCCESSFUL in 6s
3 actionable tasks: 3 up-to-date
integrationTest finished with exit code 0 in 11.7s.
運(yùn)行時(shí),可以看到。在執(zhí)行integrationTest之前,它會(huì)首先啟動(dòng)containers: database和exchange-rate-service,然后等待這兩個(gè)容器的狀態(tài)變得health之后才開(kāi)始在java-build-env這個(gè)容器中運(yùn)行./gradlew integrationTest。而這些都是在integraionTest這個(gè)task中定義好的:
tasks:
integrationTest:
description: Run the integration tests.
group: Test tasks
dependencies:
- database
- exchange-rate-service
run:
container: java-build-env
command: ./gradlew integrationTest
task中的run部分用來(lái)配置要執(zhí)行task所使用的容器是什么,執(zhí)行的命令是什么。這種container配置和task分離的方式可以很方便的重用container來(lái)執(zhí)行不同的task。
在task中還可以指定dependencies,dependencies中配置的容器會(huì)提前執(zhí)行,并且只有在他們的staus變health之后,task才開(kāi)始執(zhí)行。
batect如何知道容器變health了呢?它是通過(guò)docker中的HEALTHCHECK來(lái)定義的。
在task中還可以指定prerequisites,prerequisites是用來(lái)指定task的依賴,如: journeyTest:
tasks:
journeyTest:
description: Run the journey tests.
group: Test tasks
prerequisites:
- build
dependencies:
- international-transfers-service
run:
container: java-build-env
command: ./gradlew journeyTest
prerequisites中的task會(huì)在最開(kāi)始執(zhí)行,所以執(zhí)行順序:prerequisites -> prerequisites -> task。從下面的運(yùn)行輸出上也能反映出這個(gè)順序。
./batect journeyTest
Running build...
java-build-env: running ./gradlew shadowJar
BUILD SUCCESSFUL in 5s
2 actionable tasks: 2 up-to-date
build finished with exit code 0 in 8.3s.
Running journeyTest...
database: running
exchange-rate-service: running
international-transfers-service: running
java-build-env: running ./gradlew journeyTest
> Task :journeyTest
com.charleskorn.banking.internationaltransfers.JourneyTest > journeyTest PASSED
BUILD SUCCESSFUL in 11s
2 actionable tasks: 2 executed
journeyTest finished with exit code 0 in 46s.
通過(guò)containers和tasks可以很方便的將構(gòu)建和測(cè)試容器化和代碼化,即As Code。
include
細(xì)心的話,你會(huì)發(fā)現(xiàn)tasks中使用的java-build-env container,我們并沒(méi)定義過(guò)它,它其實(shí)來(lái)源于https://github.com/batect/java-bundle.git。
include:
- type: git
repo: https://github.com/batect/java-bundle.git
ref: 0.1.0
Includes允許您將其他地方定義的配置導(dǎo)入項(xiàng)目。 在許多情況下,它們是一種有用的機(jī)制:
- 使配置文件較小且易于管理。
- 在項(xiàng)目之間共享任務(wù)和容器。
- 利用他人構(gòu)建和維護(hù)的任務(wù)和容器,從而節(jié)省您的時(shí)間和精力。
在docker中運(yùn)行batect
batect依賴也java,如果我們并不想在本機(jī)或者持續(xù)集成服務(wù)器上安裝java的話,我們可以使用docker in docker的方式在docker中運(yùn)行batect,如下,可以在項(xiàng)目中創(chuàng)建/auto/batect:
#! /bin/bash
set -euo pipefail
current_dir=$(cd $(dirname "${BASH_SOURCE[0]}") && pwd)
root_dir=${current_dir}/..
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
-v ${HOME}/.batect:/root/.batect \
-v ${root_dir}:${root_dir} \
-v /tmp:/tmp \
-w ${root_dir} \
openjdk:12 \
./batect --output=simple ${BATECT_ARGS}
然后在使用如下方式運(yùn)行task:
BATECT_ARGS=integrationTest ./auto/batect