這篇是記錄我在看鐵人賽 Python 與自動化測試的敲門磚 的過程

CI/CD 簡介

CI/CD 其實是指兩個部分,分別是 Continuous Integration (持續整合) 以及 Continuous Deployment (持續部屬)

CI 持續整合

在軟體開發的過程中,通常會由無數個開發人員一起工作,然而隨著程式碼以及人數的增加,專案會越來越難進行整合,這個時候我們就可以透過 CI 來進行。與其說 CI 是個工具,不如說 CI 是一種合作模式,藉由簡單的設定來讓 CI 工具替我們進行測試,就可以降低我們的專案在進行更新、整合時碰到問題的機率。

在 CI 執行的過程中,會建議每個開發人員每天上班前先做一次 pull 的動作,於每天下班前至少執行一次 push 的動作,以此確保 CI 的運行效率。

CD 持續部屬

每當我們透過 CI 將專案整合完成後,便可以透過 CD 來進行自動化的部屬,減少我們在測試與部屬之間所耗費的時間

CI/CD 常用工具

GitLab

於 GitLab 上提供了 CI/CD 的介面,藉由在某處部屬好的 docker image,即可進行 CI/CD,為目前主流的 CI/CD 工具

GitHub

GitHub 於 2019 年推出了 GitHub Actions,此工具可以協助我們進行 CI,不過由於此功能還算新,尚有許多功能不成熟,因此比較建議使用在小型專案的 CI 當中

Jenkins

為目前主流的 CD 工具,藉由 GitLab 進行 CI 後,會將程式碼打包到 Jenkins 進行自動化部屬

GitHub 設定

建立 yml file

進入 github 專案並點選 Actions 選項

需要點擊啟用 Actions 設定


點擊 New Workflow

在 Workflows 欄位底下輸入 “python” 進行搜尋

選擇 Python Application,並點選 Configure 選項

將跳轉後出現的頁面的程式碼滑到最底下,並將 pytest 更改為 echo “hello”

點選右上方 “Start Commit” 選項

點選 “Commit new file” 選項

測試若有設定過會出錯,需要新建立一個repository才會成功

頁面會自動跳轉回專案首頁,請點選回 “Actions”,就可以看到 github 按照剛剛建立的檔案建立了一個 CI 任務

點進此任務,並選擇 build 選項

點選 “Test with pytest” 部分即可看到剛剛修改的 echo “hello”

修改 yml

由於我們剛剛是直接在 github 上進行 commit 來新增 workflows,因此我們要先回到專案上執行 git pull 確保專案同步

執行完 git pull 後即可在專案上看到 “.github” 這個目錄,並且有 “workflows” 這個子目錄

“workflows” 目錄下會有一個 python-app.yml 檔案,這個就是我們等一下要修改的檔案

在修改 yml 前我們先建立幾個簡單的 test case 並存放到 day_22/test_demo.py 目錄下

1
2
3
4
5
6
7
8
9
10
11
12
def test_export_report_1():
a = 1 + 1
b = 2 + 2

assert b > a


def test_export_report_2():
a = 2 + 2
b = 4 + 4

assert a < b

打開 python-app.yml

將剛剛的 echo “hello” 修改為 pytest -s -v ./day_22/test_demo.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions

name: Python application

on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]

permissions:
contents: read

jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: Set up Python 3.10
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest -s -v ./day_22/test_demo.py

察看結果

修改完成後,我們就可以將整個專案 push 到 Github 上,這個時候我們就可以回到剛剛的 “Actions” 頁面察看結果了

下圖中我們可以看到 Acitons 這邊多了一個任務,任務名稱會使用剛剛的 commit message 命名

點進去該任務後就可以查看 pytest 的執行結果了

python-app.yml

運行於指定的 branch 上

我們可以透過 on 參數來設定當哪個 branch 被推上 Github 上時要進行 CI 的動作,由於 CI 的進行也是需要耗費一定的資源,因此不太可能讓所有瑣碎的 branch 被推上去時都自動進行一次,透過設定 branches 可以指定那些 branch 被推上來時要執行 CI

補充:可以看到 on 底下有分為 push 以及 pull_request 兩個層級,分別代表著 Github 上的兩種協作方式

1
2
3
4
5
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]

建立 CI 任務

變數 功能
runs-on 為 CI 執行時最底下的一個 docker image 名稱,設定完成後 CI 會於執行時使用該 image 建立環境
steps 為實際 CI 執行的細項,每一個細項開頭都會使用 “-“ 來表示
uses 為為運行此 steps 時啟動的服務,為 docker image 名稱
name 為此 step 的名稱
run 為此 step 要執行的命令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Set up Python 3.10
uses: actions/setup-python@v3
with:
python-version: "3.10"

- name: Test with pytest
run: |
pytest -s -v ./day_22/test_demo.py

GitLab 設定

建立 CI/CD runner

在 GitLab 運行 CI/CD 時,同樣需要一個 runner 在背後執行,只是在 GitHub 的時候,他們幫我們做好了,因此我們不需要去碰到這塊,下面的方法為建立 runner 並和 GitLab 上的專案綁定的方法

接下來的步驟請先在要建立 runner 的電腦上安裝 docker

前往 GitLab 專案的 Setting 裡面的 CI/CD 頁面

Setting > CI/CD

找到 Runner 部分並打開,可以看到要和 runner 做連線的網址以及 token

打開 terminal

建立 docker volume

1
docker volume create gitlab-runner

啟動 docker 並將他連上剛剛建立的 volume

1
docker run -d --name gitlab-runner --restart always -v /var/run/docker.sock:/var/run/docker.sock -v gitlab-runner:/etc/gitlab-runner gitlab/gitlab-runner:latest

使用下方指令進行 runner 與 GitLab 的連線

1
docker run --rm -it -v gitlab-runner:/etc/gitlab-runner gitlab/gitlab-runner:latest register

接下來會開始進行註冊程序,我們會以一個問題搭配一個回答的方式撰寫

註冊程序 回答
Enter the GitLab instance URL 寫上剛剛在 GitLab 上看到的 url
Enter the registration token 寫上剛剛在 GitLab 上看到的 token
Enter a description for the runner 寫上你想要為這個 runner 的描述,這邊使用 “nickchen1998_ithelp_2022_marathon”
Enter tags for the runner 為這個 runner 增加 tag,用來指派 CI/CD 任務用,這邊先寫上 “nickchen1998_ithelp_2022_marathon”
Enter optional maintenance note for the runner 這部份我們不需要,直接按 Enter
Enter an executor 這邊我們輸入 “docker”,用來作為 runner 運行的環境
Enter the default Docker image 當 yaml 檔沒有指定要使用的 image 時,預設會使用的 image,這邊我們輸入 “python:latest”

註冊完成

回到剛剛的 runner 頁面,我們就可以看到一個新的 runner 被建立給這個專案

設定環境變數

我們可以透過 Settings 內的 CI/CD 頁面裡面的 Variables 欄位進行環境變數的設定

Settings > CI/CD > Variables

點選 Add Variable 開始設定環境變數

依序輸入 key、value 並點選 Add Variable

可以看到成功新增的變數

設定 yaml 檔案

建立 .gitlab-ci.yml

在整個專案的 “最外層” 建立 gitlab-ci.yml 檔案

建立流程

直接將工作名稱寫在最外層,並於其下一層利用 stage 表示此工作階段的名稱

1
2
execute-test:
stage: test

將要執行的指令依序寫在 scripts 後方

1
2
3
4
5
execute-test:
stage: test
script:
- pip3 install pytest
- pytest -s -v ./day_25/test_demo.py

最後利用 tag 指定我們要執行這個任務的 runner (昨天文章有提到該如何建立 runner)

1
2
3
4
5
6
7
execute-test:
stage: test
script:
- pip3 install pytest
- pytest -s -v ./day_25/test_demo.py
tags:
- nickchen1998_ithelp_2022_marathon

管理 stage

透過設定 stage 可以來管理我們要執行哪個部份的腳本

透過下面這段程式碼,我們就成功設定執行所有 stage 為 test 的任務了

1
2
3
4
5
6
7
8
9
10
stages:
- test

execute-test:
stage: test
script:
- pip3 install pytest
- pytest -s -v ./day_25/test_demo.py
tags:
- nickchen1998_ithelp_2022_marathon

成果展示

可以看到下圖中 GitLab 順利為我們生成一條 CI pipline

這邊要注意 runner 要指定對並且是執行中才會成功,不然會 Pending

Selenium 設定

修改 .gitlab-ci.yml 設定檔案

首先我們要替我們的 .gitlab-ci.yml 加上 service 表示我們要在指定的任務中運行其他服務

1
2
3
4
5
6
7
8
9
10
11
12
13
stages:
- test

execute-test:
stage: test

services:

script:
- pip3 install -r ./requirements.txt
- pytest -s -v ./day_26/test_demo.py
tags:
- nickchen1998_ithelp_2022_marathon

接著透過設定 services 底下的 name 參數來指定我們要使用哪個 docker image,我們會需要使用到 selenium/standalone-chrome 這個 image 來替我們建立一個可以遠端執行 Chrome 的環境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
stages:
- test

execute-test:
stage: test

services:
- name: selenium/standalone-chrome

script:
- pip3 install -r ./requirements.txt
- pytest -s -v ./day_26/test_demo.py
tags:
- nickchen1998_ithelp_2022_marathon

最後透過設定 alias 參數來為這個 services 進行命名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
stages:
- test

execute-test:
stage: test

services:
- name: selenium/standalone-chrome
alias: CICD_Selenium

script:
- pip3 install -r ./requirements.txt
- pytest -s -v ./day_26/test_demo.py
tags:
- nickchen1998_ithelp_2022_marathon

script 方面我有實際測試,
pip3 install -r ./requirements.txt
這行實際運行會出錯,我後來調整成這樣可以運行成功

script:
- pip3 install pytest
- pip3 install selenium
- pip3 install webdriver_manager
- pytest -s -v ./day_26/test_demo.py

建立 fixture

建立一個 conftest.py 並將 driver 的 fixture 寫在裡面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import sys
import pytest
from typing import Union
from selenium.webdriver import Remote, Chrome
from selenium.webdriver.chrome.options import Options
from webdriver_manager.chrome import ChromeDriverManager


@pytest.fixture(name="driver")
def driver_fixture() -> Union[Remote, Chrome]:
options = Options()
options.add_argument("--headless")
options.add_argument(f"user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
f"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36")

if sys.platform == "win32":
driver = Chrome(ChromeDriverManager().install(),
options=options)
else:
driver = Remote(command_executor="http://CICD_Selenium:4444/wd/hub",
options=options)

yield driver

driver.quit()

撰寫測試程式

1
2
3
4
5
6
7
8
9
10
11
12
from selenium.webdriver import Remote, Chrome
from typing import Union


def test_current_url(driver: Union[Remote, Chrome]):
driver.get("https://ithelp.ithome.com.tw/")

correct_url = "https://ithelp.ithome.com.tw/"
assert driver.current_url == correct_url

correct_title_start = "iT 邦幫忙"
assert driver.title.startswith(correct_title_start)