善用 JUnit XML 格式,將各種 Reports 整合至 GitLab CI Pipeline

既然 GitLab CI 能接受 Junit XML 格式的 Report,那我們可以善用這一點,將各種 Reports 整合至 GitLab CI Pipeline 中,方便團隊直接在同一個介面上瀏覽自動化測試的結果。

前言

上一篇文章,我介紹了在 GitLab CI Pipeline 執行 Unit test,本文繼續延續上篇文章的內容,這次我們要進一步的善用 GitLab CI 的 artifacts:reports:junit 功能。

如同我在上一篇文章說明的,GitLab CI 能夠解讀 JUnit XML 格式的 Report,將內容直接整合在 CI Pipeline 的 UI 上。既然如此,是不是我可以不限於 Unit test 的 report,而是將任何存成 JUnit XML 格式的內容都丟給 GitLab CI 呢?就讓我們來實驗看看吧!

操作步驟

實驗一:GitLab CI 是否接受,非 Unit test tool 產出的 JUnit XML

第一個實驗,讓我們找一個不是 Unit test,卻又能產出 JUnit XML 格式的工具來實驗,看看 GitLab CI 是否接受該工具產出的 XML。

這裡我挑中的是 Python 圈的一個新工具 ruff

自從 Rust 這個程式語言開始大紅大紫之後,大家似乎都看上 Rust 在 Performance 所帶來的優異表現,於是各種工具都紛紛開始出現有人用 Rust 重新撰寫,而 ruff 就是其中一個例子,它就是用 Rust 所開發給 Python 使用的 Linter。

(你看看 ruff 官網上的標語,就是這麼直白 An extremely fast Python linter, written in Rust.

ruff 的使用方式與其他的 Linter 沒有太大差異,這裡就直接示範我們會用到的 Command。

# 直接執行 ruff check
# 餵給它要檢查的程式碼路徑
# -o 表示要輸出為檔案,並決定檔名
# --output-format 控制要輸出為哪種格式,這裡當然要選 junit
ruff check /path-to-code/ -o linter-report.xml --output-format=junit

讓我將它放進 .gitlab-ci.yml 中。

python linter:
  image: python:3.12-alpine3.18
  stage: test
  script:
  # 先用 pip 安裝 urff
  - pip install ruff
  - ruff check . -o ruff.xml --output-format=junit
  artifacts:
    reports:
      junit: ruff.xml

搭配一段一定會被 ruff 檢查出問題的 Python code。

import os
import sys

print(sys.version)

讓 CI Pipeline 執行看看吧!

(在 CI Job,有順利執行 ruff,並產出 Report 上傳到 Job Artifats。)

(在 CI Pipeline 介面也確實有出現 Report 的內容。)

(點開 Detail,也能正確看到 ruff 查到的問題。)

根據上面的實驗可以發現,就算不是 Unit test tool 產出的 JUnit XML 檔案,GitLab CI 也是能順利整合進 CI Pipeline 的介面。

實驗二:手動隨便做一個假的 JUnit XML,看看 GitLab CI 是否接受

接著第二個實驗,我打算手動隨便亂作一個 JUnit XML 檔案,測試看看 GitLab CI 是否一樣能整合進 CI Pipeline 的介面中。為此,首先我要先了解一下 JUnit XML 到底規範了怎麼樣的格式與內容。

根據 testmoapp/junitxml 的介紹,我們可以了解到最基本的 JUnit XML 格式包含以下內容。

<!-- 首先記得要宣告 xml version 與 encoding -->
<?xml version="1.0" encoding="UTF-8"?>

<!-- 建立最上層的 tag:testsuites -->
<testsuites>
  <!-- 開始撰寫每一個 testsuite 的結果。要寫明跑了多少個 Test,有多少是失敗的-->
  <testsuite name="TestSuite1" tests="2" failures="0" errors="0" skipped="0">
    <properties>
      <!-- 這可以塞入更多測試相關資訊 -->
      <property name="version" value="20231119" />
      <property name="commit" value="abcdef" />
    </properties>
    <!-- testsuite 裡面可以有多個 testcase -->
    <testcase name="TestCase1" classname="TestClass1" time="0.1">
        <!-- 如果是失敗的 Test,可以寫明它的失敗原因  -->
        <failure message="say something">say something</failure>
    </testcase>
    <testcase name="TestCase2" classname="TestClass2" time="0.2">
        <failure message="say something">say something</failure>
    </testcase>
  </testsuite>
</testsuites>

就讓我按著上面的範例,做一個假的 JUnit XML 檔案吧!

<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
  <testsuite name="CaptainTest1" tests="3" failures="0" errors="1" skipped="0">
    <properties>
      <property name="user" value="ChengWei" />
      <property name="nickname" value="Captain" />
      <property name="jobtitle" value="GitLab Hero" />
    </properties>
    <testcase name="TestCase1" classname="TestClass1" time="0.1">
        <failure message="Captain Say">Use the force, Harry</failure>
    </testcase>
    <testcase name="TestCase2" classname="TestClass2" time="0.2">
    </testcase>
    <testcase name="TestCase3" classname="TestClass3" time="0.3">
    </testcase>
  </testsuite>
  <testsuite name="CaptainTest2" tests="1" failures="0" errors="1" skipped="0">
    <properties>
    </properties>
    <testcase name="TestCase4" classname="TestClass4" time="0.1">
        <failure message="Hello GitLab">Hello GitLab Hero</failure>
    </testcase>
  </testsuite>
</testsuites>

既然是假的 JUnit XML,當然就給它一個假的 CI Job 啦!fake-junit.xml 檔案我就直接放進 Project 了。

fake junit xml:
  stage: test
  script:
  - echo "demo fake JUnit XML"
  artifacts:
    reports:
      junit: fake-junit.xml

當 CI Pipeline 執行後,得到下面的結果!

(CI Pipeline 的介面依然有正確顯示 Report 內容。)

(吻合我設計的 JUnit XML 內容,有兩個 Test 沒通過。)

(也同樣吻合我設計的內容,在 Detail 有顯示我撰寫的文字。)

經過上面的測試,我也證實了只要是吻合正確的 JUnit XML 格式的檔案,GitLab CI 都是能接受的!

實驗三:讓我們將某個 Text 格式的 Report 轉成 JUnit XML 再交給 GitLab CI

延續前面兩個實驗,既然 GitLab CI 可以接受任何的 JUnit XML 檔案,那我們只要有辦法將其他的 Report 轉成 JUnit XML,不就可以通通整合進 GitLab CI Pipeline 的介面了。因此就讓我們挑一個目前尚未提供 JUnit XML 格式的工具,然後將 Report 轉為 JUnit XML 實驗一下吧!

這裡我挑選的工具是之前介紹過的 OSV-Scanner,它是一個可以用來掃描相依套件是否存有漏洞的檢查工具。

在今天 2023-11-19 為止,OSV-Scanner 的最新版本 v1.4.3 依然無法輸出 JUnit XML 格式的 Report,因此它剛好是一個合適的實驗對象。

首先我自己 build 了一個 OSV-Scanner v1.4.3 的 docker image,並準備了一個一定會掃出問題的 package-lock.json(裡面列了許多超舊的 nodejs package)。

接著讓它用 --format=table 輸出一次 Text 格式的 Report,我們先查看一下其中的結構與內容。

你可能會好奇為什麼不產出 .json 格式的 Report?這是因為 OSV-Scanner 在產出 .json 的 Report 時,裡面會直接塞滿來自 Vulnerability Database 大量且完整的資訊,如果要將那些內容轉成 JUnit XML 會有一點辛苦;反之如果是 --format=table 產出的內容則非常單純,很適合用來當作這次實驗的對象,下面列出一小段內容當作範例。

Scanning dir /path/
Scanned /path/package-lock.json file and found 1014 packages
+-------------------------------------+------+-----------+----------------------+---------+----------------------+
| OSV URL                             | CVSS | ECOSYSTEM | PACKAGE              | VERSION | SOURCE               |
+-------------------------------------+------+-----------+----------------------+---------+----------------------+
| https://osv.dev/GHSA-67hx-6x53-jw92 | 9.3  | npm       | @babel/traverse      | 7.11.0  | ../package-lock.json |
| https://osv.dev/GHSA-whgm-jr23-g3j9 | 7.5  | npm       | ansi-html            | 0.0.7   | ../package-lock.json |
###### 中略 ######
+-------------------------------------+------+-----------+----------------------+---------+----------------------+

如上範例,我打算將這些內容轉換成如下的 JUnit XML。

<?xml version='1.0' encoding='utf8'?>
<testsuites>
    <testsuite name="Vulnerability">
        <testcase classname="npm" name="https://osv.dev/GHSA-67hx-6x53-jw92">
            <failure message="">Package: @babel/traverse, Version: 7.11.0</failure>
        </testcase>
    </testsuite>
    <testsuite name="Vulnerability">
        <testcase classname="npm" name="https://osv.dev/GHSA-whgm-jr23-g3j9">
            <failure message="">Package: ansi-html, Version: 0.0.7</failure>
        </testcase>
    </testsuite>
</testsuites>

由於結構單純,所以我用 Python 寫了一個簡單的轉換程式。

import xml.etree.ElementTree as ET

data = []
with open("osv-report.txt", "r") as f:
    lines = f.readlines()
    lines = [line for line in lines if not line.startswith("Scann")]
    lines = lines[3:]
    lines = lines[:-1]

    for line in lines:
        data.append(line.split("|"))

testsuites = ET.Element("testsuites")

for d in data:
    testsuite = ET.SubElement(testsuites, "testsuite", name="Vulnerability")
    testcase = ET.SubElement(testsuite, "testcase", classname=d[3].strip(), name=d[1].strip())

    failure = ET.SubElement(testcase, "failure", message=d[0].strip())
    failure.text = f"Package: {d[4].strip()}, Version: {d[5].strip()}"

xml_str = ET.tostring(testsuites, encoding='utf8').decode('utf8')

with open("osv-report.xml", "w") as f:
    f.write(xml_str)

最後組裝一條 CI Pipeline 來完成實驗。

osv scanner:
  # 使用我自己 build 的 docker image
  image: chengweisdocker/osv-scanner-for-ci:1.4.3
  stage: test
  script:
  - set +e
  - cd $CI_PROJECT_DIR
  # 掃描並產出 text report  
  - /root/osv-scanner -r --format=table --output=osv-report.txt .
  - cat osv-report.txt
  # 上傳到 Job Artifact 中歸檔
  artifacts:
    paths:
    - osv-report.txt

OSV Text to JUnit:
  # 把我的轉換程式塞進去做成了一個 docker image 
  image: chengweisdocker/osv-text-to-junit:demo
  stage: test
  variables:
    # 這個 Job 不需要 project 內的其他檔案,因此設置為 none 來 skip git 動作
    GIT_STRATEGY: none
  # 相依上一個 Job
  needs:
    - "osv scanner"
  # 需要上一個 Job 的 artifacts
  dependencies:
    - "osv scanner"
  script:
  # 執行我的轉換程式
  - cd $CI_PROJECT_DIR
  - python /text-to-junit.py
  # 上傳 artifacts
  artifacts:
    reports:
      junit: osv-report.xml

CI Pipeline 成功執行,如下圖獲得了我想要的結果。

(先執行 osv scanner,然後下一個 Job 會接著把 Report 轉為 JUnit XML。)

(Job: osv scanner 會將 text 格式的 report 上傳到 Job Artifact。)

(Job: OSV Text to JUnit 會先取得前一個 Job 的 Artifact,然後將它轉換為 JUnit XML,並上傳到 artifacts:reports:junit。)

(如我所願的,在 CI Pipeline 介面上有顯示 OSV Scanner Report 的內容。)

(Detail 的內容也吻合我的規劃,會顯示有漏洞的 Package Name 與 Version。)

小結

在本文我們嘗試善用 GitLab CI 的 artifacts:reports:junit 功能,將非 Unit test 的 Report 順利整合在 CI Pipeline 介面上。

雖然這個做法,跟 GitLab 付費功能相比還是稍微陽春且遜色了一些,但我覺得依然勝過「必須先下載 Report」或「必須進入到特定 CI Job 查看 CI Log」才能看到 Report 內容的原始方法。

最後要提醒一下,本文最後一個實驗所使用的 Python 程式,它並非是一個完成版的程式,它僅能適用於本文中的 package-lock.json。如果你使用 OSV Scanner 去掃描自己的專案時,可能會發現你輸出的 Text 排版格式跟本文中的範例會有一些差異,例如你可能會遇到 PACKAGE 與 VERSION 這兩欄在某幾行被合併的狀況。因此如果你真的打算自己轉換 OSV Scanner 的 Report 為 JUnit XML 格式,記得要多做一些實驗,確認 OSV Scanner 到底會輸出哪些內容喔!(或者你可以挑戰看看將 Json 格式轉為 JUnit XML)

本文就到此結束啦!如果你也有其他善用 artifacts:reports:junit 功能的經驗,歡迎與我分享交流喔!

參考資料

轉貼本文時禁止修改,禁止商業使用,並且必須註明來自「艦長,你有事嗎?」原創作者 Cheng Wei Chen,及附上原文連結。

工商服務

更多文章