# PHPUnit ## Basics - 文件中 The Command-Line Test Runner 有結果縮寫的代表意義 - Error 是異常,Fail 是斷言錯誤 - 有些 Assertions 的判斷方式要注意,還是要看手冊跟實作方法才能確定,例如 `assertEmpty(['', ''])` ## Test Dependencies 測試的執行流程有獨立性,和平常的執行流程不同;如果不同測試方法有相依,例如需要使用前一個測試方法運算果的資料,可以用 `@depends` 來做相依的測試 ## Config - XML Configuration File 有各種元素可以用 - `<php></php>` 可以定義全域變數 ## Annotations 可以加在方法上,測試執行器會對這方法附加特殊功能,例如 `@depends`、`@dataProvider` ## Fixtures 提供一些方法方便進行測試例如 `setUp()`、`tearDown()`,如果想要使用其他方法名稱也可以搜尋有相同功能對應的 Annotations ## Tricks ### 測試 protected、private 屬性或方法 ```php= class User { private $firstName; protected $lastName; public function __construct($firstName, $lastName) { $this->firstName = ucfirst($firstName); $this->lastName = ucfirst($lastName); } protected function passwordHashed() { return 'password hashed'; } } class UserTest extends TestCase { // 使用 closure 和 bindTo 測試 private 屬性 public function testValidUserFirstName() { $user = new User('Mike', 'bibby'); $phpunit = $this; $closure = function () use ($phpunit) { $phpunit->assertSame('Mike', $this->firstName); }; $binding = $closure->bindTo($user, get_class($user)); $binding(); } // 使用匿名類別繼承要測試的類別測試 protected 屬性 public function testValidUserLastName() { $user = new class('Mike', 'bibby') extends User { public function getLastName() { return $this->lastName; } }; $this->assertSame('Bibby', $user->getLastName()); } // 使用 closure 和 bindTo 測試 private 方法 public function testPasswordHashed2() { $user = new User('Mike', 'bibby'); $phpunit = $this; $closure = function () use ($phpunit) { $phpunit->assertSame('password hashed', $this->passwordHashed()); }; $binding = $closure->bindTo($user, get_class($user)); $binding(); } // 使用匿名類別繼承要測試的類別測試 protected 方法 public function testPasswordHashed2() { $user = new class('Mike', 'bibby') extends User { public function getPasswordHashed() { return $this->passwordHashed(); } }; $this->assertSame('Bibby', $user->getPasswordHashed()); } } ``` ### 不用 setter() 也能 mock object 使用 closure、bindTo() ```php= class Database { public function getemailandlastname() { echo 'real database touched'; } } class User { private $firstName; protected $lastName; protected $db; public function __construct($firstName, $lastName) { $this->firstName = ucfirst($firstName); $this->lastName = ucfirst($lastName); $this->db = new Database(); } public function getFullName() { $this->db->getEmailAndLastName(); return $this->firstName . ' ' . $this->lastName; } } class UserTest extends TestCase { public function testValidDataFormat() { $user = new User('Mike', 'bibby'); $mockDb = new class extends Database { public function getemailandlastname() { echo 'not real database touched'; } }; $closure = function () use ($mockDb) { $this->db = $mockDb; }; $binding = $closure->bindTo($user, get_class($user)); $binding(); $this->assertSame('Mike Bibby', $user->getFullName()); } } ``` ### mock 注入的介面 要測試的類別依賴介面時,可以在測試程式中用匿名物件去實作該介面,然後注入到測試目標類別 ### 測試抽象類別的方法 在測試程式內用匿名物件去繼承要測試的抽象類,然後測試;如果要測試的方法依賴抽象方法的話就必須用 mock object 才能測試 ### 測試時避免靜態方法的影響 利用 `call_user_func()` 將直接呼叫 static function 的部分改為屬性,再搭配 set 來修改呼叫的方法,就可以在測試程式裡去修改呼叫的 static function ```php= <?php class Logger { public static function log($message) { echo 'real log ' . $message; } } class Product { protected $addLoggerCallable = [Logger::class, 'log']; public function setLoggerCallable(callable $callable) { $this->addLoggerCallable = $callable; } public function __construct(SessionInterface $session) { $this->session = $session; } public function fetchProductById($id) { $product = 'product 1'; $this->session->write($product); // Logger::log($product); call_user_func($this->addLoggerCallable, $product); return $product; } } class ProductTest extends TestCase { public function testProduct() { $session = new class extends Session { public function open() {} public function close() {} public function write($product) { echo 'mock write to the session ' . $product; } }; $product = new Product($session); $product->setLoggerCallable(function ($product) { echo 'mock log ' . $product; }); $this->assertSame('product 1', $product->fetchProductById(1)); } } ``` ### 總結 在測試程式中可以透過以下方法來處理依賴或者取得要測試類別的內部資料 1. 實作要依賴注入的介面 2. 繼承要依賴注入的類別 3. 使用 closure 和 bindTo ## converage analysis - 要安裝 XDubug 之類的工具 - phpunit.xml `forceCoversAnnotation` 設為 false,不然每個測試還要用 `@cover` anotation 標記 - 覆蓋率的算法基本上就是看有沒有執行到該分支語句 - 讓測試執行器計算覆蓋率時忽略不記某區塊 https://phpunit.readthedocs.io/en/8.5/code-coverage-analysis.html#code-coverage-analysis-ignoring-code-blocks ## Test Doubles & mocking real object ### stubs stub 目的是解決相依性問題,透過模擬相依物件,讓測試目標可以正常運作 ```php= <?php use PHPUnit\Framework\TestCase; class UserStubTest extends TestCase { public function testCreateUser() { // 這二種寫法一樣 $user = $this->getMockBuilder(StubUser::class) ->getMock(); $user = $this->createStub(StubUser::class); $user = $this->getMockBuilder(StubUser::class) ->setMethods(null) // no method will be replace ->getMock(); $user = $this->getMockBuilder(StubUser::class) ->disableOriginalConstructor() ->setMethods(['save']) ->getMock(); $user->method('save')->willReturn(true); $this->assertTrue($user->createUser('Adam', 'adam@email.com')); $this->assertFalse($user->createUser('Adam', 'adam')); } } class StubUser { public function __construct() { echo 'construct was called'; } public function createUser($name, $email) { $this->name = $name; $this->email = $email; if ($this->validate()) { return $this->save(); } else { return false; } } public function validate() { if (empty($this->name)) { return false; } if (! filter_var($this->email, FILTER_VALIDATE_EMAIL)) { return false; } return true; } public function save() { echo 'User was saved in database - real operation!'; return true; } } ``` ### mock mock 的目的是透過注入 mock object 來驗證測試目標與 mock object 的互動 我發現也會直接 mock 測試目標,當測試的目標方法 A 相依於另一個方法 B,而 B 會影響測試的結果時,也會 mock B 方法以順利測試 A 方法;這可以想成單元的範圍從物件縮小到方法 把 mock `Logger` 注入 `Product` 中,然後測試 `Logger` 在 `Product` 中是如何被使用 ```php= <?php class ProductMockTest extends TestCase { public function testSaveProduct() { // 封裝 getMockBuilder 的方法 $loggerMock = $this->createMock(Logger::class); $loggerMock->expects($this->once()) ->method('log') ->with( $this->equalTo('error'), $this->anything() ); $product = new Product($loggerMock); $this->assertFalse($product->saveProduct('Panasonic', 'price')); } public function testSaveProduct2() { $loggerMock = $this->createMock(Logger::class); $loggerMock->expects($this->exactly(2)) ->method('log') ->withConsecutive( [$this->equalTo('notice'), $this->stringContains('greater than 10')], [$this->equalTo('success'), $this->anything()] ); $product = new Product($loggerMock); $this->assertTrue($product->saveProduct('Panasonic', 11)); } } class Logger { public function log($type, $message) { echo 'real Logger executed'; } } class Product { private $logger; public function __construct(Logger $logger) { $this->logger = $logger; } public function saveProduct($name, $price) { if (! is_string($name)) { $this->logger->log('error', 'Invalid name'); return false; } elseif (! is_int($price)) { $this->logger->log('error', 'Invalid price'); return false; } if ($price > 10) { $this->logger->log('notice', 'Price is greater than 10'); } $this->logger->log('success', 'Product was saved'); return true; } } ``` ## 測試 trait、abstract class 的方法 ### 測試 trait ```php= <?php class MyTraitTest extends TestCase { public function testMyMethod() { $mock = $this->getMockBuilder(MyTrait::class) ->getMockForTrait(); $this->assertSame(20, $mock->traitMethod(10)); } } trait MyTrait { public function traitMethod($number) { return $number + 10; } } ``` ### 測試 abstract class 想測試抽象類別內有實作的方法,但該方法卻依賴抽象方法時,可以利用 mock 直接指定 abstract method 回傳值 ```php= <?php class Employeetest extends TestCase { public function testFullName() { $mock = $this->getMockBuilder(PersonAbstract::class) ->setConstructorArgs(['John', 'Doe']) ->getMockForAbstractClass(); $mock->expects($this->any()) ->method('getSalary') ->willReturn($this->returnValue(6000)); $this->assertSame('John Doe earns 6000 per month.', $mock->showFullNameAndSalary()); } } abstract class PersonAbstract { protected $firstName; protected $lastName; public function __construct($name, $lastName) { $this->firstName = $name; $this->lastName = $lastName; } abstract public function getSalary(); public function showFullNameAndSalary() { return $this->firstName . ' ' . $this->lastName . ' earns ' . $this->getSalary() . ' per month.'; } } ``` ## reference - [PHP Unit](https://phpunit.readthedocs.io/en/8.5/index.html) - [Isolated Unit Test](https://dotblogs.com.tw/hatelove/2017/01/23/bad-smells-discovered-by-unit-testing) ###### tags: `trick`