淺談依賴注入

昨天同事遇到了單元測試的問題,剛好與依賴注入(dependency injection)有關,因此剛好可以騙一篇文章來介紹與討論這個名詞。

在開始說明前,先來思考下面這個 Kata。這是教我單元測試的師父出的題目,但後續有跟其他社群朋友討論後,才知道原來這是 Joey Chen 前輩教材的一部分:

請實作可以判斷今天是否是聖誕節的程式,並對它寫個單元測試。

實作

這程式太簡單了,三秒鐘就能搞定!

function isTodayXmasDays(): bool
{
$now = new DateTime();

return '1225' === $now->format('md');
}

簡短到不需要註解說明,用 PHP 原生 DateTime 類別加上 format 方法就搞定了。

但,這個 Kata,難的是測試。下面有兩種 PHPUnit assertion 寫法,請問應該要用哪一種?

public function testIsTodayXmasDay()
{
// 預期今天是聖誕節
$this->assertTrue(isTodayXmasDays());

// 預期今天不是聖誕節
$this->assertFalse(isTodayXmasDays());
}

使用 assertTrue,那這個測試只有在 12/25 那天才會 pass;使用 assertFalse 則相反,在 12/25 那天會壞掉,而且那天還沒有放假!

來回頭看單元測試應該要有的五個原則--F.I.R.S.T. 原則

  • Fast
  • Independent 獨立,測試間互不依賴
  • Repeatable 任何環境都能正常執行
  • Self-Validating 直接回應驗證的結果
  • Thorough / Timely 確保程式與測試是一起完成的

這個程式無法符合 Self-Validating 特性,因為它無法「直接」反應,必須要時間到的時候才能反應,因此就會造成上述問題。

這個測試程式不符合 Repeatable 原則,因為它需要特定環境(指時間)才能正常執行,因此就會造成上述問題。

延伸思考:即便不是使用單元測試,透過驗收測試也會有一樣的問題。

Why?

為什麼會這樣?因為實作裡面有一段程式的內容是無法控制的,另一個說法則是它有副作用(side effect)。這段內容指的就是 new DateTime()--我們沒辦法控制「今天」是什麼時候。即便換成 date() 或其他函式,都有一樣的問題。

那該怎麼寫測試呢?這時候就輪到依賴注入出場了。依賴注入如其名,依賴在別的地方準備好後,再交給實際的程式使用。這段實作依賴了「今天」的內容,因此我們把今天的內容改成注入即可。

依賴注入的形式

在物件導向設計的實作裡,依賴注入的方法有三種。

參數注入

最簡單暴力的方法,直接加一個新的參數,在呼叫函式的時候,同時把依賴也給函式:

function isTodayXmasDays(DateTime $now): bool
{
return '1225' === $now->format('md');
}

測試的時候,只要根據物件類型來 mock 即可。

// 預期是聖誕節
$trueCase = Mockery::mock(DateTime::class)->shouldReceive('format')->willReturn('1225');
$this->assertTrue(isTodayXmasDays($trueCase));

// 預期不是聖誕節
$falseCase = Mockery::mock(DateTime::class)->shouldReceive('format')->willReturn('1224');
$this->assertFalse(isTodayXmasDays($falseCase));

建構子注入

在建構(new)的時候,同時把依賴放在自己的肚子裡,等呼叫對應的方法時再拿出來用。上例程式是函式,所以必須要改寫成類別才能使用這個形式:

class Xmas
{
private $now;

public function __construct(DateTime $now)
{
$this->now = $now;
}

public function isToday(): bool
{
return '1225' === $this->now->format('md');
}
}

測試的時候,在建構同時給日期的物件:

$mock = Mockery::mock(DateTime::class)->shouldReceive('format')->willReturn('1225');
$target = new Xmas($mock);

$this->assertTrue($target->isToday());

Setter 注入

與建構子注入差不多,只是改由另一個方法來處理這個任務:

public function setNow(DateTime $now)
{
$this->now = $now;
}

測試程式則需要額外呼叫 setter 方法:

$mock = Mockery::mock(DateTime::class)->shouldReceive('format')->willReturn('1225');

$target = new Xmas();
$target->setNow($mock);

$this->assertTrue($target->isToday());

結論

主要的方法就這三種,其他框架可能有更方便的方法,但原理都是利用其他機制(如反射)來自動處理或選擇上面的方法來達成最後的結果。

三種方法的選擇,我的經驗如下:

  1. 參數注入用在,只有該方法會需要用到此依賴的時候用的,但這個做法可能會有隱含問題,要確認的是方法與主要類別是否有內聚關係。
  2. 建構子注入用在,依賴與主要類別是強依賴關係。
  3. Setter 注入用在,依賴與主要類別是弱依賴關係,這個做法可能也會有隱含問題,要確認依賴跟主要類別的關係是否可以透過第三方(如 Context)來隔離。

最後,這個技巧可以有效應用在單元測試與設計模組上。如果想把程式寫好的話,建議上面三種方法要實際自己多嘗試,才會了解如何選擇與應用。