2010年9月11日 星期六

每件事都有它的價值

過去我一直在尋找捷徑,希望能找到經驗以外的價值,不然只是比年資,感覺很無趣。老手比新手強,只是因為他做得久、比較熟悉公司內的程式。待新手變強後,也只是因為他經驗變多。似乎人與人之間沒什麼差異性。改善自己學習的方式是個好主意,但有智慧的人都會不斷改善自己學習的方式,以能夠更有效率地學習。我不斷地思考自己的定位,以及如何學得更有效率。期望在天份、經驗外找到其它的聖杯。

Steve Jobs 在對 Stanford 大學的畢業演講裡提到:

Again, you can’t connect the dots looking forward; you can only connect them looking backwards.

抱著半信半疑的心態,我只能先相信這個看法,繼續邊做邊想。

後來在杜書伍的《打造將才基因》裡看到:

我並末想出什麼令人拍案叫絕的答案來,但當時得到的結論卻一直到現在我都認為是正確的,...,經驗要累積得快,除了比別人更努力工作外,似乎找不到其他的方法。

看第一次時仍是半信半疑。然而,最近重讀這本書,對照近來的體悟,忽然間想通了這點。

在經歷 TDD 的洗禮後,我覺得我好像找到一個不錯的答案,至少它是我在軟體開發領域裡找到最好的答案。似乎只要練 TDD,就能在同樣時間內爆增功力。相較於過去看的 Design Pattern、程式語言實作技巧等,TDD 似乎更宏觀實用。

就在實踐 TDD 一年後,偶然看到別人的討論,而有不同想法。練習 TDD 後確實能寫出較好維護的軟體,不過除錯的經驗也變少了。寫了快一年半的程式下來,我一直沒有用 debugger 的需求,只開過兩三次 python debugger,碰一下就放棄它,改成補寫 unit test 偵錯。這才發覺,每段時間都有它的意義,只是練得目標不同。我明白照目前的方式走下去,再過個三年五年,我用 TDD 的功力會愈來愈深,不過用 debugger 功力仍然會是零。這沒好壞之分,端看目標為何,以及自己的定位。

在其它學習的經驗裡,發覺看過的知識,還是要實際操作過數次才能內化到自己的思考體系裡。操作次數愈頻繁,體悟愈深。像許多系統設計或軟體開發流程的最佳實踐 (best practice),還是要走過不同的路,回頭才會明白最佳實踐背後的考量,才知道如何融會這些點到自己的情境裡。很多東西光用看的,或光用做的,無法體會背後的精神,遇到狀況時仍然使不出來。所以,減少嘗試多讀最佳實踐,效果有限;老是硬幹不讀別人的體悟,也是效果有限。

剛才在聊看 Python 源始碼的事Scott Thinker 不約而同建議直接看原始碼,而不用看書 (即使 Scott 認為書本的大綱看來不錯)。但我覺得先看書有個概念再來看原始碼應該比較有「效率」。經 Thinker 說明,才發覺這是練得目標不同。直接硬啃 code 看來較費工,但在費工之中才會有更多練習讀碼的機會,會更熟練相關工具和技巧。而我的目的是增加系統設計經驗為主,讀原始碼為輔,自然會覺得先讀書較好。

每件事都有它的價值,每個技術都需要時間才能挖深。然而,要將走過的路合起來發揮最大功效,要將所學收斂在相關領域裡。這讓我不經花更多時間思考,往後自己的定位為何,我想發展什麼樣的知識網。總之,相信 Steve Jobs 和杜書伍的話,「Stay hungry. Stay foolish.」繼續邊做邊想吧。

2010年7月18日 星期日

學習的自我要求

學生時代我一直在想一個問題,究竟是問題導向的學習方式較佳,還是技術導向較佳?舉例來說,我想寫個留言版,我發現要完成最基本的需求,我得學 HTML、PHP 和 MySQL,於是我針對我要做的功能學相關的語法,最後完成留言版。可能資料庫設計沒用到正規化,網站外觀沒用到 CSS,一些動態功能用 PHP 實現而非 JavaScript。相較於從頭學寫網站所需的各項技術,這個作法比較務實,我用較少的時間完成目標。極端地看,用問題導向的解法時,我可能找到相關的程式碼,用它們拼貼出結果,但不確定各塊在做什麼。技術導向的解法,可能要先花一段時間弄清楚寫網站需要的技術,再各別學個基本能力,接著開始實作。當然,世界上不是只有 0 和 1,我們常混著用問題導向和技術導向的方式解問題。這裡我說用問題導向的意思,是指偏向用問題導向。

不只做產品,做研究時也是類似的情況。問題導向就是從問題開始往下挖,看前人做那些方法,將自己不熟的部份補起來,有了必要的知識後,就能回頭解決問題。而技術導向的做法,我可能會加強基礎學科知識,視類別而定可能是機率和線代,或是計算機組織和編譯器,接著才開始思考如何解問題(或重新定義問題)。

以前我沒什麼時間觀念,通常是技術導向的作法,覺得有趣就看我能從多底層開始往上學。比方說使用 Java Collections Framework 時,我會弄清楚我要用的資料結構怎麼運作的的,時間複雜度是多少。實際上大部份的使用情境不需要這些知識,稍微讀一下文件的注意事項即可。要用 machine learning 的工具,就找適合的 machine learning 課程講義來讀,弄清楚要用到的 model 怎麼運作。直到現在,我無法確認這些對於使用工具究竟幫了多少忙,當初花的時間對應到目前的幫助,真的划算嗎?

最近我試著用問題導向的方式解問題,初期進展確實比較快,不過後來遇到一些狀況,才發覺技術導向的好處。有些情況,我們根本不明白問題出在那裡,有太多可能。舉例來說,前幾天我和同事發覺不同使用者登入同一頁面,操作速度卻差了一大截。有許多可能原因:網路連線 、網頁框架、資料庫等。做了一些初步測試,我懷疑是 MySQL 根據歷史記錄做錯 query optimization。於是用 EXPLAIN  看相同的 SQL 配合不同使用者 ID,結果發現 MySQL 執行 query 的方式有細微差異,造成取出某些使用者的資料時,用較慢方式執行 SQL。於是讓 MySQL 重分析表內的資料後,問題就解決了。若不是之前稍微看過 MySQL 執行 query 的相關知識,不會這麼快就直指問題核心。也許就會用別的方式繞開這問題,一輩子都不知道怎麼解它。待發生類似問題時,又用別的方式繞開它,長遠來看浪費開發時間又增加維護成本。

另一個反面的例子是,我一直沒用 lock 的習慣,教科書告訴我們 deadlock 很可怕,所以我會想辦法避開用 lock。結果最近有個小專案因為沒用 lock,真的發生 race condition 造成有一點點資料不正確。實作前我明白會有這樣的狀況,但這個問題對我們的目的沒什麼影響,衡量開發時間後我決定寫下註解強調會有 race condition,而選擇不處理它。對照最近的體悟,我明白這樣下去我不可能學會用 lock,這不是個好現象,所以又找時間回頭看 MySQL 怎麼 lock table,結果比想像中來得簡單,之前多慮了。

有很多類似這種的逃避例子,像多人一起寫程式容易有問題,於是大家傾向將功能切乾淨,每人寫沒有交集的功能,最後再來整合。但是,對照近年來的軟體開發的趨勢,愈早整合愈容易解決問題。一個人開發容易有盲點,互相協助可以降低初期錯誤,以利後期整合。問題是,要能順利地多人共同開發,得做對不少事才行。像是版本管理系統、coding style、天天合併程式等。每一項都需要時間練習。若一個人開發時有好好練習,和別人合作時會減少許多問題,比較容易推動密集的團隊合作。

在面試別人時,我發覺一個問題:有些人學到的技能剛好只能應付他負責的專案。問題在於,若平時我們都處理簡單的專案,要怎麼轉去負責困難的專案?兩者之間有個斷層。像這類的例子不勝杖舉。比方說要從資料庫表 A 取出部份資料塞入表 B,最「簡單」的作法是寫個程式用 SQL 取出資料,用程式做些處理再用 SQL 一筆筆寫入表 B。另一個作法是直接用一個較複雜的 SQL 直接搞定。當資料量大時,後者執行速度會快上不少。並且,學會後者的寫法後,之後只要花一點時間就能處理類似的情況,不用再寫一個小程式。其它像寫程式的習慣、用程式工具的習慣,都是一樣的。多數情況我們可以用最「不費力」的作法滿足需求,但長遠來看卻是毫無長進。實際上有更有效率的作法,這裡的效率包含開發時間和軟體品質。

對照大學的程式來看,這一年來我以為自己程式已寫得頗有品質了,雖然知道一些小問題,但覺得並不迫切,也不知怎麼查相關的解法,就放著它們。最近翻了一下 Effective Java 第二版Growing Object-Oriented Software Guided by Tests,才發覺還有太多的東西要學,自己和資深的軟體工程師差了一大截,照我目前的學習方式,和他們的差距只會拉大不會縮短。若只專注完成眼前的工作,我永遠無法補足和更難工作之間的差距,這才驚覺問題導向的盲點。

走過天平的兩端後,我現在的體悟是,得雙向夾擊來解決問題。一方面用問題導向解問題以符合時程,確保時間有花在刀口上。另一方面再抽時間用技術導向的方式強化自己的實力。如此一來,在完成當下的專案的同時,也有一點一滴地補足技術斷層,取得挑戰更難專案的機會。題外話,英文也是很重要的「技術」,這一年來我半強迫地讓自己盡量搜英文文件,思考關鍵字比以前敏銳不少,閱讀速度也變快,獲得答案的速度比以前快、品質也較佳。

2010年5月29日 星期六

養成寫程式的好習慣

我很喜歡 Kent Beck 說的這段話
“I’m not a great programmer, I’m a pretty good programmer with great habits.”
從學生時代開始,我就習慣照著書上的建議寫程式,一直沒覺得什麼特別的。後來和一些人合作,或是看到別人抱怨那裡又出錯了,才驚覺那些好習慣有這麼大的影響力。試著在一些場合向別人說明如何改進寫程式的方式,才發覺很難改變寫程式的習慣 — 大概就和我一直無法早睡早起一樣難...。

大前研一說許多人在解決問題時誤把結果當原因,沒有深入追究問題的根本,只是解決問題根源造成的表面問題。寫程式自然會有 bug,但是有許多 bug 並不是 bug,而是壞習慣造成的。像是在 PHP 或 JavaScript 裡不用 === 和 !==,而被 type casting 誤導造成 bug。或是在 if / while 裡用 assignment 又不小心漏了括號,寫出 if (a=foo() > 0) 這類 bug。
好習慣得隨時間慢慢累積,做得愈多愈久,功力自然會變深。就如同龜仙人要悟空和克林送牛奶那般練基礎功,帶著好習慣寫程式,經年累月下來,學到的東西會更多。以下針對一些特定的習慣提我自己的感受。

Coding style

網路上有不少 coding style 建議,有些如 Google coding style 甚至會解釋為何要這麼寫,這樣寫的好處和壞處為何。可以學到不少寫程式的小技巧。
最近遇到的實例是遵守 Python coding style 所說,在 module 開頭寫 import,並照順序 import 內建、第三方模組和自己的模組。在程式寫到近兩萬行後要做些修改時,我發覺這個簡單的習慣,讓我很容易明白那些模組有關聯,很快就能找出要修改的程式。反過來說,閱讀一些 Django 程式碼時,發覺常在函式裡 import module,不易掌握模組之間的關係。
還有控制函式的行數在螢幕的高度內、區域變數的使用範圍(要用到時再「宣告」)、避免用全域變數。遵守這些習慣使我容易掌握變數的影響範圍,除錯時可以省下不少心思思考變數是否被別的地方改到。
最近初學 JavaScript,就在 coding style 的建議裡找到減少 global object 和管理變數 scope 的技巧:使用一個 global object 存放所有變數和函式,藉此在 JavaScript 中做出模組的效果。

Version control (以 Mercurial 的指令為例)

即使一個人寫程式,version control 仍有很大的用處。保持每個 commit 精簡,每個 commit 只完成一個小功能,就能輕易追踪過去的改變。
以下是幾個我常用 VCS 協助的情況:
  • 寫程式較不怕被中斷,只要 hg diff 就知道剛才改了什麼。commit 前也能清楚明白這次做了那些修改,去掉忘了除掉的 debug code。
  • 可以放心地修改,改到昏頭就 hg up -C 清掉剛才不知所云的修改,不用花費力氣將程式弄回正常的版本。
  • 寫到一半發覺要先完成另一個功能,hg shelve 暫存目前的修改,接著將另一個功能做完並 commit,再 hg unshelve 回頭做原本的事,可以輕鬆地切換目標,隨時專注在目前的目標上。
  • 若發覺某個功能忽然不能運作,hg up 切回舊的版本,做個 binary search (或用 hg bisect) 立即找到改出問題的 commit。由於每個 commit 都很精簡,看一下就會找到改爛的原因。
我最近用 jQuery 寫的程式,過了一陣子後發覺某個功能不能運作。用 hg up 和 binary search 的方式,很快地找到在一百多版前改爛的,而且改爛的原因很奇妙,我將目標 tag 的 id 設為 “submit” 後就爛了,但若換個名字或不設 id 就沒事。若沒有 version control,我想我在原本的程式裡找半天也不會找到,根本不會懷疑問題出在這裡。最後大概會重寫該段,然後莫明奇妙地避開這個問題。
和人合作時 version control 就更有用了,方便和其他人共用程式、做 code review、自動跑測試確保各版運作正常,好處不勝杖舉。相較於每個人各自寫程式,多個人同寫一份程式不但方便討論,容易互相支援,開發時士氣也會較好。每次看到別人 push code,就會覺得待做事項漸漸變少,而寫得更有勁。

Refactoring

在寫新功能或修 bug 前,若發現有重覆程式碼或一些有潛在風險的程式,先重構程式再回頭做原本該做的事。重構前記得要確保重構後行為不變。若情況太糟很難補 unit test 或是沒時間補太細,準備好幾組常用的輸入資料,記好它們對應的輸出結果,寫個自動測試的 recorded test 會比較安全,也可加快後續的重構。配合 VCS 做起來更容易,改改發現無法通過 recorded test,就 hg up -C 重頭改一次。然後別太貪心,一次改一點比較不容易犯錯。視情況寫 recorded test 的方式有所不同。通常我會用 shell script + diff 這類指令很快的拼一個可以用的小工具,重構完就丟了,節省準備 recorded test 的時間還有免除日後維護它的負擔。但若打算長久維護的話,自然是照規矩一步步做會比較穩當。

Unit test / Acceptance test / TDD

這部份在先前的文章已提了不少,隨著時間演進,實例愈來愈多。最近將 Django 1.1 昇到 1.2,跑 unit test OK, 但跑 Selenium test 時看到 CSRF 的錯誤訊息。稍微修一下,測試程式全過。讓我立即確信所有用到的 plugin 都順利地在 1.2 版下運作。
開始用 TDD 可說是我寫程式生涯中的重大里程碑,踏入完全不同的格局。讓我明白如何寫出易於長期發展的程式,不用像在玩踩地雷般辛苦。

Pair programming

這不算是個人習慣,順便記在這裡。
這部份我沒太多經驗,有時運作的不錯,有時不太順。執行 pair programming 前要先確保兩人的背景知識差不多,才不會有一人跟不上進度,讓另一人空轉。運作順利時,可以很快地完成較複雜的設計,並確保至少有兩個人可以繼續維護這份程式。而且程式也會較易懂:兩個人覺得好懂的程式,遠比一個人覺得好懂的程式易懂多了。
Pair programming 比寫下規範更容易讓大家有一致的開發習慣,像是 coding style 或是 commit 的規範。藉由一人帶一人的方式連結開發習慣。也方便分享實作技巧,像操作工具的技巧、使用函式庫的經驗或是寫程式的技巧。

其它

除養成好習慣外,偶而抽點時間學習工具的操作,像是 Linux 架站裝軟體之類的,開發軟體時很難避免這些事。像我習慣用 Linux terminal 開發程式,多熟悉 screen、bash、Vim 的設定和操作,開發速度可以快上不少。
最近的例子是使用 Firefox 的 plugin Firebug。以前改 CSS 都笨笨地存檔、重讀網頁,用 Firebug 讓我用十倍以上的速度完工,令人不勝噓唏。

結語

剩下的就是熟悉函式庫、框架,還有學會資訊工程一些基本知識,了解程式背後運作的原理。一但每個人都能保持寫程式的好習慣,團隊合作將會簡單許多,大家方便共用程式,方便互相支援 (寫相依的元件或除錯),既能加快開發速度,也會比較有趣。

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 說的這段話:

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

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