使用 go test 框架驅動的自動化測試

一. 背景

團隊的測試人員稀缺,無奈只能 “自己動手,豐衣足食”,針對我們開發的系統進行自動化測試,這樣既節省的人力,又提高了效率,還增強了對系統質量保證的信心

我們的目標是讓自動化測試覆蓋三個環境,如下圖所示:

我們看到這三個環境分別是:

我們會建立統一的用例庫或針對不同環境建立不同用例庫,但這些都不重要,重要的是我們用什麼語言來編寫這些用例、用什麼工具來驅動這些用例

下面看看方案的誕生過程。

二. 方案

最初組內童鞋使用了 YAML 文件 [2] 來描述測試用例,並用 Go 編寫了一個獨立的工具讀取這些用例並執行。這個工具運作起來也很正常。但這樣的方案存在一些問題:

編寫一個最簡單的 connect 連接成功的用例,我們要配置近 80 行 yaml。一個稍微複雜的測試場景,則要 150 行左右的配置。

由於最初的 YAML 結構設計不足,缺少了擴展性,使得擴展用例時,只能重新建立一個用例文件。

我們的系統是消息網關,有些用例會依賴一定的時序,但基於 YAML 編寫的用例無法清晰地表達出這種用例。

如果換一個人來編寫新用例或維護用例,這個人不僅要看明白一個個百十來行的用例描述,還要翻看一下驅動執行用例的工具,看看其執行邏輯。很難快速 cover 這個工具。

爲此我們想重新設計一個工具,測試開發人員可以利用該工具支持的外部 DSL 文法 [3] 來編寫用例,然後該工具讀取這些用例並執行。

注:根據 Martin Fowler 的《領域特定語言》[4] 一書對 DSL 的分類,DSL 有三種選型:通用配置文件 (xml, json, yaml, toml)、自定義領域語言,這兩個合起來稱爲外部 DSL。如:正則表達式、awk, sql、xml 等。利用通用編程語言片段 / 子集作爲 DSL 則稱爲內部 dsl,像 ruby 等。

後來基於待測試的場景數量和用例複雜度粗略評估了一下 DSL 文法 (甚至藉助 ChatGPT 生成過幾版 DSL 文法),發現這個“小語言” 那也是 “麻雀雖小五臟俱全”。如果用這樣的 DSL 編寫用例,和利用通用語言(比如 Python) 編寫的用例在代碼量級上估計也不相上下了。

既然如此,自己設計外部 DSL 意義也就不大了。還不如用 Python 來整。但轉念一想,既然用通用語言的子集了,團隊成員對 Python 又不甚熟悉,那爲啥不回到 Go 呢 ^_^。

讓我們進行一個大膽的設定:將 Go testing 框架作爲 “內部 DSL” 來編寫用例,用 go test 命令作爲執行這些用例的測試驅動工具。此外,有了 GPT-4 加持,生成 TestXxx、補充用例啥的應該也不是大問題。

下面我們來看看如何組織和編寫用例並使用 go test 驅動進行自動化測試。

三. 實現

1. 測試用例組織

我的《Go 語言精進之路 vol2》[5] 書中的第 41 條 “有層次地組織測試代碼”[6] 中對基於 go test 的測試用例組織做過系統的論述。結合 Go test 提供的 TestMain[7]、TestXxx 與 sub test[8],我們完全可以基於 go test 建立起一個層次清晰的測試用例結構。這裏就以一個對開源 mqtt broker 的自動化測試爲例來說明一下。

注:你可以在本地搭建一個單機版的開源 mqtt broker 服務作爲被測對象,比如使用 Eclipse 的 mosquitto[9]。

在組織用例之前,我先問了一下 ChatGPT 對一個 mqtt broker 測試都應該包含哪些方面的用例,ChatGPT 給了我一個簡單的表:

如果你對 MQTT 協議 [10] 有所瞭解,那麼你應該覺得 ChatGPT 給出的答案還是很不錯的。

這裏我們就以 connection、subscribe 和 publish 三個場景 (scenario) 來組織用例:

$tree -F .
.
├── Makefile
├── go.mod
├── go.sum
├── scenarios/
│   ├── connection/              // 場景:connection
│   │   ├── connect_test.go      // test suites
│   │   └── scenario_test.go
│   ├── publish/                 // 場景:publish
│   │   ├── publish_test.go      // test suites
│   │   └── scenario_test.go
│   ├── scenarios.go             // 場景中測試所需的一些公共函數
│   └── subscribe/               // 場景:subscribe
│       ├── scenario_test.go     
│       └── subscribe_test.go    // test suites
└── test_report.html             // 生成的默認測試報告

簡單說明一下這個測試用例組織布局:

// github.com/bigwhite/experiments/automated-testing/scenarios/subscribe/scenario_test.go

package subscribe
  
import (
    "flag"
    "log"
    "os"
    "testing"

    mqtt "github.com/eclipse/paho.mqtt.golang"
)

var addr string

func init() {
    flag.StringVar(&addr, "addr""""the broker address(ip:port)")
}

func TestMain(m *testing.M) {
    flag.Parse()

    // setup for this scenario
    mqtt.ERROR = log.New(os.Stdout, "[ERROR] ", 0)

    // run this scenario test
    r := m.Run()

    // teardown for this scenario
    // tbd if teardown is needed

    os.Exit(r)
}

接下來我們再來看看具體測試 case 的實現。

2. 測試用例實現

我們以稍複雜一些的 subscribe 場景的測試爲例,我們看一下 subscribe 目錄下的 subscribe_test.go 中的測試 suite 和 cases:

// github.com/bigwhite/experiments/automated-testing/scenarios/subscribe/subscribe_test.go

package subscribe

import (
 scenarios "bigwhite/autotester/scenarios"
 "testing"
)

func Test_Subscribe_S0001_SubscribeOK(t *testing.T) {
 t.Parallel() // indicate the case can be ran in parallel mode

 tests := []struct {
  name  string
  topic string
  qos   byte
 }{
  {
   name:  "Case_001: Subscribe with QoS 0",
   topic: "a/b/c",
   qos:   0,
  },
  {
   name:  "Case_002: Subscribe with QoS 1",
   topic: "a/b/c",
   qos:   1,
  },
  {
   name:  "Case_003: Subscribe with QoS 2",
   topic: "a/b/c",
   qos:   2,
  },
 }

 for _, tt := range tests {
  tt := tt
  t.Run(tt.name, func(t *testing.T) {
   t.Parallel() // indicate the case can be ran in parallel mode
   client, testCaseTeardown, err := scenarios.TestCaseSetup(addr, nil)
   if err != nil {
    t.Errorf("want ok, got %v", err)
    return
   }
   defer testCaseTeardown()

   token := client.Subscribe(tt.topic, tt.qos, nil)
   token.Wait()

   // Check if subscription was successful
   if token.Error() != nil {
    t.Errorf("want ok, got %v", token.Error())
   }

   token = client.Unsubscribe(tt.topic)
   token.Wait()
   if token.Error() != nil {
    t.Errorf("want ok, got %v", token.Error())
   }
  })
 }
}

func Test_Subscribe_S0002_SubscribeFail(t *testing.T) {
}

這個測試文件中的測試用例與我們日常編寫單測並沒有什麼區別!有一些需要注意的地方是:

這裏使用了 Test_Subscribe_S0001_SubscribeOK、Test_Subscribe_S0002_SubscribeFail 命名兩個 Test suite。命名格式爲:

Test_場景_suite編號_測試內容縮略

之所以這麼命名,一來是測試用例組織的需要,二來也是爲了後續在生成的 Test report 中區分不同用例使用。

每個 TestXxx 是一個 test suite,而基於表驅動的每個 sub test 則對應一個 test case。

通過 testing.T 的 Parallel 方法可以標識某個 TestXxx 或 test case(subtest) 是否是可以並行執行的。

這樣可以保證 test case 間都相互獨立,互不影響。

3. 測試執行與報告生成

設計完佈局,編寫完用例後,接下來就是執行這些用例。那麼怎麼執行這些用例呢?

前面說過,我們的方案是基於 go test 驅動的,我們的執行也要使用 go test。

在頂層目錄 automated-testing 下,執行如下命令:

$go test ./... -addr localhost:30083

go test 會遍歷執行 automated-testing 下面每個包的測試,在執行每個包的測試時會將 - addr 這個 flag 傳入。如果 localhost:30083 端口並沒有 mqtt broker 服務監聽,那麼上面的命令將輸出如下信息:

$go test ./... -addr localhost:30083
?    bigwhite/autotester/scenarios [no test files]
[ERROR] [client]   dial tcp [::1]:30083: connect: connection refused
[ERROR] [client]   Failed to connect to a broker
--- FAIL: Test_Connection_S0001_ConnectOKWithoutAuth (0.00s)
    connect_test.go:20: want ok, got network Error : dial tcp [::1]:30083: connect: connection refused
FAIL
FAIL bigwhite/autotester/scenarios/connection 0.015s
[ERROR] [client]   dial tcp [::1]:30083: connect: connection refused
[ERROR] [client]   Failed to connect to a broker
--- FAIL: Test_Publish_S0001_PublishOK (0.00s)
    publish_test.go:11: want ok, got network Error : dial tcp [::1]:30083: connect: connection refused
FAIL
FAIL bigwhite/autotester/scenarios/publish 0.016s
[ERROR] [client]   dial tcp [::1]:30083: connect: connection refused
[ERROR] [client]   dial tcp [::1]:30083: connect: connection refused
[ERROR] [client]   Failed to connect to a broker
[ERROR] [client]   Failed to connect to a broker
[ERROR] [client]   dial tcp [::1]:30083: connect: connection refused
[ERROR] [client]   Failed to connect to a broker
--- FAIL: Test_Subscribe_S0001_SubscribeOK (0.00s)
    --- FAIL: Test_Subscribe_S0001_SubscribeOK/Case_002:_Subscribe_with_QoS_1 (0.00s)
        subscribe_test.go:39: want ok, got network Error : dial tcp [::1]:30083: connect: connection refused
    --- FAIL: Test_Subscribe_S0001_SubscribeOK/Case_003:_Subscribe_with_QoS_2 (0.00s)
        subscribe_test.go:39: want ok, got network Error : dial tcp [::1]:30083: connect: connection refused
    --- FAIL: Test_Subscribe_S0001_SubscribeOK/Case_001:_Subscribe_with_QoS_0 (0.00s)
        subscribe_test.go:39: want ok, got network Error : dial tcp [::1]:30083: connect: connection refused
FAIL
FAIL bigwhite/autotester/scenarios/subscribe 0.016s
FAIL

這也是一種測試失敗的情況。

在自動化測試時,我們一般會把錯誤或成功的信息保存到一個測試報告文件 (多是 html) 中,那麼我們如何基於上面的測試結果內容生成我們的測試報告文件呢?

首先 go test 支持將輸出結果以結構化的形式展現,即傳入 - json 這個 flag。這樣我們僅需基於這些 json 輸出將各個字段讀出並寫入 html 中即可。好在有現成的開源工具可以做到這點,那就是 go-test-report[11]。下面是通過命令行管道讓 go test 與 go-test-report 配合工作生成測試報告的命令行:

注:go-test-report 工具的安裝方法:go install github.com/vakenbolt/go-test-report@latest

$go test ./... -addr localhost:30083 -json|go-test-report
[go-test-report] finished in 1.375540542s

執行結束後,就會在當前目錄下生成一個 test_report.html 文件,使用瀏覽器打開該文件就能看到測試執行結果:

通過測試報告的輸出,我們可以很清楚看到哪些用例通過,哪些用例失敗了。並且通過 Test suite 的名字或 Test case 的名字可以快速定位是哪個 scenario 下的哪個 suite 的哪個 case 報的錯誤!我們也可以點擊某個 test suite 的名字,比如:Test_Connection_S0001_ConnectOKWithoutAuth,打開錯誤詳情查看錯誤對應的源文件與具體的行號:

爲了方便快速敲入上述命令,我們可以將其放入 Makefile 中方便輸入執行,即在頂層目錄下,執行 make 即可執行測試:

$make
go test ./... -addr localhost:30083 -json|go-test-report
[go-test-report] finished in 2.011443636s

如果要傳入自定義的 mqtt broker 的服務地址,可以用:

$make broker_addr=192.168.10.10:10083

四. 小結

在這篇文章中,我們介紹瞭如何實現基於 go test 驅動的自動化測試,介紹了這樣的測試的結構佈局、用例編寫方法、執行與報告生成等。

這個方案的不足是要求測試用例所在環境需要部署 go 與 go-test-report

go test 支持將 test 編譯爲一個可執行文件,不過不支持將多個包的測試編譯爲一個可執行文件:

$go test -c ./...
cannot use -c flag with multiple packages

此外由於 go test 編譯出的可執行文件不支持將輸出內容轉換爲 JSON 格式 [12],因此也無法對接 go-test-report 將測試結果保存在文件中供後續查看。

本文涉及的源碼可以在這裏 [13] 下載 - https://github.com/bigwhite/experiments/tree/master/automated-testing


Gopher Daily(Gopher 每日新聞) 歸檔倉庫 - https://github.com/bigwhite/gopherdaily

我的聯繫方式:

商務合作方式:撰稿、出書、培訓、在線課程、合夥創業、諮詢、廣告合作。

參考資料

[1] 

驗收測試: http://en.wikipedia.org/wiki/Acceptance_testing

[2] 

YAML 文件: https://tonybai.com/2019/02/25/introduction-to-yaml-creating-a-kubernetes-deployment/

[3] 

外部 DSL 文法: https://tonybai.com/2022/05/10/introduction-of-implement-dsl-using-antlr-and-go

[4] 

《領域特定語言》: https://book.douban.com/subject/21964984/

[5] 

《Go 語言精進之路 vol2》: https://item.jd.com/13694000.html

[6] 

第 41 條 “有層次地組織測試代碼”: https://book.douban.com/subject/35720729/

[7] 

TestMain: https://pkg.go.dev/testing#Main

[8] 

sub test: https://tonybai.com/2023/03/15/an-intro-of-go-subtest/

[9] 

Eclipse 的 mosquitto: https://github.com/eclipse/mosquitto

[10] 

MQTT 協議: https://mqtt.org/mqtt-specification/

[11] 

go-test-report: https://github.com/vakenbolt/go-test-report

[12] 

不支持將輸出內容轉換爲 JSON 格式: https://github.com/golang/go/issues/22996

[13] 

這裏: https://github.com/bigwhite/experiments/tree/master/automated-testing

[14] 

“Gopher 部落” 知識星球: https://wx.zsxq.com/dweb2/index/group/51284458844544

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/RKshV1hu61wabq4am906PA