2010年4月24日 星期六

百人百觀 (3)

原本我以為大部份問題有「標準答案」,而不斷地尋找每個問題的「標準解」。兩個人見解不同,必然有人有誤,也可能都不對。在《百人百觀》裡,我才明白不是這麼一回事:

最後我發覺沒什麼最正確、最有道理的事,百人百觀,於是我不會硬把自己的想法套到別人身上,當別人和我抱怨誰的想法不合理,大家都如何,他偏偏不合群,即使我贊同大部份人的看法,卻不會像以前一樣,認為那個特立獨行的人有問題,想說服他。

《百人百觀 (2)》裡,我才明白不該強加自己的觀念在別人身上,即使我認為自己沒錯,也要尊重別人的見解:

於是我不在意我是否能影響聽者的觀點,有的話,當然很高興;沒有的話,對方可能需要不同的契機來改變,那個契機不是我。也可能這個想法適用於我,不適用於對方。另一方面,我仍然熱於與人討論,百人百觀不意味交流沒有意義,只是不用過於執著自己的想法。

但是直到最近,我才明白自己的想法常常有誤,要能接受不同的看法,從中學習。這個轉變花了我不少時間。先是看了費曼的言論而開始懷疑一切、懷疑自己。我很喜歡他在《這個不科學的年代! 》第一篇裡說的話:

有些人說:「你怎麼能夠活著而無知?」我不知道他們是什麼意思。我從來都活著,也從來都很無知。那容易得很。我想知道的是你如何能什麼都知道。

後來又看了 TED Talk 《Weird, or just different?》,裡面有段話很棒:

Whatever brilliant ideas you have or hear, the opposite may also be true.

我原本對此半信半疑,經過幾次實例驗證,發覺我堅信「絕對沒錯」的作法,仍有一些情境不適用。若能從自己習慣的作法中找出反面的價值,或許會大有幫助。一但認定「肯定如此」後,就失去改進的機會了。

至於前兩篇文章對於溝通的焦慮,現在的心得是,要能開放心胸,站在對方的角度思考。嘗試幾次後,會發覺以前沒想到的事。再來就是去除情緒,就事論事。說來容易做來難,還在持續練習中。附帶一提,站在對方的角度思考並不容易,不只是情緒上的問題,有時沒類似的經驗,無法明白對方看重的點。

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 後,會改變寫程式的思維。先寫測試有助於寫出易測的程式。

2010年4月5日 星期一

[譯文] 為什麼我們不好意思承認,我們不知道如何寫測試?

原文見:《Why are we embarrassed to admit that we don’t know how to write tests?》

前言

這篇帶給我很大的影響。對我來說,明白「可測性是最重要的」是一大里程碑。隨著經驗累積,了解得愈深,愈明白 Miško Hevery 寫得多有道理。就當我打算寫篇心得時,才發覺很容易變成用我的話重說 Miško Hevery 說過的東西,而且還很容易漏講。轉念一想,乾脆翻譯他的文章好了。二月底時徵得他的同意,沒想到一拖就拖了一個月半,真不好意思。

大家覺得那裡譯得不好或呈現方式不好,就直接反應出來 。透過 Buzz、Murmur、Plurk、Facebook 或在此留言都可。謝啦。

本文

Take your average developer and ask “do you know language/technology X?” None of us will feel any shame in admitting that we do not know X. After all there are so many languages, frameworks and technologies, how could you know them all? But what if X is writing testable code? Somehow we have trouble answering the question “do you know how to write tests?” Everyone says yes, whether or not we actually know it. It is as if there is some shame in admitting that you don’t know how to write tests.

找出你公司內一般水準的開發者並問他們「你會語言/技術 X嗎?」沒有人會為承認自己不懂 X 而感到不好意思。畢竟有太多程式語言、框架和技術,你怎麼可能全部都會?但若 X 是寫出能被測試的程式呢?不知為何,我們很難回答這個問題:「你會寫測試嗎?」不論我們是否真的懂,每個人都回答會。就如同承認自己不懂寫測試是件很不好意思的事。

Now I am not suggesting that people knowingly lie here, it is just that they think there is nothing to it. We think: I know how to write code, I think my code is pretty good, therefore my code is testable!

我不是暗示人們故意說謊,而是他們認為測試沒什麼大不了的。我們認為「我知道如何寫程式,我覺得我的程式相當不錯,因此程式是可以測試的!」

I personally think that we would do a lot better if we would recognize testability as a skill in its own right. And as such skills are not innate and take years of practice to develop. We could than treat it as any other skill and freely admit that we don’t know it. We could than do something about it. We could offer classes, or other materials to grow our developers, but instead we treat it like breathing. We think that any developer can write testable code.

我個人認為如果我們能意識到可測試性是一個獨立的技術,我們可以做得好多了。這種技術並不是天生就會的,需要經年累月的練習來培養。我們可以將它視為另一項技術並坦率地承認我們不會這項技術。於是我們就能對它做點事。我們能提供課程或其它教材來讓開發者成長,而不是將寫測試的技術視為如同呼吸的能力,好像任何開發者都會寫可以測試的程式。

It took me two years of writing tests first, where I had as much tests as production code, before I started to understand what is the difference between testable and hard to test code. Ask yourself, how long have you been writing tests? What percentage of the code you write is tests?

在我開始了解可測試的程式和難以測試的程式的差別前,我花了兩年的時間先寫測試。在這些程式裡,測試碼的量和產品碼一樣多。問問你自己,你持續寫測試多久了?在你寫的程式裡,測試碼占了百分之多少?

Here is a question which you can ask to prove my point: “How do you write hard to test code?” I like to ask this question in interviews and most of the time I get silence. Sometimes I get people to say, make things private. Well if visibility is your only problem, I have a RegExp for you which will solve all of your problems. The truth is a lot more complicated, the code is hard to test doe to its structure, not doe to its naming conventions or visibility. Do you know the answer?

你可以問這個問題來證明我的觀點:「你如何寫出難以測試的程式?」我喜歡在面試時問這個問題,多數的時候我得到沉默的回應。有時有人回答「隱藏物件」。嗯,如果物件的可見範圍是唯一的問題,我可以給你一個正規表示式讓你解決這問題(譯者注:我猜是在測試程式前先用字串比對把程式內所有 private 換成 public,那就可以測了)。真正的答案複雜許多,是因為程式的結構造成它難以測試,而不是命名習慣或物件的可見範圍。你知道答案嗎?

We all start at the same place. When I first heard about testing I immediately thought about writing a framework which will pretend to be a user so that I can put the app through its paces. It is only natural to thing this way. This kind of tests are called end-to-end-tests (or scenario or large tests), and they should be the last kind of tests which you write not the first thing you think of. End-to-end-tests are great for locating wiring bugs but are pretty bad at locating logical bugs. And most of your mistakes are in logical bugs, those are the hard ones to find. I find it a bit amusing that to fight buggy code we write even more complex framework which will pretends to be the user, so now we have even more code to test.

一開始我們都是一樣的。當我第一次聽到測試時,我立即想到寫一個框架來假裝使用者,使得我能用它來執行被測的應用程式。很自然會這麼想。這類型的測試被稱為使用者端測試(end-to-end-tests)(或是情境測試、大型測試),它們應該是你最後寫的測試,而不是一開始想到的。使用者端測試很適合找出 wiring bugs(譯者注:不知該怎麼翻,請參見 wiring bug 連結的說明),但不適合找出邏輯錯誤。並且,你的多數錯誤會是邏輯錯誤,它們才是難以找到的錯誤。我發覺這有些有趣,為了對抗有錯誤的程式我們卻寫了更複雜的框架來假裝使用者,於是我們有更多程式待測。

Everyone is in search of some magic test framework, technology, the know-how, which will solve the testing woes. Well I have news for you: there is no such thing. The secret in tests is in writing testable code, not in knowing some magic on testing side. And it certainly is not in some company which will sell you some test automation framework. Let me make this super clear: The secret in testing is in writing testable-code! You need to go after your developers not your test-organization.

每個人都在尋找解決測試麻煩的神奇測試框架、技術、知識。然而我有個消息要告訴你:沒有這種東西。測試的祕訣就是寫出能被測試的程式碼,而不是明白測試領域中某種魔法。並且大概不會有某家公司賣你某種自動測試框架。讓我說得更清楚一些:測試的祕訣就是寫出能被測試的程式碼!你需要關注你的開發者而不是你的測試組織。

Now lets think about this. Most organizations have developers which write code and than a test organization to test it. So let me make sure I understand. There is a group of people which write untestable code and a group which desperately tries to put tests around the untestable code. (Oh and test-group is not allowed to change the production code.) The developers are where the mistakes are made, and testers are the ones who feel the pain. Do you think that the developers have any incentive to change their behavior if they don’t feel the pain of their mistakes? Can the test-organization be effective if they can’t change the production code?

現在讓我們想想這點。大部份的組織讓開發者寫程式,接著讓一個測試組織來測試。讓我確保我明白這是怎麼回事,有組人馬寫出無法測試的程式,和另一組人馬分頭試著測試這些無法測試的程式(喔,而且測試小組不被允許改變產品碼)。開發者是錯誤的來源,測試者感受這些痛苦。你認為有任何誘因讓開發者改變他們的行為 — 如果他們沒有為他們製造的錯誤而感到痛苦?在不能改變產品碼的情況下,測試組織能夠有效地作事嗎?

It is so easy to hide behind a “framework” which needs to be built/bought and things will be better. But the root cause is the untestable code, and until we learn to admit that we don’t know how to write testable code, nothing is going to change…

躲在能建造/買來的框架後面是很容易的,事情也會改善。但是問題的根源是無法測試的程式,直到我們學會承認我們不懂如何寫出能被測試的程式,情況不會改變的...。

相關閱讀

2010年4月1日 星期四

我們不能改變手裡的牌,但是可以決定如何出牌。

在愚人節發文章好像怪怪的,搞不好會被人誤以為在說反話。

我很喜歡 Randy 說的這段話:

我們不能改變手裡的牌,但是可以決定如何出牌。

了解自己能改變什麼、不能改變什麼,專注在自己能影響的範圍裡,就沒什麼需要煩惱的事了。