class: center, middle, inverse # 單元測試的藝術 ## Ch2 第一個單元測試 Dino Lai, 2018.02.05 --- ## 單元測試框架 ─ 簡介 * 易於用來撰寫、執行和確認單元測試程式及其結果的框架。 * xUnit, PHPUnit, JUnit, NUnit, CppUnit * [單元測試框架列表](https://en.wikipedia.org/wiki/List_of_unit_testing_frameworks) .center[ ![figure-2.1](figure-2.1.png) 圖 2-1 ] --- ## 單元測試框架 ─ 功能 | 單元測試實踐 | 框架如何進行輔助 | |-------------|:-----------------| | 輕鬆撰寫結構化的程式 | 框架為開發人員提供包含下列的類別庫:
可以繼承的基礎類別或介面 (`extends TestCase`)
在程式中用來標記測試方法的特性 (`@Test`)
提供專用驗證方法的類別,用來驗證程式碼 (`Assert.Equals`)
| | 執行一個或全部單元測試 | 測試框架提供一個測試執行器(一個主控台或 GUI 工具),用來:
發現程式中的測試
自動執行測試
執行期間顯示狀態
可以用命令列自動化
| | 確認測試執行結果 | 測試執行器通常提供下列資訊:
已執行測試的數量
未執行測試的數量
測試失敗的數量
哪一些測試是失敗的
測試失敗的原因
你所寫的 ASSERT 訊息
測試失敗的程式碼位置
可能導致測試失敗的例外呼叫堆疊資訊,可依此找到呼叫堆疊中各函數的呼叫資訊
| --- ## PHPUnit ### 安裝
### GUI
--- class: middle, center, inverse ## 範例 - LogAn (log and notification) --- ### 範例 - LogAn (log and notification) 對公司自製的 log 檔案進行分析,在其中搜尋特定的情況和事件。 .center[待測試單元 `src/LogAnalyzer.php`] ```php class LogAnalyzer { public function isValidLogFileName(string $fileName): bool { // should be ===, just for demo test failed if (substr_compare($fileName, ".SLF", -strlen(".SLF")) !== 0) { return false; } return true; } } ``` * **應該測試任何一個包含邏輯程式碼的方法** --- ### 加入一個自動化測試 * 新增 `tests` 資料夾 * 新增 `tests/LogAnalyzerTest.php` * 新增一個 method: `isValidFileName_BadExtension_ReturnFalse` ```php use PHPUnit\Framework\TestCase; class LogAnalyzerTest extends TestCase { public function test_IsValidFileName_BadExtension_ReturnFalse() { } } ``` #### 基本規則 | 測試物件 | 測試專案所建立的物件 | |---------|---------------------| | 專案 | 在專案根目錄新增 `tests` 資料夾 | | 類別 | `[ClassName]Test` | | 測試方法 | `[UnitOfWorkName]_[ScenarioUnderTest]_[ExpectedBehavior]` | --- ### 測試方法命名 `[UnitOfWorkName]_[ScenarioUnserTest]_[ExpectedBehavior]` * 如果使用方法名稱(UserLogin, RemoveUser, StartUp),要確保這些方法是公開的,否則他們不能真正代表一個工作單元的起點。 * UnitOfWorkName: 被測試的方法、一組方法、一組類別 * Scenario: 假設條件 * 登入失敗、無效的使用者、密碼正確 * 系統記憶體不足、無此使用者、使用者已存在 * ExpectedBehavior: 預期行為。 * 回傳一個結果值 * 系統狀態的改變 * 呼叫外部第三方系統 --- ### 測試程式位置選擇 #### 產品程式碼中? * 複雜的條件編譯設定 * 降低可讀性 * 部署之後測試產品程式品質 #### 單獨的測試專案? * 更容易執行所有測試相關的任務 .red[**PHP 習慣將測試程式放置於產品專案底下 tests 資料夾**] --- ## PHPUnit Feature * Test Class Naming * `XXXTest` * Test method: * `testYYY` * `@test` ```php class LogAnalyzerTest { /** * @test */ public function isValidFileName_BadExtension_ReturnFalse() { } public function testIsValidFileName_BadExtension_ReturnFalse() { } } ``` --- class: middle, center, inverse ## 第一個測試程式 --- ### 三個行為 1. **準備(Arrange)** 物件、建立物件、進行必要的設定 1. **操作(Act)** 物件 1. **驗證(Assert)** 某件事符合預期 ```php class LogAnalyzerTest extends TestCase { public function test_IsValidFileName_BadExtension_ReturnFalse() { // Arrange $analyzer = new LogAnalyzer(); // Act $result = $analyzer->isValidLogFileName("filewithbadextension.foo"); // Assert $this->assertFalse($result); } } ``` --- ### Assert 程式碼與測試框架中間的橋樑,用來確認在該假設下某個期望應該成立。 * `assertTrue(bool $condition[, string $message = ''])` * `assertEquals(mixed $expected, mixed $actual[, string $message = ''])` * `$expected` and `$actual` are equal * like `==` * `assertSame(mixed $expected, mixed $actual[, string $message = ''])` * have the same type and value * like `===` ```php assertEquals('2', 1+1); // pass assertSame('2', 1+1); // failed ``` **千萬不要使用 Assert 提供的 message 參數來呈現失敗訊息,而是思考該怎麼用你的測試名稱來說明應該發生的結果。** --- ### 使用 PHPUnit 執行第一個測試 #### 執行方法 * Console * GUI * IDE or Editor Extension #### 流程 1. 指定測試方法 `--filter
` ```console ./vendor/bin/phpunit --bootstrap ./vendor/autoload.php --filter IsValidFileName ch2/LogAnalyzerTests.php ``` 1. 失敗 1. 修正 ```php if (substr_compare($fileName, ".SLF", -strlen(".SLF")) !== 0) { return false } ``` --- ### 新增正向測試 想出完善的測試案例來涵蓋所有情況 * 測試大寫副檔名 * 測試小寫副檔名 ```php class LogAnalyzerTest extends TestCase { public function test_IsValidFileName_GoodExtensionLowercase_ReturnTrue() { $analyzer = new LogAnalyzer(); $result = $analyzer->isValidLogFileName("filewithbadextension.slf"); $this->assertTrue($result); } public function test_IsValidFileName_GoodExtensionUppercase_ReturnTrue() { $analyzer = new LogAnalyzer(); $result = $analyzer->isValidLogFileName("filewithbadextension.SLF"); $this->assertTrue($result); } } ``` * 失敗! --- ### 新增正向測試 Cont. * 改寫:改用不分大小寫的字串比對 ```php public function isValidLogFileName(string $fileName): bool { if (substr_compare($fileName, self::EXT, -strlen(self::EXT), strlen($fileName), true) !== 0) { return false; } return true; } ``` * 測試成功! .large.center[**紅燈─綠燈─重構**] --- ### 測試程式風格 * 測試名稱**可以很長** * **下底線**可以讓你不會遺漏所有重要的資訊。 * 準備物件、操作物件、驗證階段的程式碼之間,**多空一行空白**。 * 盡量把驗證和操作物件的程式碼**分開**。提高可讀性。 * 針對一個結果值而不是直接對一個方法呼叫來做驗證。 ```php // bad $this->assertTrue($analyzer->isValidLogFileName("filewithbadextension.slf")); // good $result = $analyzer->isValidLogFileName("filewithbadextension.slf") $this->assertTrue($result); ``` --- class: middle, center, inverse ## 重構 --- ### 使用參數來重構程式 Problem: 在類別 LogAnalyzer 建構式加入一個參數,全部測試都要修復。 Solve: 參數化測試 (parameterized tests) * 使用 [@testWith](https://phpunit.de/manual/6.5/en/appendixes.annotations.html#appendixes.annotations.testWith) 1. 新增 @testWith 1. 將測試中寫死的值,替換成測試方法的參數 1. 把被替換掉的值放到 @testWith 裡 1. 用一個比較共用的名字,重新命名測試方法 1. 合併其他測試方法,新增其他測試案例 1. 移除其他測試 ```php /** * @param string $file * @testWith ["filewithbadextension.SLF"] * ["filewithbadextension.slf"] */ public function test_IsValidFileName_ValidExtensions_ReturnTrue(string $file) { $analyzer = new LogAnalyzer(); $result = $analyzer->isValidLogFileName($file); $this->assertTrue($result); } ``` --- ### 加入一個負面的測試 方法名稱需更改為更抽象一些 * 可讀性降低 * **避免對參數化測試技術的過度使用** ```php /** * @param string $file * @param bool $expected * @testWith ["filewithbadextension.SLF", true] * ["filewithbadextension.slf", true] * ["filewithbadextension.foo", false] */ public function test_IsValidFileName_VariousExtensions_ChecksThem(string $file, bool $expected) { $analyzer = new LogAnalyzer(); $result = $analyzer->isValidLogFileName($file); $this->assertEquals($expected, $result); } ``` * 測試名稱變得通用,很難依據測試名稱來判斷哪種情況是有效的,哪種是無效的。 * 傳入的參數需要簡潔明瞭,必須盡量使用簡單的參數,用最簡單的值來證明你的觀點。(可讀性, ch8) * 不能只用一個龐大的參數化測試方法來實現所有的測試。(可維護性, ch8) --- ### PHPUnit 的另一種參數化方法 - Data Providers * [Data Providers](https://phpunit.de/manual/current/en/writing-tests-for-phpunit.html#writing-tests-for-phpunit.data-providers) * 不同測試方法間可共用相同測試資料 ```php /** * @dataProvider extensionProvider */ public function test_IsValidFileName_VariousExtensionProvider_ChecksThem(string $file, bool $expected) { $analyzer = new LogAnalyzer(); $result = $analyzer->isValidLogFileName($file); $this->assertEquals($expected, $result); } public function extensionProvider() { return [ ["filewithbadextension.SLF", true], ["filewithbadextension.slf", true], ["filewithbadextension.foo", false], ]; } ``` --- ### 重構被測試程式 * 修改 LogAnalyzer 的 if,簡化為一個回傳語句。 ```php class LogAnalyzer { const EXT = ".SLF"; public function isValidLogFileName(string $fileName): bool { * if ($this->isStrEndsWithCaseInsensitively($fileName, self::EXT)) { return false; } return true; } private function isStrEndsWithCaseInsensitively($mainStr, $str) { return substr_compare($mainStr, $str, -strlen($str), strlen($mainStr), true) !== 0; } } ``` --- class: middle, center, inverse ## 驗證預期的例外狀況 --- ### 拋出例外 傳入一個空的檔名時,應該拋出 ArgumentException (InvalidArgumentException) 例外 ```php class LogAnalyzer { const EXT = ".SLF"; public function isValidLogFileName(string $fileName): bool { * if (is_null($fileName) || $fileName === "") { * throw new InvalidArgumentException("filename has to be provided"); * } if ($this->isStrEndsWithCaseInsensitive($fileName, self::EXT)) { return false; } return true; } private function isStrEndsWithCaseInsensitive($mainStr, $str) { return substr_compare($mainStr, $str, -strlen($str), strlen($mainStr), true) !== 0; } } ``` --- ### 不好的驗證方法 * [@expectedException](https://phpunit.de/manual/current/en/appendixes.annotations.html#appendixes.annotations.expectedException) ```php /** * @expectedException InvalidArgumentException * @expectedExceptionCode 0 * @expectedExceptionMessage filename has to be provided * @expectedExceptionMessageRegExp ^filename has to be provided$ */ public function test_IsValidFileName_EmptyFileName_ThrowsException_UseAnnotation() { $this->analyzer = $this->makeAnalyzer(); $this->analyzer->isValidLogFileName(""); } ``` * 將整個方法包在一個很大的 try-catch 裡 * 不知道在整個測試方法的大區塊裡,是不是我們所期望的那一行拋出例外。 * 如果建構函式拋出一樣的例外,測試就會 Pass #### 工廠方法 * 被測試類別建構函式改變時,不需要修改散落在各個測試案例裡面的程式。維護程式更簡單。 --- ### 建議的驗證方法 1 - expectedException * c#: `Assert.Catch
()` * PHPUnit: `$this->expectedException()` * 將驗證方法放在待測試方法之前 * 缺點:無法驗證同一方法不同的例外情況 ```php public function test_IsValidFileName_EmptyFileName_ThrowsException_UseMethod() { $this->analyzer = $this->makeAnalyzer(); // Put expectException before SUT method $this->expectException(InvalidArgumentException::class); $this->expectExceptionCode(0); $this->expectExceptionMessage("filename has to be provided"); $this->analyzer->isValidLogFileName(""); } ``` * 可使用 expectedExceptionMessageRegExp 驗證字串內容,避免加入越多功能後,字串內容發生變化。 --- ### 建議的驗證方法 2 - try catch * try catch 後再驗證 Exception 類型與資訊 ```php public function test_IsValidFileName_EmptyFileName_Throws() { $this->analyzer = $this->makeAnalyzer(); try { $this->analyzer->isValidLogFileName(""); } catch (Exception $e) { $this->assertInstanceOf(InvalidArgumentException::class, $e); $this->assertEquals("filename has to be provided", $e->getMessage()); $this->assertEquals(0, $e->getCode()); } } ``` * 使用 try-catch 包住測試方法 * catch 後使用一般 assertXXX 驗證 Exception 型別與訊息 --- class: middle, center, inverse ## 更多 PHPUnit 特性 --- ### SetUp 和 TearDown * 確保之前測試過程中所遺留下來的資料或執行個體得以銷毀,新的測試執行時,狀態是重置過的,就像沒有執行過測試一般 * SetUp: PHPUnit 在執行測試類別裡的任何一個測試方法之前,都會先呼叫 setUp() * TearDown: 每個測試完畢之後被呼叫 * 使用的 SetUp 越多,程式的可讀性就越差。 * 閱讀程式碼時,需要在測試程式與 SetUp() 之間來回看。 * [Fixture@PHPUnit](https://phpunit.de/manual/6.5/en/fixtures.html) ```php class LogAnalyzerSTTest extends TestCase { private $analyzer = null; public function setUp() { echo "setUp" . PHP_EOL; $this->analyzer = new LogAnalyzer(); } // testing methods... public function tearDown() { // 只是為了 Demo // 請不要在實務上這麼寫 echo "tearDown" . PHP_EOL; $this->analyzer = null; } } ``` ] --- ### SetUp 和 TearDown Cont. * SetUp: 建構函式;TearDown: 解構函式 * SetUp, TearDown 在類別中只能有一個 * 對測試類別中的每個測試方法,只測試一次 * 應該避免使用 SetUp 初始化被測試類別的物件執行個體。 * 難以閱讀 * 採用工廠方法 (factory method) 取代。(ch7) * 測試類別裡所有測試執行前後,進行狀態的設定與清理。 * `setUpBeforeClass()`; `tearDownAfterClass()` * 設定和清理工作花費時間很長,而每個測試都需固定執行一次。ex: 讀取設定檔 * 使用時需特別謹慎,不小心的話會導致幾個測試之間**共享系統狀態**。 * 更像建構與解構函式 ```php class LogAnalyzerSetupTeardownTest extends TestCase { public static function setUpBeforeClass() { echo "setUp before class" . PHP_EOL; } // testing methods... public static function tearDownAfterClass() { echo "tearDown after class" . PHP_EOL; } } ``` --- ### SetUp 和 TearDown Cont. * 幾乎不會用到 TearDown 或 Before, AfterClass * 如果用到,很有可能是相依檔案系統或是資料庫,所撰寫的是**整合測試** * 單元測試 TearDown 的使用時機 * 重設靜態變數 * 重設單例 (Singleton) 的狀態 * PHP Annotation * setUp(): [@before](https://phpunit.de/manual/current/en/appendixes.annotations.html#appendixes.annotations.before) * tearDown(): [@after](https://phpunit.de/manual/current/en/appendixes.annotations.html#appendixes.annotations.after) * setUpBeforeClass: [@beforeClass](https://phpunit.de/manual/current/en/appendixes.annotations.html#appendixes.annotations.beforeClass) * tearDownAfterClass: [@afterClass](https://phpunit.de/manual/current/en/appendixes.annotations.html#appendixes.annotations.afterClass) ```php /** * @beforeClass */ public function setUpDatabaseConnection() { } /** * @before */ public function setUpSeed() { } /** * @after */ public function cleanDatabase() { } ``` --- ### 忽略測試 測試程式有問題,但又必須趕緊 commit 了。 * `$this->markTestIncomplete([$message])` * PHPUnit 執行時顯示為 `I` * `$this->markTestSkipped([$message])` * PHPUnit 執行時顯示為 `S` * @doesNotPerformAssertions * 執行方法內程式但不執行 assert ```php /** * @doesNotPerformAssertions */ public function test_IsValidFileName_ValidFile_Throws() { } public function test_IsValidFileName_ValidFile_InComplete() { $this->markTestIncomplete(); } public function test_IsValidFileName_ValidFile_Skipped() { $this->markTestSkipped(); } ``` --- ### 流利語法 Assert.That() * [assertThat()](https://phpunit.de/manual/current/en/appendixes.assertions.html#appendixes.assertions.assertThat) ```php public function test_IsValidFileName_EmptyFileName_ThrowsFluent() { $this->analyzer = $this->makeAnalyzer(); try { $this->analyzer->isValidLogFileName(""); } catch (Exception $e) { $this->assertThat( $e, $this->logicalOr( $this->isInstanceOf(InvalidArgumentException::class), $this->isInstanceOf(LengthException::class) ) ); } } ``` * 作者喜愛 assert.* 勝過 assert.That * 保證測試專案風格的一致性,否則會導致可讀性的問題 --- ### 設定測試分類 * [@group](https://phpunit.de/manual/current/en/appendixes.annotations.html#appendixes.annotations.group) * 預設 group * @large * @medium * @small ```php /** * @group fast */ public function test_IsValidFileName_GoodExtensionUppercase_ReturnTrue() { } ``` * 指定 group `--group` ```console ./vendor/bin/phpunit --bootstrap ./vendor/autoload.php --group fast ch2/LogAnalyzerTests.php ``` * 列出可用 groups `--list-groups` ```console ./vendor/bin/phpunit --bootstrap ./vendor/autoload.php --list-groups ch2/LogAnalyzerTests.php ``` --- class: middle, center, inverse ## 測試系統狀態的改變 --- ### 系統狀態改變 * 狀態驗證 > 透過檢查被測試系統及與之協作(依賴的物件或三方系統)在測試方法執行後,其行為或狀態的改變,來判斷被測試方法是否正常運作。 ```php class LogAnalyzer { const EXT = ".SLF"; * public $wasLastFileNameValid = false; public function isValidLogFileName(string $fileName): bool { if (is_null($fileName) || $fileName === "") { throw new InvalidArgumentException("filename has to be provided"); } if ($this->isStrEndsWithCaseInsensitive($fileName, self::EXT)) { return false; } * $wasLastFileNameValid = true; return true; } private function isStrEndsWithCaseInsensitive($mainStr, $str) { return substr_compare($mainStr, $str, -strlen($str), strlen($mainStr), true) !== 0; } } ``` --- ### 測試與命名 * 沒有前置動作而有一個預期的回傳值,ByDefault * 改變狀態或呼叫第三方系統,WhenCall, Always * Sum_WhenCalled_CallsTheLogger * Sum_Always_CallsTheLogger ```php public function test_IsValidFileName_ByDefault_ReturnFalse() { $analyzer = $this->makeAnalyzer(); $this->assertFalse($analyzer->isValidLogFileName); } public function test_IsValidFileName_WhenCalled_ChangesWasLastFileNameValid() { $analyzer = $this->makeAnalyzer(); $analyzer->isValidLogFileName("badname.foo"); $this->assertFalse($analyzer->wasLastFileNameValid); } ``` --- class: middle, center, inverse ## 小結 --- ### 小結 * PHPUnit 對簡單的程式撰寫測試 * 使用 `@TestWith`, `setUp()`, `tearDown()` 保證測試前後是乾淨的 * 使用工廠方法來讓測試更好維護 * 使用 Ignore 略過有問題的測試 * 分類測試: `@group` * 正確擺放 `$this->expectException()`,確保被測試程式在符合預期的地方拋出例外 * 測試物件互動後的系統狀態 ### 提醒 * 慣例: * 對每個待測試類別建立對應的測試類別 * 對每個待測試專案建立對應的測試專案 * 對每個工作單元建立對應的測試方法 * 命名:`[UnitOfWork]_[Scenario]_[ExpectedBehavior]` * 使用.red[工廠方法]取代在每個測試方法使用物件建立函數 * 盡量不要在單元測試使用 `setUp()` 和 `tearDown()` --- class: middle, center, inverse ## 問題與討論