單元測試在Unity中的應用

SouthBegonia發表於2021-12-06

專案描述:簡單演示單元測試在Unity中的應用
專案地址:UnityTestRunner_Tutorial - SouthBegonia
專案版本:2020.3.20f1
專案用法:開啟就用,程式碼都放在 Assets/Editor內了

單元測試

簡介

單元測試是指對軟體中的 最小可測試單元 進行檢查和驗證,一般情況下就是對程式碼中的 一個函式 去進行驗證,檢查它的 正確性

單元測試並不測基礎結構問題(如資料庫、檔案系統和網路資源的互動等)。

意義

  • 節省開發期間的測試時間

​ 相比於以往直接寫業務程式碼、執行Unity跑功能、看斷點看日誌,單元測試能在編譯器模式下快速執行業務邏輯的單元測試

  • 有助於完善程式碼

​ 因為能便捷的新增各類測試資料,所以編寫測試程式碼期間就能發現正式業務程式碼需要注意的地方(如判空、合法性驗證、邊界問題、演算法複雜度等)

  • 減少程式碼耦合

​ 當程式碼緊密耦合時,可能難以進行單元測試。 如果不為編寫的程式碼建立單元測試,則耦合可能不太明顯,為程式碼編寫測試會自然地解耦程式碼

測試模式

採用 “Arrange、Act、Assert” 模式,主要包含3個操作:

  1. 安排物件,根據需要對其進行建立和設定

  2. 作用於物件

  3. 斷言某些項按預期進行

Unity Test Runner

簡介

Unity Test Runner 是 NUnit單元測試框架 在Unity中的實現,可在編輯器模式下執行單元測試。

通過 Window->General->Test Runner 開啟頁面。雙擊某測試單元或左上角的 Run All、Run Selected ... 即可執行測試,並輸出測試結果到控制檯

使用流程

  1. 編寫被測試程式碼

    • 被測程式碼應當是剔除Unity元件互動、資源互動等後的核心演算法邏輯。例如某功能模組下的某函式
    • 若被測程式碼自身已較為獨立(如各Utility類),則直接在測試程式碼內呼叫即可;否則應當新建被測試類進行測試
    • 新建的被測試類檔案可放在Asset->Editor下;採用測試功能名來命名即可
  2. 編寫測試程式碼

    • 測試程式碼需遵守“Arrange、Act、Assert”模式,且程式碼能簡就簡
    • 測試函式需要打 [Test] 或 [TestCase] 標籤,詳見具體事例或NUnit Attribute
    • 儘量減少if、switch、for等語句的使用(減小測試程式碼出bug的可能性)
    • Assert斷言語句一旦測試失敗即丟擲,且失敗日誌的資訊較少(只知道失敗行和失敗結果),因此可輔以Debug日誌或斷點除錯
    • 新建的測試類檔案必須放在Asset->Editor下;採用測試功名+Tests來命名
  3. 在Unity Test Runner 頁面執行目標測試

    • 選中較為常用的EditMode
    • 選中各自需測試的單元執行測試即可(如某個測試類或該測試類下的某測試函式)

具體事例

事例1

需要測試GameUtils類下的獲取字串長度函式GetTextLength(),在各類傳參下能否返回正確長度值。

先新建被測試類GameUtils及被測試函式GetTextLength()

public class GameUtils
{
    public static int GetTextLength(string str)
    {
        // ---------- 錯誤:缺判空 ----------
        // if (string.IsNullOrEmpty(str))
        // {
        //     return 0;
        // }

        int len = 0;
        for (int i = 0; i < str.Length; i++)
        {
            byte[] byte_len = Encoding.UTF8.GetBytes(str.Substring(i, 1));
            if (byte_len.Length > 1)
                len += 2;
            else
                len += 1;
        }

        return len;
    }
}

後新建GameUtils的測試用類GameUtilsTests,編寫GetTextLength()的測試函式:

public class GameUtilsTests
{
    // GetTextLength測試null字串
    [Test]
    public void GetTextLength_NullStr()
    {
        string str = null;
        int result = GameUtils.GetTextLength(str);
        Assert.AreEqual(0, result);
    }

    // 多測試資料的GetTextLength測試
    [TestCase("", 0)]
    [TestCase("Hello World", 11)]
    public void GetTextLength_MultiTestData(string data, int exResult)
    {
        int result = GameUtils.GetTextLength(data);
        Assert.AreEqual(exResult, result);
    }
}

測試結果如下:

事例2

需要測試PVP排行榜的排序演算法,是否能在單、多排序引數下正確得到排序資料。

先簡化排行榜資料單元類為PVPRankCell,新建被測試類PVPRankSort,編寫測試函式PVPRankCellComparer_BySingleComparedParam()PVPRankCellComparer_ByMultiComparedParam(),以及用於生成測試資料的方法GenTestRankList()

//排行榜資料單元
public class PVPRankCell
{
    public string Name;
    public int Score;
    public int RankInGlobal;
    public long PlatformID;
}

public class PVPRankSort
{
    public static int PVPRankCellComparer_BySingleComparedParam(PVPRankCell a, PVPRankCell b)
    {
        //return -a.PlatformID.CompareTo(b.PlatformID); //錯誤
        return a.PlatformID.CompareTo(b.PlatformID);     //正確
    }

    public int PVPRankCellComparer_ByMultiComparedParam(PVPRankCell a, PVPRankCell b)
    {
        if (a.Score != b.Score)
            return -a.Score.CompareTo(b.Score);

        if (a.RankInGlobal != b.RankInGlobal)
            return a.RankInGlobal.CompareTo(b.RankInGlobal);

        return -a.PlatformID.CompareTo(b.PlatformID); //錯誤
        //return a.PlatformID.CompareTo(b.PlatformID);     //正確
    }
    
    // 生成測試用資料
    public List<PVPRankCell> GenTestRankList()
    {
        List<PVPRankCell> testRankList = new List<PVPRankCell>
        {
            new PVPRankCell() {Name = "A", Score = 10, RankInGlobal = 3, PlatformID = 1001},
            new PVPRankCell() {Name = "B", Score = 10, RankInGlobal = 3, PlatformID = 1002},
            new PVPRankCell() {Name = "C", Score = 10, RankInGlobal = 3, PlatformID = 1002},    //隱患資料
            new PVPRankCell() {Name = "D", Score = 20, RankInGlobal = 1, PlatformID = 1003},
            new PVPRankCell() {Name = "E", Score = 30, RankInGlobal = 2, PlatformID = 1004},
        };
        return testRankList;
    }
}

後新建測試類PVPRankSortTests,編寫2個排序演算法的測試函式:

public class PVPRankSortTests
{
    PVPRankSort PvpRankSort;

    [SetUp]
    public void SetUp()
    {
        //最先執行的方法,作為多測試方法的功能部分
        PvpRankSort = new PVPRankSort();
    }

    [TearDown]
    public void TearDowm()
    {
        //最後執行的方法,用於清除或回收公共資源
        PvpRankSort = null;
    }

    // 單一比較引數排序演算法的測試
    [Test]
    public void PVPRankSort_SingleComparedParam()
    {
        // Arrange:安排物件,根據需要對其進行建立和設定
        //        如構造測試用資料
        List<PVPRankCell> testRankList = PvpRankSort.GenTestRankList();

        // Act:作用於物件
        //        如具體演算法實現
        testRankList.Sort(PVPRankSort.PVPRankCellComparer_BySingleComparedParam);

        // Assert:斷言某些項按預期進行
        //        如結果校驗:PlatformID升序
        for (int index = 0; index + 1 < testRankList.Count; ++index)
        {
            if (testRankList[index].PlatformID != testRankList[index + 1].PlatformID)
                Assert.Less(testRankList[index].PlatformID, testRankList[index + 1].PlatformID); //PlatformID升序
            else
                Debug.LogWarning($"Warning>>>>>  {testRankList[index].Name} 的排序引數和 {testRankList[index + 1].Name} 一致"); //隱患情況
        }
    }

    // 多比較引數排序演算法的測試
    [Test]
    public void PVPRankSort_MultiComparedParam()
    {
        // Arrange:安排物件,根據需要對其進行建立和設定
        //        如構造測試用資料
        List<PVPRankCell> testRankList = PvpRankSort.GenTestRankList();

        // Act:作用於物件
        //        如具體演算法實現
        testRankList.Sort(PvpRankSort.PVPRankCellComparer_ByMultiComparedParam);

        // Assert:斷言某些項按預期進行
        //        如結果校驗:分數降序->名次升序->PlatformID升序
        for (int index = 0; index + 1 < testRankList.Count; ++index)
        {
            if (testRankList[index].Score != testRankList[index + 1].Score)
                Assert.Greater(testRankList[index].Score, testRankList[index + 1].Score); //分數降序
            else if (testRankList[index].RankInGlobal != testRankList[index + 1].RankInGlobal)
                Assert.Less(testRankList[index].RankInGlobal, testRankList[index + 1].RankInGlobal); //排名升序
            else if (testRankList[index].PlatformID != testRankList[index + 1].PlatformID)
                Assert.Less(testRankList[index].PlatformID, testRankList[index + 1].PlatformID); //PlatformID升序
            else
                Debug.LogWarning($"Warning>>>>>  {testRankList[index].Name} 的排序引數和 {testRankList[index + 1].Name} 一致"); //隱患情況
        }
    }
}

測試結果如圖:

其他

NUnit Attribute

TestAttribute

常用標籤,標記該方法能被執行測試,方法必須為public void 無參

// GetTextLength測試null字串
[Test]
public void GetTextLength_NullStr()
{
    string str = null;
    int result = GameUtils.GetTextLength(str);
    Assert.AreEqual(0, result);
}

TestCaseAttribute

標記該方法能被執行測試,方法必須為public void,可傳參,引數由TestCase傳入

// 多測試資料的GetTextLength測試
[TestCase("", 0)]
[TestCase("Hello World", 11)]
public void GetTextLength_MultiTestData(string data, int exResult)
{
    int result = GameUtils.GetTextLength(data);
    Assert.AreEqual(exResult, result);
}

TestFixtureAttribute

暫無需使用。用於標記一個類為測試類,其中此類必須是public,必須保證此建構函式不能有任何的副作用(不能出現異常或者錯誤的情況),在一個測試過程中,可以被構造多次。如果建構函式帶有引數,可以指定預設的初始化引數

SetUpAttribute

標記該方法在測試流程中被首先執行,用作初始化公共引數

PVPRankSort PvpRankSort;

[SetUp]
public void SetUp()
{
    //最先執行的方法,作為多測試方法的功能部分
    PvpRankSort = new PVPRankSort();
}

TearDownAttribute

標記該方法被最後執行,用作回收公共引數部分,與SetUp配對使用

[TearDown]
public void TearDowm()
{
    //最後執行的方法,用於清除或回收公共資源
    PvpRankSort = null;
}

CategoryAttribute

給該測試方法打篩分標籤,在UnityTestRunner頁面可篩分顯示(但有特殊字元限制)

RepeatAttribute

標記該測試方法重複執行指定次數

參考文章

相關文章