0x00 單元測試Pro & Con
最近嘗試在我參與的游戲項目中引入TDD(測試驅動開發)的開發模式,因此單元測試便變得十分必要。這篇博客就來聊一聊這段時間的感悟和想法。由于游戲開發和傳統軟件開發之間的差異,因此在開發游戲,特別是使用Unity3D開發游戲的過程中編寫單元測試往往會面臨兩個主要的問題:
- 游戲開發中會涉及到很多的I/O操作處理,以及視覺和UI的處理,而這個部分是單元測試中比較難以處理的部分。
- 具體到使用Unity3D開發游戲,我們自然而然的希望能夠將測試的框架集成到Unity3D的編輯器中,這樣更加容易操作。
但是,單元測試的好處也十分多。
- TDD,測試驅動開發。編寫單元測試將使我們從調用者觀察、思考。特別是先寫測試,迫使我們把程序設計成易于調用和可測試的,即迫使我們解除軟件中的耦合??梢詫⑷蝿盏牧6冉档汀.斎籘DD是否適合游戲開發尚有爭論,但是單元測試的必要性是無需置疑的。
- 單元測試是一種無價的文檔,它是展示方法或類如何使用的最佳文檔。這份文檔是可編譯、可運行的,并且它保持最新,永遠與代碼同步。
- 更加適合應對需求的經常性變更。身處游戲開發行業的從業人員都不能否認的一點便是游戲開發中需求變更是一件不可避免甚至是必不可少的事情,而單元測試另一個好處便是一旦因為需求變更而出現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),除了單元測試之外還包括:
- 單元測試
- 集成測試
- 斷言組件
需要指出的是Unity Test Tool基于NSubstitute這個庫。
0x02 初識單元測試
既然本文的主題是單元測試,那么我們就必須先對單元測試下一個定義:
一個單元測試是一段自動化的代碼,這段代碼調用被測試的工作單元,之后對這個單元的單個最終結果的某些假設進行檢驗。單元測試使用單元測試框架編寫,并要求單元測試可靠、可讀并且可維護。只要產品代碼不發生變化,單元測試的結果是穩定的。
既然有了單元測試的定義,下面我們就嘗試在Unity項目中寫單元測試吧。
一個單元測試的小例子:
編寫單元測試用例時,使用的主要是Unity Editor自帶的單元測試模塊,因此單元測試是基于NUnit框架的。
借助NUnit,我們可以:
- 編寫結構化的測試。
- 自動執行選中的或全部的單元測試。
- 查看測試運行的結果。
因此這就要求編寫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運行器至少需要兩個特性才知道需要運行什么。
- [TestFixture]:標識一個自動化NUnit測試的類。
- [Test]:可以加在一個方法上,標識這個方法是一個需要調用的自動化測試。
當然,還有一些別的特性供我們使用,來方便我們更好的控制測試代碼,例如[Category]特性可以將測試分類、[Ignore]特性可以忽略測試。
常用的NUnit屬性見下表:
[SetUp]
[TearDown]
[TestFixture]
[Test]
[TestCase]
[Category]
[Ignore]
測試命名和布局標準
測試類的命名:
對應被測試項目中的一個類,創建一個名為[ClassName]Tests的類。
工作單元的命名:
對每個工作單元(測試),測試方法的方法名由三部分組成,并且按照如下規則命名:[被測試的方法名]_[測試進行的假設條件]_[對測試方法的預期]。
具體來說:
- 被測試的方法名
- 測試進行的假設條件,例如“登入失敗”、“無效用戶”、“密碼正確”。
- 對測試方法的預期:在測試場景指定的條件下,我們對被測試方法的行為的預期。
其中,對測試方法的預期會有三種可能的結果:
- 返回一個值(數值、布爾值等等)。
- 改變被測試的系統的一個狀態。
- 調用一個第三方系統。
可以看出,我們的測試代碼在格式上與標準的代碼有所不同,測試名可以很長,但是在編寫測試代碼時,可讀性是最為重要的方面之一,而測試名中的下劃線可以令我們不會遺漏所有的重要信息,我們甚至可以將測試方法名當做一個句子來讀,這樣就會使得這個測試方法的測試目標、場景以及預期都十分明確,無需額外的注釋。
測試單元的行為——3A原則
有了NUnit屬性可以標識可以自動運行的測試代碼和測試代碼的一些命名規則,下面我們就來看看如何測試自己的代碼。
一個單元測試通常包含三個行為,可以歸納為3A原則即:
- Arrange,準備對象,創建對象并進行必要的設置。
- Act,操作對象。
- 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 單元測試的可靠性
我們的目標是寫出可靠、可維護、可讀的測試。
因此,除了遵循單元測試結構規范編寫單元測試之外,我們還需要注意可靠性、可維護性以及可讀性這些方面。因此,一些原則我們也需要注意。
不輕易刪除和修改測試
一旦測試寫好了并且通過了,就不應該輕易的修改和刪除這些測試。因為這些測試是對應系統代碼的保護傘,在修改系統代碼時,這些測試會告訴我們修改后的代碼是否會破壞已有的功能。
盡量避免測試中的邏輯
隨著測試中的邏輯增多,測試代碼出現缺陷的幾率也會增大。而且由于我們往往相信測試是可靠的,因此一旦測試出現缺陷我們往往不會首先考慮是測試的問題,可能會浪費時間去修改系統代碼。而單元測試中,最好保持邏輯的簡單,因此盡量避免使用下面的邏輯控制代碼。
- switch、if
- foreach、for、while
一個單元測試應該是一系列的方法調用和斷言,但是不應該包含控制流語句。
只測試一個關注點
在一個單元測試中驗證多個關注點會使得測試代碼變得復雜,但卻沒有價值。相反,我們應該在分開的、獨立的單元中驗證多余的關注點,這樣才能發現真正導致失敗的地方。
0x05 單元測試的可維護性
去除重復代碼
和系統中的重復代碼一樣,在單元測試中重復代碼同樣意味著測試對象某方面改變時要修改更多的測試代碼。
如果測試看上去都一樣,僅僅是參數不同,那么我們完全可以使用參數化測試即使用[TestCase]特性將不同的數據作為參數傳入測試方法。
實施測試隔離
所謂的測試隔離,指的是一個測試和其他的測試隔離,甚至不知道其他測試的存在,而只在自己的小世界中運行。
將測試隔離的目的是防止測試之間的互相影響,常見的測試之間互相影響的情況可以總結如下:
- 強制的測試順序:測試要以某種順序執行,后一個測試需要前面的測試結果,這種情況有可能會導致問題的原因是因為NUnit不能保證測試按照某種特定的順序執行,因此今天通過的測試,明天可能就不好用了
- 隱藏的測試調用:測試調用其他測試
- 共享狀態被破壞:測試要共享狀態,但是在一個測試完成之后沒有重置狀態,進而影響后面的測試
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,換言之是否要先寫單元測試后實現功能是值得討論的事情,但是單元測試本身是十分有必要在工程中使用的。在代碼結構設計、日后的重構都會很有幫助。