使用 MSTest 撰寫單元測試套路整理 (2) - 處理物件相依

前言

本篇文章主要是針對測試替身 (Test Doubles) 測試方式的一些套路技巧說明。

目錄

4. 引入外部相依 (Dependency)

4.1 什麼是外部相依 (Dependency)?

現如今軟體系統越做越大,已經不可能由單一系統元件,就可以完成所有工作。

往大了看,一個系統背後可能會呼叫不同的 Web API 來協助完成工作,比方說一個登入頁面可能會呼叫 OAuth 的 web service 來進行使用者登入動作;往小處看,一個類別 (Class) 可能也會需要與其他類別協作,比方說在 .NET 傳統的三層式架構 (Presentation Layer、Business Logic Layer、Data Access Layer) 下,一個 BLL class 可能會使用不同的 DAO class 來進行資料存取的動作。

對我們來說,這些非我族類的系統或是類別,我們都可以視為是外部相依。

讓我們修改上篇文章 的例子,假設現在銀行要鼓勵存款,所以提出一個行銷活動︰每次存款都有 1% 的機率,可以即時獲得回饋金 10 元,架構大概像這樣︰

為了達成上述目錄,讓我們修改 Production Code 如下︰

Production Code (v1.1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/// <summary>
/// 樂透機
/// </summary>
public interface ILotteryMachine
{
/// <summary>
/// 是否得獎
/// </summary>
/// <returns>true 表示得獎</returns>
bool IsPrizeWinner();
}

/// <summary>
/// 樂透機
/// </summary>
public class LotteryMachine : ILotteryMachine
{
/// <summary>
/// 是否得獎
/// </summary>
/// <returns>true 表示得獎</returns>
public bool IsPrizeWinner()
{
// 避免失焦,此處忽略實作,假設真實實作了 1% 中獎率
}
}

/// <summary>
/// 銀行帳戶
/// </summary>
public class BankAccount
{
/// <summary>
/// 樂透機
/// </summary>
public ILotteryMachine LotteryMachine;

/// <summary>
/// 帳號餘額
/// </summary>
public int Balance { get; set; }

/// <summary>
/// Bank Account 建構子
/// </summary>
/// <param name="initialAmount">帳戶初始餘額</param>
public BankAccount(int initialAmount)
{
this.Balance = initialAmount;
}

/// <summary>
/// 存款
/// </summary>
/// <param name="amount">存入數量</param>
public void DepositMoney(int amount)
{
this.Balance += amount;

if (this.LotteryMachine.IsPrizeWinner())
{
this.Balance += 10;
}
}
}

針對這樣的 Production Code,我們該如何測試呢?假設我們現在想測試「存款並中獎」的情境,直覺上,我們可能會將測試程式寫成以下格式︰

Test Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[TestClass]
public class BankAccountTest
{
[TestMethod]
public void DepositMoney_存款100元進餘額為33元的帳戶中_活動中獎_帳戶餘額應為143元()
{
// Arrange
var lotteryMachine = new LotteryMachine();
var target = new BankAccount(33);
target.LotteryMachine = lotteryMachine;

// Act
target.DepositMoney(100);

// Assert
var expected = 143;
Assert.AreEqual(expected, target.Balance);
}
}

這樣的測試程式看起來是對的,也可以執行,但一旦您真的執行此測試程式,你會發現什麼呢?那就是如果您的 Production Code 沒有實作錯誤的話,您的 Test Case 是綠燈的可能性只有 1%

為什麼呢?因為在您的 Test Code 中,BankAccount 類別是使用真實的 LotteryMachine 實作,而 LotteryMachine 在真實的情形下,真正中獎的機率就是 1%,也就造成這個「中獎」的測試案例會成真的機率只有 1% 了。

從這例子中,我們可以看到,待測物件 BankAccount 使用相依物件 LotteryMachine,會造成測試案例無法穩定執行。但回頭來看,其實我們想要驗証的邏輯,並不是「樂透機中獎了沒?」,而是「當樂透機顯示中獎的前提下,我的商業邏輯可不可以正常運作?」

從這角度出發,是不是我們可以提供待測物件 BankAccount 一個「穩定會中獎」的樂透機,讓我們可以驗証的商業邏輯呢?

回到目錄

4.2 相依物件回傳罐頭回應

自己實作罐頭回應

當然熟悉 OOP 的同學會想,我可以自己去實作 ILotteryMachine 界面,讓 IsPrizeWinner() 方法總是回傳 true(中奬),不就可以設定「總是中獎」這個情境了嗎?像這樣︰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class AlwaysWinLotteryMachine : ILotteryMachine
{
public bool IsPrizeWinner()
{
return true;
}
}

[TestClass]
public class BankAccountTest
{
[TestMethod]
public void DepositMoney_存款100元進餘額為33元的帳戶中_活動中獎_帳戶餘額應為143元()
{
// Arrange
var lotteryMachine = new AlwaysWinLotteryMachine();
var target = new BankAccount(33);
target.LotteryMachine = lotteryMachine;

// Act
target.DepositMoney(100);

// Assert
var expected = 143;
Assert.AreEqual(expected, target.Balance);
}
}

我們實作一個新的 AlwaysWinLotteryMachine class,讓 IsPrizeWinner() 方法總是回傳 true,就可以設定中獎的情形了。當然這是一個好的思路,但這種純手工的做法有個明顯的缺點,就是會讓您的 Test Code 充斥非常多給測試用的人工類別,除了降低 Test Code 的可讀性之外,也會令 Test Code 越來越複雜。

舉例來說,如果我們又要驗証「沒中獎」這情形,按照上述做法,我們可能會再實作一個 AlwaysLoseLotteryMachine 類別,最終我們的 Test Code 就會變成這樣︰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class AlwaysWinLotteryMachine : ILotteryMachine
{
public bool IsPrizeWinner()
{
return true;
}
}

class AlwaysLoseLotteryMachine : ILotteryMachine
{
public bool IsPrizeWinner()
{
return true;
}
}

[TestClass]
public class BankAccountTest
{
[TestMethod]
public void DepositMoney_存款100元進餘額為33元的帳戶中_活動中獎_帳戶餘額應為143元()
{
// Arrange
var lotteryMachine = new AlwaysWinLotteryMachine();
var target = new BankAccount(33);
target.LotteryMachine = lotteryMachine;

// 下略...
}

[TestMethod]
public void DepositMoney_存款100元進餘額為33元的帳戶中_活動未中獎_帳戶餘額應為133元()
{
// Arrange
var lotteryMachine = new AlwaysLoseLotteryMachine();
var target = new BankAccount(33);
target.LotteryMachine = lotteryMachine;

// 下略...
}
}

您看我們的 Test Code 是不是越來越肥大了呢?有沒有比較簡單又省事的做法可以使用呢?

使用 NSubstitue(NS) library 簡化工作

因為上述的情形,在撰寫測試時非常常見,所以為了減少這段無用化,就會有許多人開發相關的 mocking library / framework。在我們家,我們目前使用的 mocking framework 則是 NSubstitute

回到上述的問題,使用 NSubstitute 的話,我們的測試程式,就可以簡化為︰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[TestClass]
public class BankAccountTest
{
[TestMethod]
public void DepositMoney_存款100元進餘額為33元的帳戶中_活動中獎_帳戶餘額應為143元()
{
// Arrange
var lotteryMachine = NSubstitute.Substitute.For<ILotteryMachine>();
lotteryMachine.IsPrizeWinner().Returns(true); // 中獎

var target = new BankAccount(33);
target.LotteryMachine = lotteryMachine;

// 下略...
}

[TestMethod]
public void DepositMoney_存款100元進餘額為33元的帳戶中_活動未中獎_帳戶餘額應為133元()
{
// Arrange
var lotteryMachine = NSubstitute.Substitute.For<ILotteryMachine>();
lotteryMachine.IsPrizeWinner().Returns(false); // 沒中獎

var target = new BankAccount(33);
target.LotteryMachine = lotteryMachine;

// 下略...
}
}

從上面的程式碼中,可以看到,我們使用了 NSubstitute.Substitute.For<ILotteryMachine>() 方法來幫我們建立一個 ILotteryMachine 的替身假物件,並且在 lotteryMachine.IsPrizeWinner().Returns(true); 這一行 statement 中,使用了 Returns() 來告訴我們的假物件,針對 IsPrizeWinner() 這方法一律只回傳 truefalse 的罐頭回應。

您可以比較看看,我們使用 NS 改寫的 Test Code,與上一章節中,我們手工建立的 AlwaysWinLotteryMachineAlwaysLoseLotteryMachine 類別相比,測試程式的可讀性與精簡度是不是大幅提升了呢?

其實這樣單純只是回傳罐頭回應的假物件,有個學名稱之為 Stub,細部的定義可以查看 References 中的文章,對這名詞會有更深入的了解。

回到目錄

4.3 相依物件拋出例外

很多時候我們在撰寫程式時,不能假定世界一定都會運作如常。要記住只要是程式,就一定會有出錯的可能,能不能預先設想程式可能出錯的情形,並做好良好的錯誤處理,也是我們看待一位工程師能力成熟度的指標之一。

現在假定我們的樂透機可能會出錯並拋出例外,對於我們的 BankAccount 類別來說,為了不影響使用者的操作體驗,可能會需要將 LotteryMachine 拋出的例外給 catch 住,並記錄相關 log。這種時候雖然對不起存戶,但可能就視本次存款沒有中獎,不提供給存戶獎金。

我們可能會將 Production Code 改寫為這樣︰

Production Code (v1.2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/// <summary>
/// 銀行帳戶
/// </summary>
public class BankAccount
{
// 略...

/// <summary>
/// 存款
/// </summary>
/// <param name="amount">存入數量</param>
public void DepositMoney(int amount)
{
this.Balance += amount;

bool isPrizeWinner = false;
try
{
isPrizeWinner = this.LotteryMachine.IsPrizeWinner();
}
catch
{
// 相關錯誤處理,此處略...
}

if (isPrizeWinner)
{
this.Balance += 10;
}
}
}

這時候我們要怎麼驗証這個「因為樂透機出錯所以視為不中獎沒有獎金」(好繞,但您懂的)的商業邏輯呢?聰明的您一定想到了,可以利用上面我們提到的 Stub 物件了,是不是可以讓我們的罐頭物件穩定的拋出例外呢?

有的,大部份的 mocking library / framework 都可以做到這件事,下面我們一樣以 NSubstitute 來做示範︰

Test Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[TestClass]
public class BankAccountTest
{
// 略...

[TestMethod]
public void DepositMoney_存款100元進餘額為33元的帳戶中_樂透機出錯視為未中獎_帳戶餘額應為133元()
{
// Arrange
var lotteryMachine = NSubstitute.Substitute.For<ILotteryMachine>();
lotteryMachine.IsPrizeWinner().Returns(x => { throw new Exception(); }); // 出錯

var target = new BankAccount(33);
target.LotteryMachine = lotteryMachine;

// Act
target.DepositMoney(100);


// Assert
var expected = 133;
Assert.AreEqual(expected, target.Balance);
}

// 略...
}

可以看到我們將 Returns() 方法回傳的罐頭回應改為拋出例外,並驗証我們的 BankAccount 目標類別,可不可以正確處理這樣子的錯誤情形。

回到目錄

5. 測試物件間的互動

讓我們再回到上述樂透機出錯的情形,現在假設我們想要在樂透機出錯時,在 Log 下記錄相關訊息。比方說我想記錄存戶當時正在存多少錢,我們可能會將 Production Code 再改為以下︰

Production Code (v1.3)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/// <summary>
/// Logger
/// </summary>
public interface ILogger
{
/// <summary>
/// 記錄 Log 資訊
/// </summary>
/// <param name="logMessage">Log 資訊</param>
void Log(string logMessage);
}

/// <summary>
/// 將 Log 寫至檔案中
/// </summary>
public class FileLogger : ILogger
{
/// <summary>
/// 記錄 Log 資訊
/// </summary>
/// <param name="logMessage">Log 資訊</param>
public void Log(string logMessage)
{
// 忽略細節
}
}

/// <summary>
/// 銀行帳戶
/// </summary>
public class BankAccount
{
/// <summary>
/// 樂透機
/// </summary>
public ILotteryMachine LotteryMachine;

/// <summary>
/// 帳號餘額
/// </summary>
public int Balance { get; set; }

/// <summary>
/// Logger
/// </summary>
public ILogger Logger { get; set; }

/// <summary>
/// Bank Account 建構子
/// </summary>
/// <param name="initialAmount">帳戶初始餘額</param>
public BankAccount(int initialAmount)
{
this.Balance = initialAmount;
}

/// <summary>
/// 存款
/// </summary>
/// <param name="amount">存入數量</param>
public void DepositMoney(int amount)
{
this.Balance += amount;

bool isPrizeWinner = false;
try
{
isPrizeWinner = this.LotteryMachine.IsPrizeWinner();
}
catch
{
this.Logger.Log($"存戶存款 {amount} 時,樂透機出錯!");
// 相關錯誤處理,此處略...
}

if (isPrizeWinner)
{
this.Balance += 10;
}
}
}

針對這樣子的程式碼,我們又該如何測試呢?此時我們驗証的重點,已經不是 BankAccount 物件的餘額是否正確,而是「BankAccount 物件在 LotteryMachine 出錯的前提下,有沒有正確呼叫 Logger 並寫入正確的 Log 了」(我知道,唸起來一樣很繞)

針對驗証 BankAccount 物件與 Logger 物件間的互動行為 (在此例中,就是 BankAccount 有沒有呼叫 Logger & 訊息是否如預期) 是否正確的測試方式,我們稱之為 Behavior-based Verification (行為驗証),針對這樣的驗証需要,我們該如何撰寫 Test Code 呢?

此時又要再向大家介紹另一種假物件的形式︰Mock,讓我們先看看 Test Code 該如何寫︰

Test Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[TestClass]
public class BankAccountTest
{
// 略...

[TestMethod]
public void DepositMoney_存款100元時_樂透機出錯_應正確寫入Log()
{
// Arrange
var lotteryMachine = NSubstitute.Substitute.For<ILotteryMachine>();
lotteryMachine.IsPrizeWinner().Returns(x => { throw new Exception(); }); // 出錯

var logger = NSubstitute.Substitute.For<ILogger>();

var target = new BankAccount(33);
target.LotteryMachine = lotteryMachine;
target.Logger = logger;

// Act
target.DepositMoney(100);


// Assert
logger.Received().Log("存戶存款 100 時,樂透機出錯!");
}

// 略...
}

上面 Test Code 中的 logger 就是我們所說的 Mock object,可以看到 我們一樣是使用 NSubstitute.Substitute.For<>() 方法來建立這個 Mock object,但在測試的用途上,我們就不是使用 Returns() 方法來讓 logger 來回傳罐頭訊息,而是使用 Received() 來驗証,logger 是否有如我們預期被 BankAccount 物件所呼叫。

針對這方面的 API 細節資訊,可以再閱讀 NS 官網上 Checking received calls 文件,會有更多細節的了解。

小結

本文說明了在程式中引入相依性時,該如何進行測試的思路與手法。說穿了就是 2 種測試物件︰StubMock,在實務上我們常會視需求穿插使用這 2 種測試物件,因此了解它們的特性與差異是非常有必要的。針對它們的定義想有更清楚的了解,可以閱讀 References 中的參考文件,會大有幫助。

在下一篇文章中,我會再補充,一般 .NET 程式使用 MSTest 撰寫測試時,還有哪些實用的技巧與注意事項,我們下次見。

References

回到目錄

Proudly powered by Hexo and Theme by Hacker
© 2018 LittleLin