寫出容易測試的程式

fcamel
fcamel的程式開發心得
12 min readFeb 3, 2018

最近有機會在 C++ 寫 unit tests,找回多年前用 Python 寫 tests 的感覺。想說來整理一下舊文,結合新舊體悟重新發幾篇文章,這篇是第一篇。

TL; DR。如果要用一句話總結的話,重點是 Testability-Driven Design,而 Test-Driven Development 能引導我們往此方向練習。務實的作法是著重在 testability 而非 test。

我過去的經驗

  • 用 Python 寫過兩萬行左右的專案,印象中 software under test (SUT) 和測試程式碼比例是 2 : 1 還是 1 : 1 的樣子。專案從頭開發和維護,歷時兩年多。
  • 極少數用 C++ 寫 tests 經驗,在陳年的龐大 code bases 內修改 (十萬行以上)。

以下用 Python 實例說明「容易測試」和「不容易測試」是怎麼一回事,為什麼重要?如何寫出容易測試的程式 ?

題目說明

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

為了方便說明起見,我選簡單但完整的例子。若要將問題變得更有說服力,不妨想像要連線到網頁伺服器,需要來回幾次的通訊和分析網頁內容以達成目的 (像是 crawler)。

直覺的寫法

# wc.pyimport 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 讀網址比對輸出。比方說像這樣:

#!/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 會改變網頁內容)。那麼,測試失敗時,我們怎麼知道是測試過程有問題,還是被測試的程式有問題?
  • 無法測特殊情境。例如想支援網路連線 timeout 後嘗試重連,怎麼模擬這個測試情境?
  • 測試費時。受到網路連線的限制,測試相當費時。使得我們不會改一小段程式就執行所有測試。極端來說,若能寫一行程式跑一次測試,馬上能明白那裡出問題。
  • 測試失敗無法直指錯誤的源頭。我們只知道連 A 網址沒得到預期結果,接著得進程式一步步看,輸出內部資訊才能找到寫錯的地方。
  • 不易準備測試資料。若想測空網頁、有一個字的網頁和有一堆字的網頁,得準備三個檔案。

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

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

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

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

直接測算字數的部份:

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() 連上網路,若能在測試時替換成我們準備好的函式,就能確保測試結果一致,且減少網路連線的時間。

這裡有兩個作法:

  • 不改 SUT,在 tests 裡替換 urllib2.urlopen: Python 可行,C/C++ 這類語言不易實行。不方便同時測試多個 test cases,因為 urllib2.urlopen 只有一份。
  • 修改 SUT,不直接使用 urllib2.urlopen(),改成使用「從網路抓資料」的物件。讓 SUT 和 IO 分離,無形中建了一個溝通介面,日後用不同方式取資料 (e.g., 檔案、資料庫) 都容易擴充。但在讀程式碼細節時,不方便理解真正的實作為何,要多花點工夫找到相關程式碼。

這裡我採用第二個作法:

class UrlFetcher(object):
def get(self, url):
return urllib2.urlopen(url).read()
def count_web_page(fetcher, url):
try:
content = fetcher.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.
'''
fetcher = UrlFetcher()
print count_web_page(fetcher, url)
return 0

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

class CountWebPageTest(unittest.TestCase):
def testCountWebPage(self):
# Prepare the fixture
url = 'http://www.google.com/'
fetcher = mox.MockObject(wc.UrlFetcher)
fetcher.get(url).AndReturn('<html> ... </html>')
mox.Replay(fetcher)
# Run
actual = wc.count_web_page(fetcher, url)
# Verify
self.assertEqual(3, actual)
mox.Verify(fetcher) def testPageNotFound(self):
# Prepare the fixture
url = 'http://www.google/'
fetcher = mox.MockObject(wc.UrlFetcher)
fetcher.get(url).AndRaise(urllib2.URLError("..."))
mox.Replay(fetcher)
# Run
actual = wc.count_web_page(fetcher, url)
# Verify
self.assertEqual(-1, actual)
mox.Verify(fetcher)

mock 的行為是 record、replay、 verify,可以錄下預期的操作,然後在被呼叫到的時候傳回事先準備好的回應,像是回傳資料或是丟出例外。關於 Mock 以及 Mock 的替代選擇,可參考《Test Doubles — Fakes, Mocks and Stubs》

這個例子裡可以不用 mock,但若需要支援網路 timeout 後重傳,可以很容易用 mock 模擬第一次呼叫丟 timeout exception,第二次回傳正常內容。

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

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

這裡值得思考一點: 若沒有上述兩個改變,要怎麼滿足測試需求?該怎麼測試各種網路連線問題?這個問題觸及容易測試和不易測試程式的關鍵差異: 是否有避免 SUT 直接存取外部資源 (檔案、網路、etc)。

討論

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

有些人會懷疑為了測試而改變原本的寫法,是否本末倒置?或者換個說法:設計軟體架構的時候,最重要的性質是什麼?模組化?易重用性?低耦合?

這個問題背後真正的問題其實是軟體設計的目標是什麼?

我的看法是:

  • 如何驗證程式是對的?
  • 如何讓程式容易地被修改?

針對這兩個問題來思考設計時最重要的性質,我的答案是:容易測試和除錯。只要具備這兩個特性,之後有任何問題和新需求,都很好修正。

此外,若模組的切隔點適當,測試碼在測試模組的上層介面而不是內部瑣碎的操作,通常為測試而改變的介面會讓程式更有彈性、更易重用。

Test/Testability-Driven Development

上面的範例碼是逆向寫出來的。我是先用 Test Driven Development (TDD) 的流程寫出最後的版本,再將拆開的東西塞回去 main,弄出「直覺的寫法」。

相較於後寫測試碼,先寫測試碼有額外的好處:

  • 開發時可省下手動測試時間
  • 避免 overdesign (因為要寫測試,會比較懶得寫還未用到的功能)
  • 提早了解如何使用,有機會改善介面

習慣 TDD 後,會改變寫程式的思維。實務上要堅持TDD 太難了,多數情況也不實際。但練習 TDD 的過程會影響思維,養成在開發初期就考慮維護和易測性。如同 OOP 或其它技能一樣,需要長時間練習才會進步,然後才會在實戰上有所助益。

測試碼一樣有開發和維護成本,有時不適合或沒有足夠時間寫測試碼。此時仍能在設計介面時著重可測性 (testability) ,如此一來,之後需要補測試時,成本會比較低。宏觀來看,TDD 其實是強迫你考慮可測性的流程,一但養成考慮可測性的習慣,是否 100% 照 TDD 流程作,反而不是那麼重要了。

相關文章

--

--