1. 程式人生 > >Lua Busted 單元測試實戰

Lua Busted 單元測試實戰

目標

提供比較實用的 Lua Busted 單元測試例項。

環境

  • Unity 2018.2.5f1 Personal (64bit)
  • IntelliJ IDEA 2018.2.3 (Community Edition), Build #IC-182.4323.46, built on September 4, 2018
  • JRE: 1.8.0_152-release-1248-b8 amd64
  • JVM: OpenJDK 64-Bit Server VM by JetBrains s.r.o
  • Windows 10 10.0

目錄結構及簡單示例

└─project       專案資料夾
    ├─spec      測試程式碼(每個檔案命名為 *_spec.lua 的形式)
    └─src       專案程式碼

Busted 約定將測試放在 spec 資料夾中,命名為 *xx*_spec.lua。這樣在 spec 父資料夾層級(即project)開啟命令列使用 busted,會執行 spec 資料夾下的 *_spec.lua 中的所有測試。

spec 下新建檔案如下:

-- spec/sample_spec.lua
describe("basic test", function()
    it("should pass", function()
        assert.truthy("yup")
    end)
    it("should throw error if assert false", function()
        assert.falsy("yup")
    end)
end)

project 下開啟命令列,輸入 bustedbusted -o TAP 回車,

執行結果:

D:\tmp\lua>busted -o TAP
ok 1 - basic test should pass
not ok 2 - basic test should throw error if assert false
# spec\sample_spec.lua @ 6
# Failure message: spec\sample_spec.lua:7: Expected to be falsy, but value was:
# (string) 'yup'
1..2

我們使用了 TAP 形式的輸出,可以看到 busted 將 describe 與 it 連線起來形成了最後的測試名,因為我們的書寫方式,最後的測試名是一個完整的句子,這方便通過測試名就對測試所關注的功能一目瞭然。

測試結果中第一個通過了,因為我們 assert.truty() 裡面的值只要不是 falsenil 就能通過,字串 "yup" 自然是可以通過的。

測試中第二個沒有通過,因為我們在傳入相同的字串的時候,使用了 assert.falsy(),這當然會失敗。從這個例子我們可以看到,當測試不通過時,busted 會給出錯誤所在行數,也會詳細地告訴我們測試希望的值是什麼、實際得到的值是什麼。

概念說明與規範

在實際的單元測試中,專案程式碼往往有很多依賴,各種呼叫將各個模組連線耦合起來。在設計不佳的系統中甚至可能為了測試一個模組而初始化所有模組。UI 邏輯多了不好測。UI 需要載入其他的模組也不好獨立出來測。

針對這種情況,使用 mock 可以解決一部分問題。不過,這在 MVC 分離不全情況下也難以應用。能測的主要是那種獨立函式、獨立類。涉及 UI 的測試還是要另想辦法。

A mock object is simply a testing replacement for a
real-world object.

– Pragmatic Unit Testing in C# with NUnit 2e

Mock 就是一個真實模組的替代品。可以類比於演員的替身來理解。它與真實模組有相同的介面,但是介面返回值是我們直接指定的特定值,這樣就達成了其他模組改變時我們測試的這個模組獲得的返回值依然是固定的,也就達成了與其他模組隔離及解耦的目的。

通過 Mock 的使用,我們還可以監測對一個模組的呼叫行為,可以通過 Mock 來統計呼叫次數,也能知道呼叫時傳入的引數。

Busted 的 Mock 主要提供了呼叫次數統計、呼叫時傳入引數的獲取的功能。但是沒有起到讓我們指定返回值的作用。在 NUnit 中的 Mock 通常是自行實現一個與真實模組具有相同介面(interface)的類,然後進行使用。在 NUnit 中,Mock 更多地是一種概念而不是實際介面;在 Busted 中則提供了實際的 mock 函式,但是僅僅是有一些監測功能。

但是核心問題在於,測試的函式可能依賴多個模組,要呼叫其他模組的函式、取其中的值。Lua busted 的 Mock 並不能滿足需求,所以替代真實模組、隔離系統其他模組的任務,還是需要我們根據實際情況來手工解決。

以下先介紹 busted 提供的呼叫統計的方法,再介紹模組隔離/替代的辦法,最後對 Busted 的測試命名規範給出一些建議。在下一小節將對介紹的內容給出綜合示例。

呼叫統計

在 busted 中,進行引數和呼叫統計的 Mock 分為 spystub 兩種。

  • spy 對目標進行監測,可以監測其是否被呼叫、被用什麼函式呼叫。
  • stub 則是對目標進行包裝,同樣監測其是否被呼叫、被用什麼函式呼叫。與 spy 不同之處是,stub 會擷取呼叫,不會實際呼叫目標。適合測試資料層,這樣不會實際找資料庫要資料。
  • spy 和 stub 是單體操作,mock 是對整個表進行的操作,mock(t) 得到 t 的 spies, mock(t, true) 得到 t 的 stubs

模組隔離

主要解決全域性變數、require 的隔離。

全域性量

如果待測試的程式碼使用了全域性變數 GLOBAL,那麼使用 stub:

stub(_G, "GLOBAL")

require

使用 lua 的 package 機制來改變 require.

如果要匯入 src/logic/sample_module.lua 這個包,即require("src/logic/sample_module"),則使用:

package.preload["src/logic/sample_module"] = function ()
    --print("fake load module")
end

這樣在 require 這一模組時,不會去載入實際的檔案,而是執行這裡定義函式。在這裡面就可以提供我們自己的 Mock 了。

命名規範

筆者比較喜歡 describe it style (參照的是 https://www.bignerdranch.com/blog/why-do-javascript-test-frameworks-use-describe-and-beforeeach/), 因為最後的測試名是一個完整的句子,這方便通過測試名就對測試所關注的功能一目瞭然。

以下提供一些格式,| 分隔不同的段,最後一個段放在 it() 裡面,前面的都放在 describe() 裡面,最終組合成完整的句子:

Target | when | does sth / returns sth
Target | should have sth
Target | can do sth

以上的格式概括了常見的測試關注點:模組可以做什麼、應該有什麼、在某種情況下應該做什麼或者返回什麼。

Busted 支援給測試打標籤,這樣方便獨立執行具有某些 tag 或者不具有某些 tag 的測試。

例如筆者定義了 #InternalVariableUsed 這個標籤來標記使用了私有或者保護變數的測試,這種測試很容易因為重構而失敗。畢竟一般的單元測試應該測公共的方法和介面。

使用方法:

  • 只測有該標籤的測試:busted -o TAP --tags=InternalVariableUsed
  • 排除有該標籤的測試:busted -o TAP --exclude-tags=InternalVariableUsed

另外,busted 採用的 assert 形式,期望的值寫在前,實際值寫在後,例如:

assert.are.equal(expected, actual)

更加複雜的例項

此處建立常規的模組 sample_module,提供全域性變數的模組 sample_module_global,以及沒有用到只是 require 的模組 sample_module_empty,還有 require 了其他模組的模組 main_module,分別寫出對它們的測試方法。

針對 main_module,提供了隔離其他模組的單元測試,也提供了實際載入其他模組的整合測試。

各檔案程式碼如下:

src/logic/main.lua

local main = require("main_module")

print(main:TimeSixValue(2))

src/logic/main_module.lua

local MainModule = {}

local SampleModule = require("sample_module")
require("sample_module_global")
local Empty = require("sample_module_empty")

function MainModule:TimeSixValue(value)
    return SampleModule:ModifyInt(value) * SampleGlobal.ModifyInt(value) / value
end

function MainModule:ReturnGlobalString()
    return GLOBAL_STRING
end

return MainModule

src/logic/sample_module.lua

local SampleModule = {}

function SampleModule:ModifyInt(value)
    return value * 2
end
return SampleModule

src/logc/sample_module_empty.lua

local SampleModuleEmpty = {}

return SampleModuleEmpty

src/logic/sample_module_global.lua

SampleGlobal = {}

function SampleGlobal.TestFunc()
    print("SampleGlobal.TestFunc called")
end

function SampleGlobal.ModifyInt(value)
    return value * 3
end

GLOBAL_STRING = "a global string"

spec/main_module_spec.lua

describe("MainModule", function()
    local mainModule = {}
    -- By default each test file runs in a separate insulate block

    -- run before the describe block.
    setup(function ()
        package.preload["sample_module"] = function ()
            local mock = {}
            function mock:ModifyInt(value)
                return value * 2
            end
            return mock
        end
        package.preload["sample_module_global"] = function ()
            SampleGlobal = {}
            function SampleGlobal.ModifyInt(value)
                return value * 3
            end
            _G.SampleGlobal = SampleGlobal

        end
        package.preload["sample_module_empty"] = function ()

        end
        mainModule = require("../src/logic/main_module")
    end)

    it("should make value six times of itself", function()
        assert.are.equal(12, mainModule:TimeSixValue(2))
    end)
    it("should return global string", function ()
        stub(_G, "GLOBAL_STRING")
        --print(mainModule:ReturnGlobalString())
        assert.are_not.equal("a global string", mainModule:ReturnGlobalString())
    end)
end)

在這裡使用 package.preload 給各個模組寫了 Mock。對於常規的模組很方便。對於全域性的略麻煩,用到了 _G。正常情況下能寫 local return 式的模組時還是儘量不要寫 global 的。對於 empty 的模組或者沒使用的模組,給一個空函式就完全夠用了。把全部變數封住就用 stub

spec/main_module_integrated_spec.lua

describe("MainModule", function()
    local mainModule = {}
    setup(function ()
        package.path = "./src/logic/?.lua;"..package.path
        mainModule = require("../src/logic/main_module")
    end)
    it("should make value six times of itself #Integrated", function()
        assert.are.equal(12, mainModule:TimeSixValue(2))
    end)
end)

做整合測試主要得注意 package.path,要把原始碼路徑加好。

spec/sample_module_spec.lua

describe("SampleModule", function()
    local sampleModule = {}
    setup(function ()
        sampleModule = require("../src/logic/sample_module")
    end)
    it("should double value", function()
        assert.are.equal(4, sampleModule:ModifyInt(2))
    end)
end)

spec/sample_module_global_spec.lua

describe("SampleGlobal", function()
    setup(function ()
        require("../src/logic/sample_module_global")
    end)
    it("should triple value", function()
        assert.are.equal(6, SampleGlobal.ModifyInt(2))
    end)
end)

執行結果

D:\tmp\lua>busted -o TAP
ok 1 - MainModule should make value six times of itself #Integrated
ok 2 - MainModule should make value six times of itself
ok 3 - MainModule should return global string
ok 4 - SampleGlobal should triple value
ok 5 - SampleModule should double value
1..5

複雜例子打包下載

針對 Lua 單元測試的思考

Lua 單元測試有一個特點就是想要使用私有成員變數時非常方便,但是這也導致測試更加容易失敗。因為內部實現總是沒有外部介面那麼穩定的。需要建立一些原則來進行規範。

工作單元有三種最終結果:返回值、內部狀態改變、呼叫第三方物件。基於值的測試驗證了一個函式的返回值;基於狀態的測試改變被測試物件的狀態,然後驗證其可見的狀態變化;互動測試則是驗證一個物件如何向其他物件傳送訊息的測試。實際單元測試中,優先考慮基於值、基於狀態的測試方法,因為這兩種測試可以減少對程式碼內部細節的假設。
來自:桃子媽咪 https://www.jianshu.com/p/c7d5a214c485

參考