2010年4月11日 星期日

寫出容易測試的程式

昨天和何米特、卡曼德 (不要懷疑,他們都是道地的台灣人)聊到測試,想到一個不錯的例子。這回就用實例來說明「容易測試」和「不容易測試」到底是怎麼一回事。完整的程式可以從這裡取得。

題目說明

從參數列讀入一個網址,計算網頁內的單字數量並輸出到螢幕。單字之間用空白區隔。當網址無效時,輸出 -1。

為了方便說明起見,我選了個簡單但完整的例子。若要將問題變得更有說服力,不妨想像要連線到網頁伺服器,需要來回幾次的通訊和分析網頁內容以達成目的。比方說寫個程式下載漫畫或美女圖之類的。總之,程式愈複雜,下面提到的問題愈嚴重。

直覺的寫法

相信大家看到這題目都會覺得簡單嘛,這樣的程式有什麼難的,寫一寫、跑一跑、改一改,來回幾次就搞定了。寫出來的程式大概長這個樣子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import sys
import optparse
import urllib2
 
def main(url):
    '''\
    %prog [options] <url>
    Print the number of words in <url>.
    Print -1 if <url> is invalid.
    '''
    try:
        content = urllib2.urlopen(url).read()
        print sum(len(line.split()) for line in content.split('\n'))
    except urllib2.URLError, e:
        print -1
 
    return 0
 
if __name__ == '__main__':
    parser = optparse.OptionParser(usage=main.__doc__)
    options, args = parser.parse_args()
 
    if len(args) != 1:
        parser.print_help()
        sys.exit(1)
 
    sys.exit(main(*args))

看起來沒啥問題,但是我們要怎麼確定這份程式是對的?嗯,大概找幾個有效的網址、無效的網址,試一試,看看跑出來的數字對不對。或著專業一點,自己弄幾個簡單的網頁方便計算答案,連入自己的網頁,對看看有沒有算對字數。

可以想見,這個測試過程冗長乏味。之後若做些修改,像是規格改成「輸出的數字要四捨五入到百位」、「輸出全部的字和它的次數」,沒人想從頭重測一次所有情況。

這裡的問題是什麼?問題出在沒有做到自動測試。那就寫個 shell script 讀入一串網址依序執行並存下結果,人工確認結果無誤後,再將答案存起來。之後就執行 shell script 讀網址比對輸出。比方說像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash
# usage: ./check.sh <prog>
function check() {
    python $1 $2 > t.txt
    read n < t.txt
    rm -f t.txt
 
    if [ $n -eq $3 ]; then
        echo "Pass."
    else
        echo "Fail."
        exit 1
    fi
}
 
check MY_PROG http://www.googel.com/ 257
check MY_PROG http://www.googel/ -1

雖然少了冗長的手動測試,上述的測試流程仍有幾個問題:

  • 無法保證測試結果一致。有可能連到的網站改變內容,或是剛好連不上,使得每次測試可能得到不同的結果 (即使 Google 不會斷線,它總會改變網頁內容吧)。那麼,測試失敗時,我們怎麼知道是測試過程有問題,還是被測試的程式有問題?
  • 測試費時。受到網路連線的限制,測試相當費時。使得我們不會改一小段程式就執行所有測試。若我們能寫一行程式就跑一次測試,馬上能明白是那裡改出問題。
  • 測試失敗無法直指錯誤的源頭。我們只知道連 A 網址沒得到預期結果,接著得進程式一步步看,輸出內部資訊才能慢慢找到寫錯的地方。
  • 不易準備測試資料。若想測空網頁、有一個字的網頁和有一堆字的網頁,就得準備三個檔案。

目前看來,上述的問題似乎不大。但若有上萬行程式和上百個測試案例,一個案例要跑一秒,加起來就變一百秒。其中又有一兩個偶而會測試失敗,造成每次跑完測試無法相信測試結果。即使測試結果沒有疑慮,當測試失敗時,要怎麼從上萬行程式中找出錯在那裡?

第二版:將計算字元數的部份獨立成函式

在第一版的程式裡,為了測試是否有算對字數,得準備多份網頁和網頁伺服器再透過 HTTP 讀入內文做測試。光看這麼長的描述就會發覺那裡有些不對勁。若將計算字數的部份獨立成一個函式,就能單獨測「無內文」、「只有一個字」、「有很多字」等情況:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def count_lines(lines):
    return sum(len(line.split()) for line in lines)
 
def main(url):
    '''\
    %prog [options] <url>
    Print the number of words in <url>.
    Print -1 if <url> is invalid.
    '''
    try:
        content = urllib2.urlopen(url).read()
        print count_lines(content.split('\n'))
    except urllib2.URLError, e:
        print -1
 
    return 0

獨立出函式後,就能直接測算字數的部份:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class CountLinesTest(unittest.TestCase):
    def testEmptyContent(self):
        actual = wc.count_lines([""])
        self.assertEqual(0, actual)
 
    def testAWord(self):
        actual = wc.count_lines(["camel"])
        self.assertEqual(1, actual)
 
    def testWords(self):
        actual = wc.count_lines(["a camel can fly"])
        self.assertEqual(4, actual)
 
    def testWordsWithSpaces(self):
        actual = wc.count_lines([" a camel can fly "])
        self.assertEqual(4, actual)
 
    def testWordsWithAdjacentSpaces(self):
        actual = wc.count_lines([" a camel  can \tfly "])
        self.assertEqual(4, actual)
 
    def testMultiLines(self):
        actual = wc.count_lines(["a", "b c", "d e f"])
        self.assertEqual(6, actual)

看來不壞,上面的單元測試可以確保算字數的部份是對的。若上面的測試失敗,也能明白錯在那段程式,又有精簡的輸出入範例協助除錯。並且,獨立出來的函式 (count_lines) 可供其它程式使用。

但是對整個程式來說,我們還是擺脫不了下列問題:

  • 無法保證測試結果一致。
  • 測試費時。

第三版:將網路連線的部份封裝成物件,並用「傳入」的方式使用它

問題出在 main() 直接用 urllib2.urlopen() 連上網路,若能在測試時替換成我們準備好的函式,就能確保測試結果一致,且減少網路連線的時間。也許有人會說,可以在測試前直接換掉 urllib2.urlopen。這是一個解法,但不建議這麼做,原因日後再說明。比較適當的作法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Client(object):
    def get(self, url):
        return urllib2.urlopen(url).read()
 
def count_web_page(client, url):
    try:
        content = client.get(url)
    except urllib2.URLError, e:
        return -1
    return count_lines(content.split('\n'))
 
def main(url):
    '''\
    %prog [options] <url>
    Print the number of words in <url>.
    Print -1 if <url> is invalid.
    '''
    client = Client()
    print count_web_page(client, url)
    return 0

於是我們可以用 mock (我用 pymox) 準備假的 Client 反應出我們期望的網頁連線結果,輕易地測試正常連線和網址無效的情況:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class CountWebPageTest(unittest.TestCase):
    def testCountWebPage(self):
        # Prepare the fixture
        url = 'http://www.google.com/'
        client = mox.MockObject(wc.Client)
        client.get(url).AndReturn('<html> ... </html>')
        mox.Replay(client)
 
        # Run
        actual = wc.count_web_page(client, url)
 
        # Verify
        self.assertEqual(3, actual)
 
        mox.Verify(client)
 
    def testPageNotFound(self):
        # Prepare the fixture
        url = 'http://www.google/'
        client = mox.MockObject(wc.Client)
        client.get(url).AndRaise(urllib2.URLError("..."))
        mox.Replay(client)
 
        # Run
        actual = wc.count_web_page(client, url)
 
        # Verify
        self.assertEqual(-1, actual)
 
        mox.Verify(client)

回頭看原本的程式有那些改變:

  • 獨立出 Client 物件,用來封裝網路連線的操作。
  • 傳 client 給 count_web_page() ,而不是在 count_web_page() 內部 new 出 Client。

想想看,若沒有上述兩個改變,要怎麼滿足測試需求呢?該怎麼測試各種網路連線問題呢?這個問題觸及容易測試和不易測試程式的關鍵差異。

結語

就結論來說,一但在函式內直接用全域變數、全域函式或使用自己 new 的物件 X,就不容易測試之後的程式了,因為之後的程式邏輯受到 X 的影響,但測試程式又無法直接控制 X 的行為。當然我們無法完全避免這個情況,總會有全域函式、需要 new 物件。重點在於,我們能將程式隔離到什麼程度?有沒有留「後門」讓測試程式方便控制內部邏輯。

也許有人會懷疑為了測試而改變原本的寫法,是否有些本末倒置?不妨自己練習做個幾次,對照一下原本的寫法和修正後的寫法,用自己的經驗判斷:為測試而改變的結果,是否恰好讓程式變得更彈性、更易重用?

附帶一提,上面的範例碼是逆向寫出來的。我是先用 TDD 寫出最後的版本,再將拆開的東西塞回去 main,弄出「直覺的寫法」。習慣 TDD 後,會改變寫程式的思維。先寫測試有助於寫出易測的程式。

沒有留言:

張貼留言