最近更新: 2006-12-30

先說故事再動手設計, 從一個簡單故事看 Test Driven Development

在進行任何程式的設計工作之前,我們必定已經知道程式的輸出入結果,亦即我們已經確知當使用者輸入什麼資料後,程式應該輸出什麼結果。更進一步說,我們已經知道使用者將如何使用這個程式,諸如使用者在操作過程中會做什麼事,而程式對操作內容應該如何回應等等。這段理所當然到近乎廢話的敘述,卻是所有軟體設計人員惡夢的開始,也是所有軟體工程實踐作法的起點。在 eXtreme Programming 中,我們稱這些內容為「故事 (story)」;在 RUP 中稱之為「使用案例 (use case)」;在 Microsoft Solution Framework 中稱之為「情節 (scenarios)」。我個人偏好使用「故事」一詞,因為它不像術語。既然故事是設計人員惡夢的開始、設計工作的起點,那麼就先說一個故事。

「很久很久以前...」,我碰到了一種稱為 URL 的格式字串,它可以用來指示網路上可用資源的所在處。我隨手寫下了幾個 URL 字串,有 http://example.com/docs/t.htmfoo@example.com 以及 ftp://f:foo@example.com/pub/t.zip。一個 URL 字串實際上是一些各有意義的內容連接而成,所以我需要一個功能幫我將 URL 中各部份內容拆解開來,拆解結果放在關聯陣列中應該是個好主意,如此我就可以用各部份名稱為鍵值取得其值。再看看 URL 的格式,會包含通訊協定、主機網址、文件路徑,有些還會包含使用者名稱及密碼等內容,就把這些內容取名叫 protocol, hostAddress, filePath, userName, password 吧。

故事到此告一段落,為免遺忘該把故事寫下來了。但是,我們是 programmer ,不是文學大師,而且目前還沒有任何 compiler 可以處理我們的日常語言,所以我們應該用電腦語言將我們的故事寫下來。在 Agile method 中隨處可見「source code is document」這句話。追隨它,相信它,貫徹它。不要淪為報告打字員。(See also: 《軟體工程三大陣營, RUP, CMMI, Agile Method》)

我用 PHP 語言,以斷言式 (assert) 的文章體裁,將故事寫成 4 個小節。第一個小節先判斷字串是否為 URL 格式。其他小節分別記下了故事中出現的三個角色以及程式拆解結果的斷言。

Case: 是否為 URL 字串
/** case InvalidUrl: **/

    $url = 'abc';
    $results = explodeUrl($url);

    assertFalse(($results != false), "{$url} is a valid URL.");

Case: http://example.com/docs/t.htm
/** case HTTP url: **/

$url = 'http://example.com/docs/t.htm';
$results = explodeUrl($url);

assertTrue(($results !== false), "{$url} is an invalid URL.");
assertEquals('http', $results['protocol'],
    "{$url} is not transmitted by HTTP protocol.");
assertEquals('example.com', $results['hostAddress']);

Case: foo@example.com
/** case Mail Address **/

$mailAddress = array(
    'userName' => 'foo',
    'hostAddress' => 'example.com'
);
$url = "mailto:{$mailAddress['userName']}@{$mailAddress['hostAddress']}";

$results = explodeUrl($url);

assertTrue(($results !== false), "{$url} is an invalid URL.");
foreach ($mailAddress as $k => $v) {
    assertTrue(isset($results[$k]));
    assertEquals($mailAddress[$k], $results[$k]);
}

Case: ftp://f:foo@example.com/pub/t.zip
/** case FTP url with user name and password **/

$url = 'ftp://f:foo@example.com/pub/t.zip';
$results = explodeUrl($url);

assertTrue(($results !== false), "{$url} is an invalid URL.");
assertEquals('foo', $results['password']);
assertContains('pub/', $results['filePath']);

撰寫測試工作內容

故事說完了,接下來是動手設計程式的時間。這個故事的需求,我設計成一個 function ,稱之為 explodeUrl() 。此 function 會將拆解結果以關聯陣列回傳。我使用 PHPUnit 3 進行測試工作,將先前記下的故事內容整理一下後,就可以完成測試工作碼,見 ExplodeUrlTest.php 。

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

class ExplodeUrlTest extends PHPUnit_Framework_TestCase
{
    public function testInvalidUrl() {
        $url = 'abc';
        $results = explodeUrl($url);

        $this->assertFalse(($results != false), "{$url} is a valid URL.");
    }

    public function testHttpAddress() {
        $url = 'http://example.foo.com/docs/test.html';
        $results = explodeUrl($url);

        $this->assertTrue(($results !== false),
            "{$url} is an invalid URL.");
        $this->assertEquals('http', $results['protocol'],
            "{$url} is not transmitted by HTTP protocol.");
        $this->assertEquals('example.foo.com', $results['hostAddress']);
    }

    public function testMailAddress() {
        $mailAddress = array(
            'userName' => 'foo',
            'hostAddress' => 'example.com'
        );
        $url = "mailto:{$mailAddress['userName']}@{$mailAddress['hostAddress']}";

        $results = explodeUrl($url);

        $this->assertTrue(($results !== false), "{$url} is an invalid URL.");
        foreach ($mailAddress as $k => $v) {
            $this->assertTrue(isset($results[$k]));
            $this->assertEquals($mailAddress[$k], $results[$k]);
        }
    }

    public function testFtpAddress() {
        $url = 'ftp://foo:oof@example.com/pub/a/x/test.html';
        $results = explodeUrl($url);

        $this->assertTrue(($results !== false), "{$url} is an invalid URL.");
        $this->assertEquals('oof', $results['password']);
        $this->assertContains('/x/', $results['filePath']);
    }
}
?>

漸近完成設計工作

在設計 explodeUrl() 時,我使用 REGEX 進行拆解 (此處用了 PHP 擴充的 REGEX 記述法,請參考《Let results of preg_match be an associative array)。 REGEX 是種功能強大的字元規則記述法,同時也是難讀而易出錯的功能。利用 Test Driven Development 的方式來設計再適當不過了。在 explodeUrl.php 中,我漸次完成了本故事中所需要的 URL 拆解功能。我一共記錄下了六個版本,有興趣的人可以一一嘗試,回溯我設計的經驗。往後任何修改 explodeUrl() 內容的動作,都要再進行測試,只要測試完全 OK ,則可以認為這次修改內容基本無誤,接著就能 commit 回專案的 Repository 了。

explodeUrl.php
<?php
function explodeUrl($url) {
    // version 1: 4 failures.
    //$rc = preg_match('/\w+/', $url, $matches);

    // version 2: 1 error, 2 failures.
    // lose file path.
    //$rc = preg_match('/(?P<protocol>\w+):(\/\/)(?P<hostAddress>[\w._-]+)\//', $url, $matches);

    // version 3: 2 failures.
    // cannot parse mail address and FTP url with user name
    //$rc = preg_match('/(?P<protocol>\w+):(\/\/)(?P<hostAddress>[\w\._-]+)\/?(?P<filePath>.*)/', $url, $matches);

    // version 4: 2 failures.
    // it assume that there must be an user name and password.
    //$rc = preg_match('/(?P<protocol>\w+):(\/\/)((?P<userName>\w+):?(?P<password>\w+)?@)(?P<hostAddress>[\w\._-]+)\/?(?P<filePath>.*)/', $url, $matches);

    // version 5: 1 failures.
    // it assume that there muse be a '//'.
    //$rc = preg_match('/(?P<protocol>\w+):(\/\/)((?P<userName>\w+):?(?P<password>\w+)?@)?(?P<hostAddress>[\w\._-]+)\/?(?P<filePath>.*)/', $url, $matches);

    // version 6: Ok.
    $rc = preg_match('/(?P<protocol>\w+):(\/\/)?((?P<userName>\w+):?(?P<password>\w+)?@)?(?P<hostAddress>[\w\._-]+)\/?(?P<filePath>.*)/', $url, $matches);

    if ($rc) {
        return $matches;
    }
    else {
        return false;
    }
}
?>

在工作團隊中,可以並行撰寫測試工作與程式。單人作業時,則一般會交叉進行這兩份工作。然而不論工作環境為何,我們皆反覆 (iterative) 進行測試→修改→再測試的動作。反覆式開發過程,或是 Microsoft Solution Framework 所稱的 Value-Up 過程,實際上是我們多數人進行設計工作的情形。我們多數人的程式碼總是要一改再改才能完成。那種一次到位的天才畢竟是極少數人,而且他們不需要軟體工程,軟體工程也不是為他們而存在。不懂軟體工程只是因為經驗不夠,不是因為它高深莫測。先入為主地將軟體工程視為少數天才談論之事物的想法,可是會妨礙學習進路的。

相關文章
樂多舊網址: http://blog.roodo.com/rocksaying/archives/2609801.html