最近更新: 2012-01-18

Working with PHPUnit, part 2 - 撰寫測試案例

繼第一部份《Working with PHPUnit, part 1 - 安裝備忘錄》後,接下來舉例說明操作 PHPUnit 之過程。

初版: 2007-01-18。
最近更新: 2012-01-18。修正參考連結,補充 3.4 版後使用差異。
  1. PHPUnit 指令工具與測試案例源碼檔
  2. 撰寫測試案例源碼內容
  3. 從測試對象產生測試案例源碼骨架
  4. 完成測試案例與產生測試項目清單

PHPUnit 指令工具與測試案例源碼檔

PHPUnit 主要指令工具是 phpunit ,請瀏覽《Chapter 5. The Command-Line Test Runner》查看 phpunit 指令說明。此處概述一些注意事項。首先, PHPUnit 以 class 為測試單位,要求每一測試案例 (Test case, Test story) 之內容應實作在一個類別中。為與測試對象有所區別,本文稱實作測試案例內容之類別為測試類別 (test class) 。據此, phpunit 的第一參數為測試類別的名稱,並假設每一個 php 測試案例源碼檔僅包含一個測試類別,且測試案例之源碼檔名與測試類別名稱相同。 phpunit 又遵循 PEAR Coding Standards::Naming Conventions 為 php 源碼檔命名與配置原則,將底線字元 (_) 視為檔案系統目錄層級的分隔字元。例如測試類別名稱若為 Model_My_ExampleTest ,則 phpunit 會自動在檔案系統中找尋 'Model/My/ExampleTest.php' 。若要抑止此一預設檔案規則,則可以指定第二參數為 php 源碼路徑與檔名。

$ phpunit Model_My_ExampleTest
# 僅指定第一參數, phpunit 自動尋找 Model/My/ExampleTest.php

$ phpunit Model_My_ExampleTest unit_test/my_example_test.php
# 指定 phpunit 以 unit_test/my_example_test.php 為測試案例源碼檔

撰寫測試案例源碼內容

SimpleTest.php 是一個基本的測試案例源碼樣板,可以此測試 PHPUnit 是否成功安裝。

SimpleTest.php

<?php
require_once 'PHPUnit/Framework.php';

class SimpleTest extends PHPUnit_Framework_TestCase
{
    public function thisIsNotATest()
    {
        echo 'nothing';
    }

    public function testItIsTrue()
    {
        $this->assertTrue(TRUE);
    }

    /**
     * @test
     */
    public function arrayIsEmpty()
    {
        $fixture = array();
        $this->assertEquals(0, count($fixture));
    }
}
?>

以下列出 PHPUnit 撰寫測試案例源碼時的基本事項:

  1. 需要載入 'PHPUnit/Framework.php' 。
  2. 測試案例之內容必須實作於一個測試類別中,且測試類別應繼承自 PHPUnit_Framework_TestCase 類別。測試類別之名稱遵循 PEAR Coding Standards::Naming Conventions ,首字母應大寫。一個測試案例源碼檔中應僅包含一個測試類別,源碼檔預設與測試類別同名。
  3. 每一測試案例項目應實作為一個測試類別之實例行為。 phpunit 預期每一個無參數且名稱符合 test* 的個體行為是一個測試項目。另一個可用的方式是在個體行為的文件區塊 (docblock) 中註記 @test。因此 SimpleTest.php 中只有 testItIsTrue()arrayIsEmpty() 是測試項目,而 thisIsNotATest() 不會執行。
  4. 測試項目之行為名稱建議採用 camel caps 樣式,即每字之間不分開,第一字之首字母小寫,其餘字之首字母大寫。採用此種命名方式時, phpunit 可自動產生可閱讀的測試項目清單文件。See also: 《Chapter 16. Other Uses for Tests: Agile Documentation》。
  5. 測試內容採用斷言敘述 (assert) ,可用之斷言參閱《Chapter 22. PHPUnit API::PHPUnit_Framework_Assert》。
$ phpunit --testdox-text test_list.txt SimpleTest
PHPUnit 3.0.0 by Sebastian Bergmann.

..

Time: 00:00


OK (2 tests)

$ type test_list.txt
Simple
 [x] It is true
 [x] Array is empty

從測試對象產生測試案例源碼骨架

由於測試案例源碼內容大致相同, phpunit 也提供了自動產生測試案例源碼骨架的功能,根據測試對象的內容產生一個對應的測試案例源碼。請參閱《Chapter 17. Skeleton Generator》,測試對象必須是一個類別,若 php 源碼檔中包含多個類別時, phpunit 只將最後一個類別視為測試對象而為其產生測試案例源碼骨架。

DatabaseRow 是一個彈性的資料庫欄位類別。可以藉 Configuration-driven Development (See also: 《Example of Configuration Driven Development with PHP) 的方式讀入資料庫欄位的 schema ,並能檢查資料值是否符合資料型態。它覆載了 __set, __get 行為作欄位內容的存取子。可隨時新增欄位,唯新增欄位不檢查資料型態。此外,對於嘗試取得未定義欄位內容的動作,皆回傳 False 。

DatabaseRow.php

<?php
class DatabaseRow {
    protected $fieldMap;

    public function __construct(&$schema = FALSE) {
        if ($schema) {
            foreach ($schema as $k => $v) {
                $this->fieldMap[$k]->schema = $v;
                if (isset($v['default'])) {
                    $this->fieldMap[$k]->value = $v['default'];
                }
            }
        }
        else {
            $this->fieldMap = array();
        }
    }

    public function __set($k, $v) {
        $isType = (isset($this->fieldMap[$k]->schema)
            ? 'is_' . $this->fieldMap[$k]->schema['type']
            : FALSE
        );
        if ( !$isType or $isType($v) ) {
            $this->fieldMap[$k]->value = $v;
        }
    }

    public function __get($k) {
        return (isset($this->fieldMap[$k]->value)
            ? $this->fieldMap[$k]->value
            : FALSE
        );
    }
}
?>

以 DatabaseRow 類別為測試對象,接著使用 phpunit 的選項 --skeleton-test 為 DatabaseRow 產生測試案例源碼骨架。 phpunit 會在測試對象之名稱後添加 'Test' 作為測試案例源碼檔與測試類別之名稱。 在 team-work 時,通常我們在決定類別的各項公開行為的名稱後就會產生測試案例骨架,接著就將工作類別與測試類別分別交給程式人員和測試人員撰寫,不必等到類別的內容實作完成後才產生測試案例骨架。

$ phpunit --skeleton-test DatabaseRow
PHPUnit 3.4.14 by Sebastian Bergmann.

Wrote skeleton for "DatabaseRowTest" to "DatabaseRowTest.php".

請自行開啟 DatabaseRowTest.php 查看測試案例骨架源碼。 phpunit 產生的測試案例骨架源碼會自動為測試對象之每一公開行為產生一個「未完成 (incomplete)」的測試項目。而且它的內容比較多,允許直接執行。

$ phpunit DatabaseRowTest
PHPUnit 3.0.0 by Sebastian Bergmann.
II
Time: 00:00
OK, but incomplete or skipped tests!
Tests: 2, Incomplete: 2.

PHPUnit 3.0 版產生的測試案例源碼帶有一些額外的啟動碼,所以可以下列方式直接以 php 指令執行。PHPUnit 3.4 版後就沒有了。

$ php DatabaseRowTest.php
PHPUnit 3.0.0 by Sebastian Bergmann.
II
Time: 00:00
OK, but incomplete or skipped tests!
Tests: 2, Incomplete: 2.

完成測試案例與產生測試項目清單

當我們將 DatabaseRow 類別的內容完成到一個段落時,我們同時改寫 DatabaseRowTest.php 的內容,為了載入 schema ,我添加 phpunit 沒有為我們加上的建構行為,在其中載入 schema (30-34行)。此處我先測試未指派 schema 和有指派 schema 兩個情形。下列為測試案例使用的 schema 與改寫後的第二版 DatabaseRowTest.php 。

DatabaseRowTest.js

{
    "id": {
        "type": "integer",
        "default": 0
    }
}

DatabaseRowTest.php, version 2

<?php
// Call DatabaseRowTest::main() if this source file is executed directly.

if (!defined("PHPUnit_MAIN_METHOD")) {
    define("PHPUnit_MAIN_METHOD", "DatabaseRowTest::main");
}

require_once "PHPUnit/Framework/TestCase.php";
require_once "PHPUnit/Framework/TestSuite.php";

require_once 'DatabaseRow.php';

/**
 * Test class for DatabaseRow.
 * Generated by PHPUnit_Util_Skeleton on 2007-xx-xx.
 */
class DatabaseRowTest extends PHPUnit_Framework_TestCase {
    /**
     * Runs the test methods of this class.
     *
     * @access public
     * @static
     */
    public static function main() {
        require_once "PHPUnit/TextUI/TestRunner.php";

        $suite  = new PHPUnit_Framework_TestSuite("DatabaseRowTest");
        $result = PHPUnit_TextUI_TestRunner::run($suite);
    }

    private $schema;
    public function __construct() {
        $this->schema =
            json_decode(file_get_contents('DatabaseRowTest.js'), true);
    }

    /**
     * Sets up the fixture, for example, open a network connection.
     * This method is called before a test is executed.
     *
     * @access protected
     */
    protected function setUp() {
    }

    /**
     * Tears down the fixture, for example, close a network connection.
     * This method is called after a test is executed.
     *
     * @access protected
     */
    protected function tearDown() {
    }

    /**
     * 未指定 Schema 時,任何欄位之值皆為 FALSE
     *
     * @test
     */
    public function newDatabaseRowWithoutSchema() {
        $row = new DatabaseRow;
        $this->assertFalse($row->id);
    }

    /**
     * 指派 Schema 。
     * 在此例中,只有 id 欄位,型態為 integer ,預設值為 0 。
     *
     * @test
     */
    public function newDatabaseRowWithSchema() {
        $row = new DatabaseRow($this->schema);
        $this->assertType($this->schema['id']['type'], $row->id);
        $this->assertEquals($this->schema['id']['default'], $row->id);
        $this->assertFalse($row->address);
    }

    /**
     * @todo Implement test__set().
     */
    public function test__set() {
        // Remove the following line when you implement this test.

        $this->markTestIncomplete(
          "This test has not been implemented yet."
        );
    }

    /**
     * @todo Implement test__get().
     */
    public function test__get() {
        // Remove the following line when you implement this test.

        $this->markTestIncomplete(
          "This test has not been implemented yet."
        );
    }
}

// Call DatabaseRowTest::main() if this source file is executed directly.

if (PHPUnit_MAIN_METHOD == "DatabaseRowTest::main") {
    DatabaseRowTest::main();
}
?>

請自行執行並觀察結果。接著我要測試設定欄位值的動作,由於每一個測試項目執行前都需要建立一個乾淨的 DatabaseRow 實例,故我將建立 DatabaseRow 實例的動作寫在 setUp() 中。 setUp() 是專門用於為每一個測試項目建立乾淨的測試環境之用。See also:《Chapter 6. Fixtures》。下列為第三版的 DatabaseRowTest.php 。

DatabaseRowTest.php, version 3

<?php
// Call DatabaseRowTest::main() if this source file is executed directly.

if (!defined("PHPUnit_MAIN_METHOD")) {
    define("PHPUnit_MAIN_METHOD", "DatabaseRowTest::main");
}

require_once "PHPUnit/Framework/TestCase.php";
require_once "PHPUnit/Framework/TestSuite.php";

require_once 'DatabaseRow.php';

/**
 * Test class for DatabaseRow.
 * Generated by PHPUnit_Util_Skeleton on 2007-xx-xx.
 */
class DatabaseRowTest extends PHPUnit_Framework_TestCase {
    /**
     * Runs the test methods of this class.
     *
     * @access public
     * @static
     */
    public static function main() {
        require_once "PHPUnit/TextUI/TestRunner.php";

        $suite  = new PHPUnit_Framework_TestSuite("DatabaseRowTest");
        $result = PHPUnit_TextUI_TestRunner::run($suite);
    }

    private $schema;
    public function __construct() {
        $this->schema =
            json_decode(file_get_contents('DatabaseRowTest.js'), true);
    }

    private $row;
    /**
     * 每個測試項目執行前,都重新配置一次 $row
     *
     * @access protected
     */
    protected function setUp() {
        $this->row = new DatabaseRow($this->schema);
    }

    /**
     * 未指定 Schema 時,任何欄位之值皆為 FALSE
     *
     * @test
     */
    public function newDatabaseRowWithoutSchema() {
        $row = new DatabaseRow;
        $this->assertFalse($row->id);
    }

    /**
     * 指派 Schema 。
     * 在此例中,只有 id 欄位,型態為 integer ,預設值為 0 。
     *
     * @test
     */
    public function newDatabaseRowWithSchema() {
        $this->assertType($this->schema['id']['type'], $this->row->id);
        $this->assertEquals($this->schema['id']['default'], $this->row->id);
        $this->assertFalse($this->row->address);
    }

    /**
     * 右值不符欄位型態時,欄位值不變。
     */
    public function testSetIdAsInvalidValue() {
        $this->row->id = 'john';
        $this->assertType($this->schema['id']['type'], $this->row->id);
        $this->assertEquals($this->schema['id']['default'], $this->row->id);
    }

    /**
     * 設定 id 為 5。
     */
    public function testSetIdAsFive() {
        $this->row->id = 5;
        $this->assertType($this->schema['id']['type'], $this->row->id);
        $this->assertEquals(5, $this->row->id);
    }

    /**
     * 增加 name 欄位,未指定資料型態 (不檢查型態)
     * 增加欄位前,其值為 FALSE 。
     * 增加欄位後,先設其值為 5 ,再設為 'john'。
     */
    public function testSetNameAsJohn() {
        $this->assertFalse($this->row->name);
        $this->row->name = 5;
        $this->assertEquals(5, $this->row->name);
        $this->row->name = 'john';
        $this->assertEquals('john', $this->row->name);
    }
}

// Call DatabaseRowTest::main() if this source file is executed directly.

if (PHPUnit_MAIN_METHOD == "DatabaseRowTest::main") {
    DatabaseRowTest::main();
}
?>

最後示範以 phpunit 在測試同時為我們產生測試項目清單。此處就可看出為測試案例類別的實例行為 (即測試項目) 取一個有意義的名字相當重要。在 Agile method 中隨處可見「source code is document」這句話。追隨它,相信它,貫徹它。不要淪為報告打字員。我們堅持拒絕多打一次報告! (See also: 《軟體工程三大陣營, RUP, CMMI, Agile Method》)

$ phpunit --testdox-text test.log DatabaseRowTest
PHPUnit 3.4.14 by Sebastian Bergmann.
.....
Time: 00:00

OK (5 tests)

$type test.log
DatabaseRow
 [x] New database row without schema
 [x] New database row with schema
 [x] Set id as invalid value
 [x] Set id as five
 [x] Set name as john

我在《先說故事再動手設計, 從一個簡單故事看 Test Driven Development》也說明了如何在日常工作中推動 Test-driven Development 過程。到目前為止已經介紹了相當多 PHPUnit 的內容,基本上足以應付多數場合的需求。更多的內容可以參閱線上手冊《PHPUnit Manual》。

樂多舊網址: http://blog.roodo.com/rocksaying/archives/2653972.html

樂多舊回應
未留名 (#comment-3894887)
Mon, 29 Jan 2007 15:35:57 +0800
補充一篇圖文並茂的 PHPUnit 文章:
進階PHP程式設計-單元測試@ Kiwi格網技術開發站