這篇文章主要是在說明我們如何在 Unity 專案中導入 TDD 開發方法。
開發環境主要使用 C# 語言在 Unity 2017.4.4f1 + Visual Studio for Mac Community 7.5.1,並使用 NUnit 和 NSubstitute 來作為開發單元測試專案的開發套件。
在開始之前
什麼是 TDD
TDD 是 Test Driven Development(測試驅動開發)的縮寫,簡而言之就是「編碼之前、測試先行」,先寫 Test Case 再實作功能去滿足測項的條件。
TDD 是個知名的開發方法,其最知名的好處就是「單元測試」和「開發」的緊密結合,利用這種開發方式,會保證開發者在開發階段中不會省略單元測試、寫出更適合測試的程式架構,對未來專案的改動或新功能都更好開發和維護。
我們的目標是什麼
- 我們希望能導入 TDD 到 Unity 的專案開發中,開發可測試的程式碼。
- 測試程式碼可以不用到 Unity Editor 在其他環境,例如:VS 中獨立執行。希望藉此達到 lightweight 以及後續可以很容易用 .Net framework 整合進 CI (Continuous Integration) 系統。
Unity 為什麼很難做 TDD
在說明 Unity 對於 TDD 的難處前,我們先來看一則典型的 TDD Test Case 範例:
[TestFixture ()]
public class TestClass {
[Test ()]
public void TestCase () {
// Arrange
// Act
// Assert
}
}
典型的測項會涵蓋三個部分 1. Arrange: 準備受測項目(Tested Class)、控制其他環境變因 2. Act: 給予適當的 input,並啟動 3. Assert: 觀察結果並與預期的答案比對
我們借用 Unity 官方的教學專案 Making A Flappy Bird Style Game 其中的 Bird Script:
using UnityEngine;
using System.Collections;
public class Bird : MonoBehaviour {
public float upForce;
private bool isDead = false;
// Some components...
private Animator anim;
private Rigidbody2D rb2d;
void Start() {
//Get reference to the Animator component attached to this GameObject.
anim = GetComponent ();
//Get reference to the Rigidbody2D attached to this GameObject.
rb2d = GetComponent ();
}
void Update() {
//Don't allow control if the bird has died.
if (isDead == false) {
if (Input.GetMouseButtonDown(0)) {
anim.SetTrigger("Flap");
rb2d.velocity = Vector2.zero;
rb2d.AddForce(new Vector2(0, upForce));
}
}
}
void OnCollisionEnter2D(Collision2D other) {
rb2d.velocity = Vector2.zero;
isDead = true;
anim.SetTrigger ("Die");
//Singleton GameControl instance
GameControl.instance.BirdDied ();
}
}
即使程式本身非常簡單入門,但要為其寫測試程式碼卻還是會遭遇各種挑戰。
1. 生成 MonoBehaviour 待測物件:
Unity 的設計概念就是不希望使用者透過 Constructor 去創造各種物件,所以當你使用 new Bird (); 時,會有 Runtime MissingMethodException 產生。當然,如果在 Unity Editor 中,你可以利用 AddComponent (); 來取代,但你依舊無法解決以下的問題。
2. 準備會使用到的 components 的 fake/mock instance:
跟 MonoBehaviour (其實也是一種 component) 一樣,Animator 和 Rigidbody2D 都無法使用 Constructor,甚至無法繼承 (sealed class),加上沒有實作 interface,這讓我們幾乎無法去抽換這兩個物件,這其實就讓我們很難做到真正的單元測試 --- 因為待測 class 的行為會受其他 class 的內部邏輯影響。
3. Static Method 的難以抽換:
承上問題,Input.GetMouseButtonDown () 是 Static Method,基本上直接使用 Static Method 難以抽換是 TDD 常見的問題,基本上就是要使用 Dependency Injection 來解決,但這篇文章先不討論這個問題。
加上 Unity 本身設計時就沒有特別考慮為單元測試的易測度做設計,所以各種文件和教學,都傾向把各種 components 在各個 method 中使用,其本身難以偽造的性質就讓這些 methods 基本上是無法被測試。
從網路上可以看到很多人在討論、研究怎麼寫出可測試的 Unity 專案:
基本上不外乎是使用各種 Design patterns 將 Unity 相關的程式碼與主要邏輯程式抽離,讓這些邏輯程式區塊變成與 Unity 無依賴性。優點是無需做 Framework 的開發,但缺點是基本上需要極強的 architecture design 能力,且主要還是依據各個專案需求去拆解 Unity 相關的程式碼,在不同屬性的專案中要復用較為困難。
向 Unity 發出戰帖吧
一言以敝之 --「移除對 Unity 的依賴性」
我們假設在一個理想的世界中「Unity 對他所有 Class 都有做對應的 Interface」。
那在開發時,我們只要使用這些 Interfaces 取代原先的 Classes,就能很輕易地在單元測試中用 NSubstitute 將其抽換成假物件。 (如下示意圖)
但真實世界不是都這麼美好的,Unity 並沒有提供任何 Interface,那我們有沒有機會幫他們架一層 Interfaces 呢?
由於我們無法改動 Unity 的 source code,沒有辦法替他原有的 Class 加上 Interface,所以勢必要在 Interfaces 和 Unity Classes 之間介接一層新增的 Classes,在下圖中我們用前綴「FD」 (Fourdesire 的縮寫) 來命名。
但如果 FDClass 要直接繼承原先 Unity 的 Class,那其實同樣的問題並沒有解決,我們依然會遇到 1. 無法使用 Constructor,離不開 Unity Editor 2. 有些 sealed Class 甚至連繼承都沒辦法,例如:RectTransform 和 Animator
所以我們將會遇到第一個難關,也就是
如何實現 FDClass
遇到難題時,首先先看一下自己目前有什麼招式:
1. Directly Inheritance (直接繼承
- FDClass 直接繼承 Unity 原生類別。
- 優點: 最小改動,只需幫現有 API 另做一份 Interface 出來,所有 function 就無痛使用。
- 缺點: 無法使用 Constructor,無法離開 Unity Editor 做 Unit Test,不能支援 sealed class。
2. Wrapper Class (外包類別
- Unity 原生類別為 FDClass 中的一個 member,FDClass 實作出與原生的 API 一樣的 Interface 並轉接到原生的 API。
- 優點: 可以應付 sealed class,對使用者來與直接繼承使用介面相同,幾乎無感。
- 缺點: 所有原生 API (包含此 class 定義以及從 base class 繼承來的 API) 都必須實作轉接 method,加法哲學,沒有實作就無法使用。還有,因為不是 Unity Component 無法支援 Unity Editor Inspector。
各個招式都有其優缺點,而且當面對不同的 Unity 原生類別時,由於不同的特性和使用需求,我們很難一體適用其中一種方法。舉例來說,Animator 是 sealed class 幾乎毫無選擇只能使用 wrapper class;而 Text 的功能相對單純也能被繼承,很適合直接繼承來使用。
而 MonoBehaviour 作為整個 Unity 程式邏輯的核心,雖然可以被繼承,但如果直接繼承,那測試環境就無法從 Unity Editor 獨立出來;而如果使用 wrapper class,那等於直接宣告從此與 inspector 分手,無法在 inspector 替 GameObject 加 Script,將會徹底的改寫 Unity 原本定義的開發模式,這是我們最不希望發生的 -- 改變開發者已習慣的開發模式。
為了盡量讓使用者延用之前已習慣的開發模式 -- 在 Unity inspector 加 Script component,這些 Script 勢必要是 MonoBehavior,但我們的目標又是把邏輯從其中抽離出來,所以一個解法就此產生 -- 把 MonoBehaviour 當作是我們主要邏輯的 wrapper class。
這就是 FDMonoBehaviour
FDMonoBehaviour 就是依循這個概念而設計的,我們希望他的使用方式能跟 MonoBehaviour 一樣,當你要開發 Script 時,你的 Class 必須繼承 FDMonoBehaviour,可以使用 Unity message methods 例如 Start、Update、Awake ,還有 StartCoroutine、GetComponent 等,原先 MonoBehaviour 有的功能。我們改寫之前的 Bird Script 來作為範例程式。
/*
* Just for example.
* Do NOT use this code.
*/
using UnityEngine;
using System.Collections;
public class Bird : FDMonoBehaviour {
public float upForce;
private bool isDead = false;
// Some FD version components
private IFDAnimator anim;
private IFDRigidbody2D rb2d;
public void Start() {
//Get reference to the Animator component attached to this GameObject.
anim = GetComponent ();
//Get reference to the Rigidbody2D attached to this GameObject.
rb2d = GetComponent ();
}
public void Update() {
//Don't allow control if the bird has died.
if (isDead == false) {
if (Input.GetMouseButtonDown(0)) {
anim.SetTrigger("Flap");
rb2d.velocity = Vector2.zero;
rb2d.AddForce(new Vector2(0, upForce));
}
}
}
public void OnCollisionEnter2D(Collision2D other) {
rb2d.velocity = Vector2.zero;
isDead = true;
anim.SetTrigger ("Die");
//Singleton GameControl instance
GameControl.instance.BirdDied ();
}
}
他沒有繼承任何 Unity 原生類別,而是當你在文字編輯器編輯好 script 切回 Unity Editor 時,會自動產生一個對應的 MonoBehaviour wrapper class,讓你能夠在 Unity Editor 中去使用,連結到不同的 component 中。例如上面的範例程式就會產生以下的 MonoBehaviour script。
/*
* Just for example.
* Do NOT use this code.
*/
/************************************************************************
* Do **NOT** Modify This File! All Your Change Will Not Be Preserved! *
* *
* This file is auto-generated by script, and will be overwritten when *
* the Unity reloads the script files. *
************************************************************************/
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class BirdMonoBehav : MonoBehaviour {
public Bird innerClass = new Bird ();
FDGameObject FDGObject;
public void InitInnerClass () {
/*
* A weak reference of MonoBehaviour in FDMonoBehaviour.
* So that FDMonoBehaviour can redirect methods call to MonoBehaiour.
*/
innerClass._MonoBehav = new MonoBehaviourWrapper (this);
// FDGameObject is a wrapper class of GameObject.
FDGObject = new FDGameObject (gameObject);
innerClass.InitGameObject (FDGObject);
// Sync all fields' values to `Bird`
SyncComponents ();
}
public float upForce;
public Animator anim;
public Rigidbody2D rb2d;
private void Start() {
innerClass.Start ();
}
private void Update() {
innerClass.Update ();
}
private void OnCollisionEnter2D(Collision2D other) {
innerClass.OnCollisionEnter2D (other);
}
private void Awake () {
InitInnerClass ();
}
// Ignore this now, just take it as assigning value to corresponding field in `Bird`.
void SyncComponents () {
var components = new Dictionary ();
components.Add ("upForce", upForce);
components.Add ("anim", new FDAnimator (anim));
components.Add ("rb2d", new FDRigidbody2D (rb2d));
innerClass.SyncComponents (components);
}
}
從範例可以看到在這樣的設計下,BirdMonoBehav 裡完全沒有邏輯相關的程式,只是一層 wrapper,所有的邏輯都在 Bird 裡,且因為他只是從 System.Object 繼承出來的基本類別,可以簡單使用 Constructor 就創造,再加上我們分別使用原先提到的_直接繼承_和 Wrapper Class 兩種招式實作出的各個 FD verison components,我們就能如同範例程式中產生使用各個 interface 且可以直接使用 new 生成的 Bird Script,易於測試版本的 MonoBehaviour -- FDMonoBehaviour 誕生了😍。
接下來,我們會針對 FDMonoBehaviour 做更多深入的介紹,包括 - 如何自動產生對應的 MonoBehaiour script? - 如何在 Unity cs solution 中加入 unit test 的 cs project? - 其他實作 FDMonoBehaviour 中遇到的問題以及我們如何解決?