單元測試的藝術 - Ch6 深入瞭解隔離框架 - Sec. 6.3 ~ 6.5

6.3 支援適應未來和可用性的功能

一個好的測試框架會提供一些讓你可以因應未來變化的一些功能,如:

  • 遞迴假物件
  • 對行為和驗證忽略預設參數
  • 非嚴格驗證與行為
  • 大範圍偽造

6.3.1 遞迴假物件

遞迴假物件是在函數回傳其他物件時的一種特殊行為,這些回傳物件會自動產生假物件。

Humanable.php

1
2
3
4
interface Humanable
{
public function generatePerson(): Humanable;
}

Person.php

1
2
3
4
5
6
class Person implements Humanable
{
public function generatePerson(): Humanable
{
}
}

Test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @test
*/
public function GeneratePerson()
{
$stubPerson = $this->createStub(Person::class);

// generatePerson() 會返回假的 Humanable

$this->assertInstanceOf(
Humanable::class,
$stubPerson->generatePerson()
);

$this->assertInstanceOf(
Humanable::class,
$stubPerson->generatePerson()->generatePerson()
);

$this->assertInstanceOf(
Humanable::class,
$stubPerson->generatePerson()->generatePerson()->generatePerson()
);
}

這個功能為什麼會重要呢?你需要告訴測試關於偽造的每個特定的 API 資訊越少,測試和產品程式碼細部的實作關聯就越少,將來產品程式碼產生異動時,需要對測試做的修改也就越少。

6.3.2 預設忽略參數

忽略參數的寫法可以增加測試程式的可讀性,以及免去設定預設參數的時間成本

Pay.php

1
2
3
4
5
6
7
class Pay
{
public function byCreditCard(array $order_list, string $credit_card_no, int $money, Humanable $user): string
{
return '真實回傳';
}
}

Payment.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Payment
{
/**
* @var Pay
*/
private $pay;

public function __construct(Pay $pay)
{
$this->pay = $pay;
}

public function payByCreditCard(): bool
{
$result = $this->pay->byCreditCard([], 'str', 0, new Person());
if ($result == '參數忽略!') {
return true;
} else {
return false;
}
}
}

Test

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
/**
* @test
*/
public function MethodBeTest_StubWantStub()
{
//Arrange
$stubPay = $this->createStub(Pay::class);
// 參數都不填寫,強制讓他回傳'參數忽略!'
$stubPay->method('byCreditCard')->willReturn('參數忽略!');
$Payment = new Payment($stubPay);

//Act
$actual = $Payment->payByCreditCard();

//Assert
var_dump($actual);
$this->assertTrue($actual);
}

/**
* @test
*/
public function MethodBeTest_RealWantStub()
{
$Payment = new Payment(new Pay());
$actual = $Payment->payByCreditCard();

var_dump($actual);

$this->assertFalse($actual);
}

6.3.3 大範圍偽造

大範圍偽造是一次偽造多個方法的能力,我們可以指定某個物件上的多個函數都回傳相同的預設值,或是只對回傳值為特定型別的方法去指定預設回傳值

p.s. 在 phpunit 中我沒有找到類似的功能,如果有人知道還請分享

6.3.4 假物件的非嚴格行為

嚴格的模擬物件可能會在兩種狀況下導致失敗:

  1. 對他呼叫了非預期的方法
  2. 沒有對他預期的方法進行呼叫

作者表示第一種狀況尤其讓他困擾,有時候我們根本不在乎工作單元內部物件之間的內部協議,那麼我們就不需要去對他們之間的互動進行驗證

我們可以對於模擬物件(createMock())省略一些斷言,讓工作單元與他的互動驗證變薄弱,例如:省略 ->expected($this->once())->with($params)

6.3.5 非嚴格模擬物件

非嚴格模擬物件允許你對他進行任何的方法呼叫,即便這個方法不在原本的預期內。對於有回傳值的方法呼叫,非嚴格模擬物件會回傳給你一個預設值,如果回傳的是一個參考型別,非嚴格模擬物件就會回傳給你 null 。前面所提到的遞迴假物件則是這種非嚴格模擬物件運作機制下的一種特例。

6.4 隔離框架設計反模式

6.4.1 概念混淆

也叫做模擬過量,作者曾在前面章節一再重申,一個測試理應最多只會出現一個模擬物件 (Mock Objects),有的時候測試框架再提供我們虛設常式與模擬物件的時候,會使用同樣的 api 命名,但這樣其實會混淆我們的目標,導致測試的可讀性變差。

以下是一個不好的範例:

before

1
2
3
4
5
6
7
8
9
10
[Test]
public void ctor_WhenViewhasError_CallsLogger()
{
var view = new Mock<IView>();
var logger = new Mock<ILogger>();
Presenter p = new Presenter(view.Object, logger.Object);
view.Raise(v => v.ErrorOccured += null, "fake error");
logger.Verify(log =>
log.LogError(It.Is<string>(s=> s.Contains("fake error"))));
}

要怎麼避免這樣的事情發生呢?

  • 在 api 中對 mock 和 stub 使用明確的詞彙。(這個可能就只能在選用測試框架的時候做考量,因為偽造的 api 應該都是測試框架提供的)
  • 在 api 中都不要用 mock 和 stub 這樣的詞彙,例如都叫 Fake ,或者像是 NSubstitute 中我們會都使用 Substitute<Something>。(此點同上,與選用的測試框架有關係)
  • 修改變數命名以區分虛設常式與模擬物件,如 mockXXXstubXXX 這樣的命名。(實際上我們能做到的大概只有這點)

after

1
2
3
4
5
6
7
8
9
10
[Test]
public void ctor_WhenViewhasError_CallsLogger()
{
var stubView = new Mock<IView>();
var mockLogger = new Mock<ILogger>();
Presenter p= new Presenter(stubView.Object, mockLogger.Object);
stubView.Raise(view=> view.ErrorOccured += null, "fake error");
mockLogger.Verify(logger =>
logger.LogError(It.Is<string>(s=>s.Contains("fake error"))));
}

6.4.2 錄製與重播

隔離框架中提供的錄製與重播功能大大降低了測試程式的可讀性,讀測試的人需要在測試中來回反覆確認前後的對應關係才有辦法看清楚測試的全貌。

before

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
[Test]
public void ShouldIgnoreRespondentsThatDoesNotExistRecordPlayback()
{
// 準備
var guid = Guid.NewGuid();
// 一部分的執行
IEventRaiser executeRaiser;
using(_mocks.Record())
{
// 準備 (還是在驗證?)
Expect.Call(_view.Respondents).Return(new[] {guid.ToString()});
Expect.Call(_repository.GetById(guid)).Return(null);
// 一部分的執行
_view.ExecuteOperation += null;
executeRaiser = LastCall.IgnoreArguments()
.Repeat.Any()
.GetEventRaiser();
// 驗證
Expect.Call(_view.OperationErrors = null)
.IgnoreArguments()
.Constraints(List.IsIn("Non-existant respondent: " + guid));
}
using(_mocks.Playback())
{
// 準備
new BulkRespondentPresenter(_view, _repository);
// 執行
executeRaiser.Raise(null, EventArgs.Empty);
}
}

以下我們將上面的過程改寫成使用 3A 的模式(arrange-act-asser)去撰寫

after

1
2
3
4
5
6
7
8
9
10
11
12
13
[Test]
public void ShouldIgnoreRespondentsThatDoesNotExist()
{
// Arrange
var guid = Guid.NewGuid();
_viewMock.Setup(x => x.Respondents).Returns(new[] { guid.ToString() });
_repositoryMock.Setup(x => x.GetById(guid)).Returns(() => null);
// Act
_viewMock.Raise(x => x.ExecuteOperation += null, EventArgs.Empty);
// Assert
_viewMock.VerifySet(x => x.OperationErrors =
It.Is<IList<string>>(l=>l.Contains("Non-existant respondent: "+guid)));
}

6.4.3 黏性行為

一但我們告訴了假物件的方法在被呼叫時應該以何種方式運作,我們很有可能會在我們的每次測試去對這個假物件設定「現在應該做什麼」的答案,儘管你的測試並不在乎這些行為。

為了解決這個問題,隔離框架可以給行為加入預設的「黏性(stickiness)」,可以為某種方法設定他預設的行為,這讓測試本身不需要知道他合作的對象方法會如何運作,而那時的呼叫對眼前的測試來說也並不重要。

6.4.4 語法過於複雜

有些測試框架在使用了一段時間後,你還是很難記住如何進行基本操作,這會影響使用者寫程式的體驗,增加讀寫測試的成本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 開頭使用大寫字母 A 來建立物件
var lollipop = A.Fake<ICandy>();
var shop = A.Fake<ICandyShop>();

// 設定一個方法要回傳簡單值
A.CallTo(() => shop.GetTopSellingCandy()).Returns(lollipop);
A.CallTo(() => foo.Bar(A<string>.Ignored, "second argument"))
.Throws(new Exception());

// 把假物件的物件執行個體當作一般物件用
var developer = new SweetTooth();
developer.BuyTastiestCandy(shop);

// 驗證的語法就跟一般設定方法行為一樣
// 無需額外學習使用一個新的 API
A.CallTo(() => shop.BuyCandy(lollipop)).MustHaveHappened();

6.5 小結

  • 測試框架分為:受限的跟不受限的隔離框架,選擇框架時要理解一個框架的能力和他本身的限制
  • 支援適應未來性和可用價值的隔離框架可以讓你的單元測試工作變得更輕鬆