這篇文章主要是在說明我們如何在 Unity 專案中導入 TDD 開發方法。

開發環境主要使用 C# 語言在 Unity 2017.4.4f1 + Visual Studio for Mac Community 7.5.1,並使用 NUnitNSubstitute 來作為開發單元測試專案的開發套件。

在開始之前

什麼是 TDD

TDD 是 Test Driven Development(測試驅動開發)的縮寫,簡而言之就是「編碼之前、測試先行」,先寫 Test Case 再實作功能去滿足測項的條件。

TDD 是個知名的開發方法,其最知名的好處就是「單元測試」和「開發」的緊密結合,利用這種開發方式,會保證開發者在開發階段中不會省略單元測試、寫出更適合測試的程式架構,對未來專案的改動或新功能都更好開發和維護。

我們的目標是什麼

  1. 我們希望能導入 TDD 到 Unity 的專案開發中,開發可測試的程式碼。
  2. 測試程式碼可以不用到 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) 一樣,AnimatorRigidbody2D 都無法使用 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 將其抽換成假物件。 (如下示意圖)

Dream-World-Unity-Has-Interface

但真實世界不是都這麼美好的,Unity 並沒有提供任何 Interface,那我們有沒有機會幫他們架一層 Interfaces 呢?

由於我們無法改動 Unity 的 source code,沒有辦法替他原有的 Class 加上 Interface,所以勢必要在 Interfaces 和 Unity Classes 之間介接一層新增的 Classes,在下圖中我們用前綴「FD」 (Fourdesire 的縮寫) 來命名。

FD-Version-Interface-For-Unity

但如果 FDClass 要直接繼承原先 Unity 的 Class,那其實同樣的問題並沒有解決,我們依然會遇到
1. 無法使用 Constructor,離不開 Unity Editor
2. 有些 sealed Class 甚至連繼承都沒辦法,例如:RectTransformAnimator

所以我們將會遇到第一個難關,也就是

如何實現 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 例如 StartUpdateAwake ,還有 StartCoroutineGetComponent 等,原先 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 {{EJS1-5}}
    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 {{EJS1-6}}.
  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易於測試版本的 MonoBehaviourFDMonoBehaviour 誕生了😍。

接下來,我們會針對 FDMonoBehaviour 做更多深入的介紹,包括
如何自動產生對應的 MonoBehaiour script?
如何在 Unity cs solution 中加入 unit test 的 cs project?
其他實作 FDMonoBehaviour 中遇到的問題以及我們如何解決?

參考文獻

[1] Introduction to Test Driven Development (TDD)
[2] Making A Flappy Bird Style Game
[3] Tile 16 – Effective unit testing in Unity
[4] Unit testing MonoBehaviours
[5] NSubstitute: Getting started
[6] NUnit Documentation
[7] Image via clement127, CC Licensed.