Slide 1

Slide 1 text

有效的單元測試 Jace Ju @ KKBOX https://commons.wikimedia.org/

Slide 2

Slide 2 text

一直以來,我們寫的測試就像…

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

捫心自問,你寫的測試有用嗎?

Slide 5

Slide 5 text

如果你常有這些抱怨… 測試容易因為執行環境問題而失敗 同事說看不懂你在測試什麼 新增或修改測試要做的事很多 常常沒有測試到真正重要的部份

Slide 6

Slide 6 text

你就需要瞭解測試的… 測試替身 可讀性 可維護性 可靠性

Slide 7

Slide 7 text

測試替身 Test Doubles https://www.pinterest.com

Slide 8

Slide 8 text

待測程式碼 真實物件 測試替身 測試替身 測試替身

Slide 9

Slide 9 text

替身的作用 隔離待測 程式 加速測試 執行 讓測試結 果穩定 模擬特殊 狀況 曝露隱藏 的行為

Slide 10

Slide 10 text

替身的類型 Stub Fake Spy Mock

Slide 11

Slide 11 text

No content

Slide 12

Slide 12 text

Stub 繼承或實作相依類別與介面 寫死的回傳值 (包含 Exception) Extract And Override

Slide 13

Slide 13 text

class StubRepository implements RepositoryInterface { public function findUser(int $id): User { return new StubUser([ 'id' => 1 ]); } public function fetchUsers(array $ids): Collection { throw new \InvalidArgumentException(); } } Stub 範例

Slide 14

Slide 14 text

Fake 提供輕量級實作 執行速度很快 建立成本較低

Slide 15

Slide 15 text

class FakeSession implements SessionInterface { private $storage = []; public function put($name, $value) { $this->storage[$name] = $value; } public function get($name) { return $this->storage[$name] ?? null; } } Fake 範例

Slide 16

Slide 16 text

Laravel 中的應用

Slide 17

Slide 17 text

Spy 在相依物件上加入 spy 程式 記錄相依物件的方法有無被呼叫 事後透過 spy 物件的方法驗證

Slide 18

Slide 18 text

$logger = Mockery::spy(Logger::class); $auth = new Auth($logger); $auth->login('user', 'pass'); $logger->shouldHaveReceived('log') ->once(); Spy 範例

Slide 19

Slide 19 text

Mock 進階版的 Stub 和 Spy 讓不同輸入參數對應不同輸出 精準控制方法的被呼叫次數

Slide 20

Slide 20 text

$service = Mockery::mock(UserService::class); $service->shouldReceive('authenticate') ->with('jaceju', '123456') ->andReturn(false); $service->shouldReceive('authenticate') ->with('butany', '123456') ->andReturn(true); $userController = new UserController($service); $actual = $userController->login('jaceju', '123456'); $this->assertFalse($actual); $actual = $userController->login('butany', '123456'); $this->assertTrue($actual); Mock 範例

Slide 21

Slide 21 text

Spy - 事後驗證行為 Mock - 事前定義 對應輸出

Slide 22

Slide 22 text

替身反模式 • 所有相依類別都用替身隔離 假測試 • 重構時也必須去修改測試中的替身 過度的實作耦合 • 資料庫邏輯被徹底隔離 設計過當 https://speakerdeck.com/adamwathan/tdd-the-good-parts-1

Slide 23

Slide 23 text

替身反模式解法 • 可控制的部份就別用替身 使用真實協作物件 • 從抽象層次去驗證需求 別驗證細節 • 測試時改用 sqlite::memory: 使用 ORM

Slide 24

Slide 24 text

替身就是造假的藝術 https://zh.wikipedia.org/

Slide 25

Slide 25 text

可讀性 Readability http://uxmastery.com/

Slide 26

Slide 26 text

可讀性原則 別讓閱讀者思考 一個測試只測試一個情境

Slide 27

Slide 27 text

可讀性反模式 斷言不直覺 斷言範圍太廣 一次測太多事 過份保護

Slide 28

Slide 28 text

斷言不直覺 $this->assertTrue(array_key_exists('foo', $ary)); $this->assertEquals(3, strpos('foobar', 'bar'));

Slide 29

Slide 29 text

用更直覺的斷言 $this->assertArrayHasKey('foo', $ary); $this->assertContains('bar', 'foobar');

Slide 30

Slide 30 text

/** @expectedException \Exception */ public function textServiceShouldThrowExceptionWhenSomethingWrong() { // ... $service = new UserService(); $service->getVerifiedUsers(); } 斷言範圍太廣

Slide 31

Slide 31 text

/** * @expectedException \Illuminate\Database\QueryException */ public function textThrowExceptionWhenQueryFailed() { // ... $service = new UserService(); $service->getVerifiedUsers(); } /** * @expectedException \Predis\Connection\ConnectionException */ public function textThrowExceptionWhenRedisIsDisconnect() { // ... $service = new UserService(); $service->getVerifiedUsers(); } 縮小斷言範圍

Slide 32

Slide 32 text

public function testAlbum() { $albumId = 10001; $albumIds = [$albumId]; $wrapper = new ImageApiWrapper(); $actual = $wrapper->getAlbumUrls($albumIds); $this->assertEquals('10001/300x300.jpg', $actual[$albumId]); $actual = $wrapper->getAlbumInfos($albumIds); $this->assertEquals('10001/300x300.jpg', $actual[$albumId]['url']); } 一次測太多事

Slide 33

Slide 33 text

protected function setUp() { $this->wrapper = new ImageApiWrapper(); } public function testGetAlbumUrlsSuccess() { $albumId = 10001; $expected = [ $albumId => '10001/300x300.jpg', ]; $actual = $this->wrapper->getAlbumUrls([$albumId]); $this->assertEquals($expected, $actual); } 一次只測一件事

Slide 34

Slide 34 text

public function testGetAlbumInfoSuccess() { $albumId = 10001; $expected = [ $albumId => [ 'id' => '10001', 'url' => '10001/300x300.jpg', ], ]; $actual = $this->wrapper->getAlbumInfos([$albumId], 'tw'); $this->assertEquals($expected, $actual); } 一次只測一件事 (續)

Slide 35

Slide 35 text

$users = (new UserRepository())->fetchVerifiedUsers(); $this->assertNotNull($users); $this->assertCount(5, $users); 過度保護

Slide 36

Slide 36 text

$users = (new UserRepository())->fetchVerifiedUsers(); $this->assertNotNull($users); $this->assertCount(5, $users); 刪除不必要的斷言

Slide 37

Slide 37 text

可維護性 Maintainability http://soniasaelensdeco.canalblog.com/

Slide 38

Slide 38 text

可維護性原則 不論誰接手都能運作 減少不穩定的狀況

Slide 39

Slide 39 text

可維護性反模式 條件邏輯 重複的程式碼 相依的測試 脆弱的測試

Slide 40

Slide 40 text

$service = new UserService(); $verifiedUsers = $service->getVerifiedUsers(); foreach ($verifiedUsers as $index => $verifiedUser) { if ($index === 0) { $this->assertEquals('butany', $verifiedUser['name']); } if ($index === 1) { $this->assertEquals('jaceju', $verifiedUser['name']); } } 條件邏輯

Slide 41

Slide 41 text

public function testUserServiceShouldReturnExpectedVerifiedUsers() { $service = new UserService(); $verifiedUsers = $service->getVerifiedUsers(); $this->assertCount(2, $verifiedUsers); $this->assertArrayHasKey('name', $verifiedUsers[0]); $this->assertEquals('butany', $verifiedUsers[0]['name']); $this->assertArrayHasKey('name', $verifiedUsers[1]); $this->assertEquals('jaceju', $verifiedUsers[1]['name']); } 重複的程式碼

Slide 42

Slide 42 text

class UserServiceTest { use AssertionHelper; private $service; public function setUp() { $this->service = new UserService(); } } trait AssertionHelper { private function assertArrayHasKeyWithValue($array, $key, $value) { $this->assertArrayHasKey($key, $array); $this->assertEquals($value, $array[$key]); } } 利用 setUp 和 Trait

Slide 43

Slide 43 text

public function testUserServiceShouldReturnExpectedVerifiedUsers() { $verifiedUsers = $this->service->getVerifiedUsers(); $this->assertCount(2, $verifiedUsers); $this->assertArrayHasKeyWithValue($verifiedUsers[0], 'name', 'butany'); $this->assertArrayHasKeyWithValue($verifiedUsers[1], 'name', 'jaceju'); } 利用 setUp 和 Trait (續)

Slide 44

Slide 44 text

class ConfigTest extends TestCase { private $config; public function testAddConfigSuccess() { $configFile = __DIR__ . '/fixture/config.json'; $this->config = new Config($configFile); $this->config->add('debug', true); $this->assertObjectHasAttribute('debug', $this->config); } public function testInjectConfigToAppShouldWork() { $app = new App($this->config); assertThat($app->debugMode, isTrue()); } } 相依的測試

Slide 45

Slide 45 text

class AppTest extends TestCase { public function testInjectConfigToAppShouldWork() { $config = Mockery::mock(Config::class); $config->shouldRecive('toArray') ->andReturn(['debug' => true]); $app = new App($config); assertThat($app->debugMode, isTrue()); } } 獨立每個測試案例

Slide 46

Slide 46 text

脆弱的測試 不可控的執行環境 寫死的檔案路徑 緩慢的等待 (Sleep)

Slide 47

Slide 47 text

可靠性 Reliability

Slide 48

Slide 48 text

可靠性原則 去除任何的疑惑 要真正是可信賴的

Slide 49

Slide 49 text

可靠性反模式 被註解的測試 永不失敗的測試 虛假的測試 有條件的測試

Slide 50

Slide 50 text

class AppTest extends TestCase { public function testInjectConfigToAppShouldWork() { // $config = Mockery::mock(Config::class); // $config->shouldRecive('toArray') // ->andReturn(['debug' => true]); // $app = new App($config); // assertThat($app->debugMode, isTrue()); } } 被註解的測試

Slide 51

Slide 51 text

class ImageApiWrapperTest extends TestCase { public function testDevelopmentMode() { $dev = true; $app = new ImageApiWrapper($dev); } } 永不失敗的測試

Slide 52

Slide 52 text

/** @test */ public function it_should_get_empty_array_when_get_urls_with_invalid_id() { $albumId = 'invalid_id'; $albumIds = [$albumId]; $wrapper = new ImageApiWrapper(); $actual = $wrapper->getAlbumUrls($albumIds); $expected = [ $albumId => 'noimg/300x300.jpg', ]; $this->assertEquals($expected, $actual); } 虛假的測試

Slide 53

Slide 53 text

public function testRunListCommandAtHomeDirectory() { $expectedFile = 'hello.txt'; $targetDirectory = '/home/jaceju'; $this->createFile($expectedFile, $targetDirectory); $command = new ListCommand($targetDirectory); $process = Process::run($command); if ($process->getExitCode() === 0) { $this->assertEquals($expectedFile, $process->getOutput()); } } 有條件的測試

Slide 54

Slide 54 text

public function testRunListCommandAtHomeDirectoryShouldGetExpectedFile() { $expectedFile = 'hello.txt'; $targetDirectory = '/home/jaceju'; $this->createFile($expectedFile, $targetDirectory); $command = new ListCommand($targetDirectory); $process = Process::run($command); $this->assertEquals(0, $process->getExitCode()); $this->assertEquals($expectedFile, $process->getOutput()); } 用斷言取代條件式

Slide 55

Slide 55 text

總結 Summary https://www.bankinghub.eu/

Slide 56

Slide 56 text

驗證需求而不是驗證細節。 一次只測試一件事。 要有清楚的斷言。 只隔離不可控的因素。

Slide 57

Slide 57 text

No content

Slide 58

Slide 58 text

http://newsletters.getresponse.com/