---
# System prepended metadata

title: Unit Test

---

# Unit Test

### 什麼是單元測試？

一個單元測試就是一段程式碼(通常是一個方法)，這段程式呼叫了另一段程式碼，然後驗證某些假設的正確性。如果這些假設是錯誤的，單元測試就會失敗。一個單元可以是一個方法或函數。

### 整合測試

**整合測試** 是對一個工作單元進行測試，而這個測試對被測試的單元並沒有完全的控制，而是使用該單元一個或多個真實依賴的相依物件，例如：時間、網路、資料庫、或檔案等等。


### 優秀的單元測試

一個優秀的單元測試是一段自動化的程式碼，這段程式會呼叫被測試的工作單元，之後對這個單元的單一最終結果的某些假設或期望進行驗證。單元測試幾乎都是使用單元測試框架進行撰寫的。撰寫單元測試很容易，執行起來快速。單元測試可靠、易讀、並且很容易維護。**只要產品程式碼不發生變化，單元測試的執行結果是穩定一致的。**

### 命名

```csharp=
[Test]
public void [UnitOfWorkName]_[Scenario]_[ExpectedBehavior] {
    // test code ...
}
```

---

### 專案介紹(LogAnalyzer)

完成一個產品，對公司的log檔案進行分析，在其中搜尋特定的情況與事件。

### 第一個測試

一個單元測試通常包含三個行為：
* **準備(Arrange)**：建立物件，進行必要設定等；
* **操作(Act)**：執行動作；
* **驗證(Assert)**：驗證事件符合預期。

```csharp=
public void IsValidFilename_BadExtension_ReturnFalse()
{
    var logAn = new LogAnalyzer();
    var result = logAn.IsValidLogFilename("FileWithBadExtension.foo");
    Assert.AreEqual(false, result);
}

public class LogAnalyzer
{
    public bool IsValidLogFilename(string filename)
    {
        if(!filename.EndsWith(".SLF"))
        {
            return false;
        }
        
        return true;
    }
}
```

### Assert(斷言) 類別

Assert類別是你的程式碼與NUnit測試框架中間的橋樑，用來確認在該假設下某個期望應該成立。

### Setup

NUnit 每次在執行前測試類別裡的任何一個測試方法之前，都會先呼叫[Setup]裡的發訪。

```csharp=
public class LogAnalyzerTests
{
    private LogAnalyzer _logAnalyzer;
    
    [SetUp]
    public void SetUp()
    {
        _logAnalyzer = new LogAnalyzer();
    }
}

public void IsValidFilename_BadExtension_ReturnFalse()
{
    var result = _logAnalyzer.IsValidLogFilename("FileWithBadExtension.foo");
    Assert.AreEqual(false, result);
}
```

### 驗證預期的例外

```csharp=
[Test]
public void IsValidFilename_EmptyFilename_ThrowException()
{
    var error = Assert.Catch<Exception>(() => _logAnalyzer.IsValidLogFilename(""));
    StringAssert.Contains("filename has to be provided", error.Message)
}

public class LogAnalyzer
{
    public bool IsValidLogFilename(string filename)
    {
        if(string.IsNullOrEmpty(filename))
        {
            throw new ArgumentException("filename has to be provided");
        }
        
        if(!filename.EndsWith(".SLF"))
        {
            return false;
        }
        
        return true;
    }
}
```

沒用Assert.AreEqual來進行完整字串驗證，而採用StringAssert.Contains去驗證訊息包含你所期待出現的字串的原因是：隨著時間的變化，當程式碼加入越來越多的功能之後，字串內容經常會發生變化，使用StringAssert可以讓你更容易維護，驗證你所期待的資訊是否被包含在字串之中。

### 狀態驗證

```csharp=
[Test]
public void IsValidFilename_WhenCalled_ChangesWasLastFilenameValid()
{
    var logAn = new LogAnalyzer();
    logAn.IsValidFilename("badextension.foo");
    Assert.AreEqual(false, logAn.WasLastFilenameValid);
}

public class LogAnalyzer
{
    public bool WasLastFilenameValid { get; set;}
    
    public bool IsValidFilename(string filename)
    {
        WasLastFilenameValid = false;
        if(string.IsNullOrEmpty(filename))
        {
            throw new ArgumentException("filename has to be provied")
        }
        
        if(!filename.EndsWith(".SLF"))
        {
            return false;
        }
        WasLastFilenameValid = true;
        
        return true;
    }
}
```

---

## 透過虛設常式(Stub)解決依賴問題

前面我們已經使用NUnit寫了第一個單元測試，同時也建立了幾個簡單的測試案例，在這些案例中，我們驗證了物件的回傳值。

接下來，我們討論更接近實務的例子，例如要測試物件依賴於另一個你無法控制或尚未實作的物件； Web服務、系統時間、或其他類的三方物件；在你的測試中無法決定回傳值，也無法控制行為的物件。

### 虛設常式(Stub)

SpaceX該如何確保太空人已經做好準備前往太空？

如果我們要對太空梭與太空人進行**整合測試**，那勢必得在太空中進行。這就是為什麼SpaceX建立整套模擬系統，模擬太空梭控制台的周遭環境，見此消除必須在外太控才能測試的外部依賴。

![](https://i.imgur.com/eUxtIB5.jpg)

**虛設常式**是在系統中產生一個可控的替代物，來取代一個**外部相依的物件**。你可以在測試中，透過虛設常式來避免相依物件所造成的問題。

**抑制測試（test-inhibiting）** — 當程式碼依賴於某個外部資源，即使邏輯是完全正確的，但這種依賴仍可能造成測試失敗


#### 依賴

我們假設系統所支援的Log檔案格式設定是被存放在硬碟中

```csharp=
public bool IsValidFilename(string filename){
    // read reference file
    // if the extension of the filename is in the list, return true.
}
```
這種設計的問題是，一旦測試依賴於檔案系統，這時所進行的其實就是整合測試。


### 如何解決依賴問題？

#### 加入抽象層
加入抽象層來封裝依賴行為，接者就可以在測試中模擬這個抽象層的實作內容
```csharp=
public bool IsValidLogFilename(string filename)
{
    var manager = new FileExtensionManager();
    return manager.IsValid(filename);
}

class FileExtensionManager
{
    public bool IsValid(string filename)
    {
        // read reference file and do some validations here.
    }
}
```

#### 擷取介面以便替換底層實作

**依賴反轉原則（Dependency inversion principle，DIP）** 是指一種特定的解耦（傳統的依賴關係建立在高層次上，而具體的策略設定則應用在低層次的模組上）形式，使得高層次的模組不依賴於低層次的模組的實現細節，依賴關係被顛倒（反轉），從而使得低層次模組依賴於高層次模組的需求抽象

```csharp=
public class FileExtensionManager : IExtensionManager
{
    public bool IsValid(string filename)
    {
        // read reference file and so some validations here.
    }
}

public interface IExtensionManager
{
    bool IsValid(string filename);
}
```

#### 建立需虛設常式

```csharp=
public class FakeExtensionManager : IExtensionManager
{
    public bool WillBeValid = false;
    public bool IsValid(string filename)
    {
        return WillBeValid;
    }
}
```

**這個類別物件使用Fake字眼，說明這個物件類似另一個物件，他可能被當成模擬物件(mock)或虛設常式(stub)使用**

#### 建構式注入

```csharp=
[Test]
public void IsValidFilename_SupportedExtension_ReturnTrue()
{
    var manager = new FakeExtensionManager();
    manager.WillBeValid = true;
    var logAn = new LogAnalyzer(manager);
    var result = logAn.IsValidLogFilename("short.ext");
    Assert.AreEqual(true, result);
}

public class LogAnalyzer
{
    private IExtensionManager _manager;
    public LogAnalyzer(IExtensionManager manager)
    {
        _manager = manager;
    }
    
    public bool IsValidLogFilename(string filename)
    {
        return _manager.IsValid(filename);
    }
}
```

#### 屬性注入

```csharp=
[Test]
public void IsValidFilename_SupportedExtension_ReturnTrue()
{
    var manager = new FakeExtensionManager();
    manager.WillBeValid = true;
    
    var logAn = new LogAnalyzer();
    logAn.ExtensionManager = manager;
    
    var result = logAn.IsValidLogFilename("short.ext");
    Assert.AreEqual(true, result);
}

public class LogAnalyzer
{
    private IExtensionManager _manager;
    
    public IExtensionManager ExtensionManager 
    {
        get => _manager;
        set => _manager = value;
    };
    
    public LogAnalyzer()
    {
        _manager = new FileExtensionManager();
    }
    
    public bool IsValidLogFilename(string filename)
    {
        return _manager.IsValid(filename);
    }
}
```

### 重構

**A型重構**: 將具象類別抽象程介面或委派。
**B型重構**: 重構程式碼，以便將瑋費或介面的偽實作注入至目標物件中。

### B型重構的幾種模式

* 從建構函式注入一個假物件 
* 透過屬性注入假物件
* 工廠模式 

### 單元測試的三種型式

* 回傳值測試
* 系統狀態測試
* 相依元件互動測試


### 互動測試

**互動測試**：如果一個特定的工作單元的最終結果，是呼叫另一個物件，就需要進行互動測試。

但是，被測試的物件不會有任何回傳值，或保存任何狀態，它根據一套複雜的業務邏輯，以正確地方式呼叫其它物件，而這個相依物件並不受你控制，或不屬於單元測試的一部份，那你該如何測試你的物標物件有正常地與其它物件互動呢？


### 基於值、狀態與互動測試

假設有一個灌溉系統，你對系統設定了一天要澆多少次水，每次要澆多少水量，你可以有兩種測試系統是否正常運行的方式：
* 基於狀態的整合測試
    * 土壤是否夠濕潤、樹是否健康、葉子是否青翠。也許很難測試，但如果可以，你就能知道系統是否正常
* 互動測試
    * 測試水量流過是否符合預期，甚至更改時間(虛設常式)以便快速驗證。
    
![](https://i.imgur.com/DajUjQg.jpg)


### 模擬物件

模擬物件是系統中的假物件，他可以拿來驗證被測試物件是否如預期呼叫這個假物件，因此來使得單元測試執行成功或失敗，通常每個測試物件裡面多只會有一個模擬物件。

// Put diagram to here

### 手刻模擬物件範例
```csharp=
public interface ILoggerService
{
    void LogError(string message);
}

public class FakeLoggerService : ILoggerService
{
    public string LastError;
    
    public void LogError(string message)
    {
        LastError = message;
    }
}

public class LogAnalyzer
{
    private ILoggerService _loggerService;
    
    public LogAnalyzer(ILoggerService service)
    {
        _loggerService = service;
    }
    
    public void Analyze(string filename)
    {
        if(filename.Length < 8)
        {
            _loggerService.Logger("Filename too short")
        }
    }
}

[Test]
public void Analyze_TooShortFileName_CallLoggerService()
{
    var mockLogger = new FakeLoggerService();
    var logAn = new LogAnalyzer(mcokLogger);
    var tooShortFileName = "abc.txt";
    logAn.Analyze(tooShortFileName);
    StringAssert.Contains("Filename too short", mockLogger.LastError)
}
```


---


### 隔離框架

隔離框架是可以幫助寫程式的API，使用這套API來建立假物件會比手刻假物件要容易得多、快得多、簡潔許多。

```csharp=
public void Analyze_TooShortFilename_CallLogger()
{
    var logger = Substitute.For<ILogger>();
    var logAn = new LogAnalyzer(logger);
    logAn.Analyze("abc.txt");
    logger.Received().LogError("Filename too short");
}
```

### 模擬回傳值

```csharp=
[Test]
public void IsValidLogFilename_ByDefault_ReturnTrue()
{
    var manager = Substitute.For<IExtensionManager>();
    manager.IsValid(Arg.Any<String>()).Return(true);
    var logAn = new LogAnalyzer(manager);
    var result = logAn.IsValidLogFilename("short.ext");
    Assert.AreEqual(true, result);
}
```

### 使用隔離框架應避免的事

* 驗證錯誤的東西
    * 驗證一個物件訂閱一個事件，不能保證功能是正確的。一個更好的方式是：你應該測試物件觸發事件時，發生一些有意義的事情。
* 一個測試中有多個模擬物件
* 過度指定測試
    * 指定驗證被測試物件的純內部行為：測試的程式碼不屬於任何公開契約或介面
    * 在需要虛設常式物件時，使用模擬物件
    * 不必要的順序或過於精準的參數匹配

### 