使用 NUnit 爲 Unity3D 編寫高質量單元測試

0x00 單元測試 Pro & Con

最近嘗試在我參與的遊戲項目中引入TDD(測試驅動開發)的開發模式,所以單元測試便變得十分必要。這篇博客就來聊一聊這段時間的感悟和想法。因爲遊戲開發和傳統軟件開發之間的差別,所以在開發遊戲,特別是使用Unity3D開發遊戲的過程當中編寫單元測試每每會面臨兩個主要的問題:html

  1. 遊戲開發中會涉及到不少的I/O操做處理,以及視覺和UI的處理,而這個部分是單元測試中比較難以處理的部分。git

  2. 具體到使用Unity3D開發遊戲,咱們天然而然的但願可以將測試的框架集成到Unity3D的編輯器中,這樣更加容易操做。程序員

可是,單元測試的好處也十分多。github

  1. TDD,測試驅動開發。編寫單元測試將使咱們從調用者觀察、思考。特別是先寫測試,迫使咱們把程序設計成易於調用和可測試的,即迫使咱們解除軟件中的耦合。能夠將任務的粒度下降。固然TDD是否適合遊戲開發尚有爭論,可是單元測試的必要性是無需置疑的。框架

  2. 單元測試是一種無價的文檔,它是展現方法或類如何使用的最佳文檔。這份文檔是可編譯、可運行的,而且它保持最新,永遠與代碼同步。編輯器

  3. 更加適合應對需求的常常性變動。身處遊戲開發行業的從業人員都不可否認的一點即是遊戲開發中需求變動是一件不可避免甚至是必不可少的事情,而單元測試另外一個好處即是一旦由於需求變動而出現bug,可以很快的發現,進而解決問題。工具

0x01 Unity3D 中經常使用的測試工具

針對問題 1,因爲對I/O處理以及UI視覺方面的操做比較難以實施單元測試,因此咱們單元測試的主要對象是邏輯操做以及數據存取的部分。佈局

針對問題 2,Unity5.3.x 已經在editor中集成了測試模塊。該測試模塊依託了NUnit框架(NUnit是一個單元測試框架,專門針對於.NET來寫的。其實在前面有 JUnit(Java),CPPUnit(C++),他們都是xUnit的一員。最初,它是從JUnit而來,U3d使用的版本是2.6.4)。
在Unity Editor中實現測試而不是在IDE中進行測試的緣由在於,一些Unity的API須要在Unity的環境中來運行,而沒法直接在外部的IDE中實現,例如實例化GameObject。單元測試

並且除了 Unity5.3.x 自帶的單元測試模塊以外,Unity官方還推出了一款測試插件Unity Test Tool(基於NSubstitute),除了單元測試以外還包括:測試

  1. 單元測試

  2. 集成測試

  3. 斷言組件

須要指出的是Unity Test Tool基於NSubstitute這個庫。

0x02 初識單元測試

既然本文的主題是單元測試,那麼咱們就必須先對單元測試下一個定義:

一個單元測試是一段自動化的代碼,這段代碼調用被測試的工做單元,以後對這個單元的單個最終結果的某些假設進行檢驗。單元測試使用單元測試框架編寫,並要求單元測試可靠、可讀而且可維護。只要產品代碼不發生變化,單元測試的結果是穩定的。

既然有了單元測試的定義,下面咱們就嘗試在Unity項目中寫單元測試吧。

一個單元測試的小例子

編寫單元測試用例時,使用的主要是Unity Editor自帶的單元測試模塊,所以單元測試是基於NUnit框架的。
藉助NUnit,咱們能夠:

  1. 編寫結構化的測試。

  2. 自動執行選中的或所有的單元測試。

  3. 查看測試運行的結果。

所以這就要求編寫Unity3D項目的單元測試時,要引入 NUnit.Framework 命名空間,且單元測試類要加上[TestFixture]屬性,單元測試方法要加上[Test]屬性,並將測試用例的文件放在Editor文件夾下。
下面是一個例子:

using UnityEngine;
using System.Collections;
using NUnit.Framework;

[TestFixture]
public class HpCompTests
{
  //測試被攻擊以後傷害數值是否和預期值相等
  [Test]
  public void TakeDamage_BeAttacked_HpEqual()
{
  //Arrange
      HpComp health = new HpComp();
      health.currentHp = 100;
     //Act
      health.TakeDamage(50);
     //Assert
      Assert.AreEqual(50f, health.currentHp);
    }
}

該例子是測試英雄受到傷害以後,血量是否和預期的相等。

測試框架會建立這個測試用例類,而且調用TakeDamage_BeAttacked_HpEqual方法來和其交互,最後使用Nunit的Assert類來斷言是否經過測試。

0x03 單元測試的結構

經過上面的小例子,咱們能夠發現單元測試實際上是有結構的。下面咱們就來具體分析一下:

使用NUnit提供的特性來標識測試代碼

NUnit使用C#的特性機制識別和加載測試。這些特性就像是書籤,用來幫助測試框架識別哪些部分是須要調用的測試。
若是要使用NUnit的特性,咱們須要在測試代碼中首先引入NUnit.Framework命名空間。

而NUnit運行器至少須要兩個特性才知道須要運行什麼。

  1. [TestFixture]:標識一個自動化 NUnit 測試的類。

  2. [Test]:能夠加在一個方法上,標識這個方法是一個須要調用的自動化測試。

固然,還有一些別的特性供咱們使用,來方便咱們更好的控制測試代碼,例如[Category]特性能夠將測試分類、[Ignore]特性能夠忽略測試。

經常使用的NUnit屬性見下表:

[SetUp] 
[TearDown]  
[TestFixture] 
[Test] 
[TestCase] 
[Category] 
[Ignore]

測試命名和佈局標準

  • 測試類的命名:對應被測試項目中的一個類,建立一個名爲[ClassName]Tests的類。

  • 工做單元的命名:對每一個工做單元(測試),測試方法的方法名由三部分組成,而且按照以下規則命名:[被測試的方法名]_[測試進行的假設條件]_[對測試方法的預期]。

具體來講:

  1. 被測試的方法名

  2. 測試進行的假設條件,例如「登入失敗」、「無效用戶」、「密碼正確」。

  3. 對測試方法的預期:在測試場景指定的條件下,咱們對被測試方法的行爲的預期。

其中,對測試方法的預期會有三種可能的結果:

  1. 返回一個值(數值、布爾值等等)。

  2. 改變被測試的系統的一個狀態。

  3. 調用一個第三方系統。

能夠看出,咱們的測試代碼在格式上與標準的代碼有所不一樣,測試名能夠很長,可是在編寫測試代碼時,可讀性是最爲重要的方面之一,而測試名中的下劃線能夠令咱們不會遺漏全部的重要信息,咱們甚至能夠將測試方法名當作一個句子來讀,這樣就會使得這個測試方法的測試目標、場景以及預期都十分明確,無需額外的註釋。

測試單元的行爲——3A原則

有了NUnit屬性能夠標識能夠自動運行的測試代碼和測試代碼的一些命名規則,下面咱們就來看看如何測試本身的代碼。
一個單元測試一般包含三個行爲,能夠概括爲3A原則即:

  1. Arrange:準備對象,建立對象並進行必要的設置

  2. Act:操做對象

  3. Assert:斷言某件事情是預期的

下面是以前的那段簡單的代碼,包含了以上的NUnit的屬性、命名規範以及3A原則下的行爲,其中斷言部分使用了NUnit框架提供的Assert類,被測試的類爲HpComp,被測試的方法爲TakeDamage。

using NUnit.Framework;

[TestFixture]
public class HpCompTests
{
  //測試被攻擊以後傷害數值是否和預期值相等
  [Test]
  public void TakeDamage_BeAttacked_HpEqual()
{
  //Arrange
      HpComp health = new HpComp();
      health.currentHp = 100;
     //Act
      health.TakeDamage(50);
     //Assert
      Assert.AreEqual(50f, health.currentHp);
    }
}

單元測試的斷言——Assert類

NUnit框架提供了一個Assert類來處理斷言的相關功能。Asset類用於聲明某個特定的假設應該成立,所以若是傳遞給Assert類的參數和咱們斷言(預期)的值不一樣,則NUnit框架會認爲測試沒有經過。

Assert類會提供一些靜態方法,供咱們使用。

例如:

Assert.AreEqual(預期值,實際值);
Assert.AreEqual(1,2 - 1);

關於Assert類的靜態方法,各位能夠直接在代碼中看。

0x04 單元測試的可靠性

咱們的目標是寫出可靠、可維護、可讀的測試。

所以,除了遵循單元測試結構規範編寫單元測試以外,咱們還須要注意可靠性、可維護性以及可讀性這些方面。所以,一些原則咱們也須要注意。

不輕易刪除和修改測試

一旦測試寫好了而且經過了,就不該該輕易的修改和刪除這些測試。由於這些測試是對應系統代碼的保護傘,在修改系統代碼時,這些測試會告訴咱們修改後的代碼是否會破壞已有的功能。

儘可能避免測試中的邏輯

隨着測試中的邏輯增多,測試代碼出現缺陷的概率也會增大。並且因爲咱們每每相信測試是可靠的,所以一旦測試出現缺陷咱們每每不會首先考慮是測試的問題,可能會浪費時間去修改系統代碼。而單元測試中,最好保持邏輯的簡單,所以儘可能避免使用下面的邏輯控制代碼。

  1. switch、if

  2. foreach、for、while

一個單元測試應該是一系列的方法調用和斷言,可是不該該包含控制流語句。

只測試一個關注點

在一個單元測試中驗證多個關注點會使得測試代碼變得複雜,但卻沒有價值。相反,咱們應該在分開的、獨立的單元中驗證多餘的關注點,這樣才能發現真正致使失敗的地方。

0x05 單元測試的可維護性

去除重複代碼

和系統中的重複代碼同樣,在單元測試中重複代碼一樣意味着測試對象某方面改變時要修改更多的測試代碼。

若是測試看上去都同樣,僅僅是參數不一樣,那麼咱們徹底可使用參數化測試即便用[TestCase]特性將不一樣的數據做爲參數傳入測試方法。

實施測試隔離

所謂的測試隔離,指的是一個測試和其餘的測試隔離,甚至不知道其餘測試的存在,而只在本身的小世界中運行。

將測試隔離的目的是防止測試之間的互相影響,常見的測試之間互相影響的狀況能夠總結以下:

  1. 強制的測試順序:測試要以某種順序執行,後一個測試須要前面的測試結果,這種狀況有可能會致使問題的緣由是由於NUnit不能保證測試按照某種特定的順序執行,所以今天經過的測試,明天可能就很差用了

  2. 隱藏的測試調用:測試調用其餘測試

  3. 共享狀態被破壞:測試要共享狀態,可是在一個測試完成以後沒有重置狀態,進而影響後面的測試

0x06 單元測試的可讀性

正如概述中所說單元測試是一種無價的文檔,它是展現方法或類如何使用的最佳文檔。所以,可讀性這條要求的重要性即可見一斑。試想一下即使是幾個月以後別的程序員均可以經過單元測試來理解一個系統的組成以及使用方法,並可以很快的理解他們要作的工做以及在哪裏切入。

單元測試命名

在單元測試的結構中已經有過要求和介紹。參考那部分。

單元測試中的變量命名

經過合理的命名變量,能夠提升可讀性,使得閱讀測試的人員能夠儘快的理解你要驗證的內容。

仍是看看上面的例子

[Test]
public void TakeDamage_BeAttacked_HpEqual()
{
//Arrange
    HpComp health = new HpComp();
    health.currentHp = 100;
   //Act
    health.TakeDamage(50);
   //Assert
    Assert.AreEqual(50f, health.currentHp);
}

這段代碼中的斷言使用了一個魔數50,可是這個數字並無使用描述性的名字,所以咱們沒法儘快的知道這個數字預期的是什麼。所以,咱們儘量不要直接使用數字和結果比較,而是使用一個有意義命名的變量來和結果進行比較。

[Test]
public void TakeDamage_BeAttacked_HpEqual()
{
    HpComp health = new HpComp();
    health.currentHp = 100;

    health.TakeDamage(50);

    float leftHp = 50f;

    Assert.AreEqual(leftHp, health.currentHp);
}

0x07 在Untiy編輯器中寫單元測試

在Unity編輯器中編寫單元測試用例時,使用的主要是Unity編輯器自帶的單元測試模塊,所以單元測試是基於NUnit框架的。

這就要求編寫單元測試時,要引入NUnit.Framework命名空間,且單元測試類要加上[TestFixture]屬性,單元測試方法要加上[Test]屬性,並將測試用例的文件放在Editor文件夾下。

測試用例的編寫結構要遵循 3A 原則,即 Arrange, Act, Assert。即先要設置測試環境,例如實例化測試類,爲測試類的字段賦值。以後寫測試的行爲,最後是判斷是否經過測試。

下面是一個例子:

using UnityEngine;
using System.Collections;
using NUnit.Framework;

[TestFixture]
public class HealthComponentTests
{
  //測試傷害以後,血的值是否比0大
  [Test]
  public void TakeDamage_BeAttacked_BiggerZero()
    {
      //Arrange 
      UnMonoHealthClass health = new UnMonoHealthClass();
      health.healthAmount = 50f;

      //Act
      health.TakeDamage(60f);

      //Assert
        Assert.GreaterOrEqual(health.healthAmount, 0);
    }
}

該例子是測試英雄受到傷害以後,血量是否會越界出現負值。

測試框架會建立這個測試用例類,而且調用TakeDamage_BeAttacked_BiggerZero方法來和其交互,最後使用Nunit的Assert類來斷言是否經過測試。

使用Editor Tests Runner開始單元測試

寫完了單元測試用例以後,咱們就能夠在Unity5.3.x的editor中開始單元測試了。如圖所示:

在這裏,咱們既能夠跑單獨的測試用例,也能夠跑全部的測試用例,經過的是綠色標識,未經過的是紅色標識。
而在最上面的一行,則是咱們能夠操做的部分:

  • Run All:測試所有用例

  • Run Selected:測試選中的用例

  • Rerun Failed: 從新測試上一次未經過的測試用例

  • 搜索框:能夠搜索用例

  • 種類過濾器:能夠根據種類來篩選用例,種類須要在測試代碼中使用CategoryAttribute來標識。

  • 測試結果篩選器:能夠按照經過、失敗以及忽略來篩選用例

在這裏咱們還能夠設置在編譯前自動運行單元測試。

使用命令行運行單元測試

除了可以在Editor中使用單元測試,咱們天然更但願可以將單元測試也歸入自動集成的流水線中,所以有必要從U3D外部調用測試。不過好在U3D也提供了外部調用的方式,這樣將單元測試也加入到咱們的自動集成的流水線中是可行的。
Unity3D 5.3.x版本中提供的命令行選項以下:

runEditorTests  必須,運行editor test的選項
editorTestsResultFile 用來保存測試結果
editorTestsFilter 根據用例名稱,來運行指定的用例
editorTestsCategories 根據用例種類,來運行指定的用例
editorTestsVerboseLog 打印更加詳細的日誌
projectPath 工程目錄

因此在命令行中開啓測試能夠這樣寫:

Unity -runEditorTests -projectPath /Users/fanyou/UnitTest -editorTestsResultFile  /Users/fanyou/UnitTest/test.xml -batchmode -quit

0x08 後記

以上即是關於在U3D中引入單元測試的一些思考,固然,遊戲開發是否適合TDD,換言之是否要先寫單元測試後實現功能是值得討論的事情,可是單元測試自己是十分有必要在工程中使用的。在代碼結構設計、往後的重構都會頗有幫助。

相關

TDD在Unity3D遊戲項目開發中的實踐

相關文章
相關標籤/搜索