昨天和何米特、卡曼德 (不要懷疑,他們都是道地的台灣人)聊到測試,想到一個不錯的例子。這回就用實例來說明「容易測試」和「不容易測試」到底是怎麼一回事。完整的程式可以從這裡取得。
題目說明
從參數列讀入一個網址,計算網頁內的單字數量並輸出到螢幕。單字之間用空白區隔。當網址無效時,輸出 -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 後,會改變寫程式的思維。先寫測試有助於寫出易測的程式。
沒有留言:
張貼留言