Thursday, April 11, 2013

那些台灣軟體產業所缺少的 – 自動化測試

你是否有計算過,你在寫專案的過程中,測試過了多少次的程式? 我想是沒有,我也沒有,但是你是否有曾想過,或是感覺過,隨著專案的膨漲,你要測試的項目也跟著變多了? 這是理所當然的事情,當專案小,測試還算很輕鬆,因為程式的功能不外乎就那幾樣,一轉眼就測完了,常見的寫程式流程會像這樣
撰寫新功能
測試新功能
當然,也有修正bug的情況
修正bug
測試bug
如此一直循環,當你寫了新功能,理所當然地會去測試新功能,看是否如你預期地執行,那舊功能呢? 或許你記憶力不錯,在寫新功能的同時,想到先前某個舊功能是依賴現在改的東西,這麼一改可能會造成舊的功能出問題,於是你也順便測了一下舊的功能,當程式還小
撰寫新功能
測試新功能
測試舊功能
嘿,不怎麼樣吧? 只佔了開發時間的三分之一,好吧那如果有更多的舊功能要測呢?
撰寫新功能
測試新功能
測試舊功能
測試舊功能
測試舊功能
….
發現了沒有? 隨著你的專案越來越大,如果要確保整個系統所有的功能都是正常運作的,無可避免地,在你修改程式之後要測試的項目會越來越多
這表示你每寫一行新程式的成本增加了,身為以減低成本為傲的島國 國民: 台灣人…,你說,簡單! 不要測舊功能不就好了? 是的,我想這可能就是最常見的情況,不要測試舊功能理所當然地,每寫一行的程式成本都保持一樣很低,但這代表著舊程式可能出錯的風險也跟著增加了,當你喜 滋滋地覺得你幫公司省了成本,結果在一個月後因為舊程式缺乏測試,因改動了核心的部份造成舊的功能將所有資料外洩,公司損失慘重,這就是不重視軟體品質的 後果
舉真實生活上發生過的例子,PTT曾經有過改程式未經好好地測試,造成每個人都能以管理員的權限登入的事情,知名的檔案同步平台Dropbox,也曾經發生過因為認證的程式改版有bug,造成任何人都可以登入別人帳號的事,我也有曾聽聞一些網站因為工程師為了測試方便,把認證的函數暫時改成
function authenticate(user_id, password) {
        return true;
        // do authentication here
        // ....
}
然後又不小心commit,因此讓任何人都通過認證的事情,但這些都不能只怪工程師本身,誰能無過? 人總會犯錯的,問題出問於工程本身的制度、專案的管理、和工具的使用上
在未來,網路的應用越來越多,而軟體的品質重要程度只會越來越高,所以,要如何維持軟體的品質又同時能不讓測試的成本隨著專案的擴張而跟著無限制地成長呢? 答案就是 – 自動化測試

自動化測試

自動化測試聽起來好像很美妙,讓電腦自動幫你測試程式? 有這麼好的事情嗎? 事實上不是那樣,自動化測試,是透過寫好的規則,自動對於程式進行測試,所以終究還是得需要人力的介入,那你或許會問,結果倒頭來還不是得用人力? 那到底有什麼好處? 答案就跟我們先前提到的一樣,如果你的專案很小,用人力測試其實可能就已足夠,但當你的專案夠大,如果沒有自動化測試,那麼光是在測舊的程式就是相當龐大 的成本上
引入了自動化測試不代表程式就不會出錯,它不是萬能的,但是它至少保證了程式一定的品質,只要使用得當,就能降低測試的成本,也能讓大部份有經過自動測試的程式都不會出現太離譜的錯誤,至於要怎麼做,讓我們看下去

單元測試

最常見的測試,就是單元測試(Unit test),通常是針對單一個或是少數類別,確保這些類別單獨運作是正確的,舉個例子,你寫了一個類別,是用來找輸入的地圖的最短路徑,那麼你就得替這類 別,寫一個單元測試,餵入你準備好的資料,然後取得輸出的結果,看是否和你準備的預期答案是一樣的,舉一個最簡單的例子,一個用Python來將輸入文字 拆解成一行一行的解析器
class LineParser(object):
    def __init__(self, newline='rn', remain=''):
        self.newline = newline
        self._buffer = [remain]
        self._size = len(remain)

    def feed(self, data):
        self._buffer.append(data)
        self._size += len(data)

    def getLine(self):
        data = ''.join(self._buffer)
        index = data.find(self.newline)
        if index != -1:
            line = data[:index]
            self._buffer = [data[index + len(self.newline):]]
            self.length = len(self._buffer[0])
            return line

    def iterLines(self):
        line = self.getLine()
        while line is not None:
            yield line
            line = self.getLine()
它的單元測試就長這樣
import unittest

class TestLineParser(unittest.TestCase):

    def makeOne(self):
        return LineParser()

    def testParser(self):
        p = self.makeOne()

        p.feed('abc')
        line = p.getLine()
        self.assertEqual(line, None)

        p.feed('rn')
        line = p.getLine()
        self.assertEqual(line, 'abc')
        line = p.getLine()
        self.assertEqual(line, None)

        # write lots line
        p.feed('111rn222rn3333')
        lines = list(p.iterLines())
        self.assertEqual(['111', '222'], lines)
        line = p.getLine()
        self.assertEqual(line, None)

        p.feed('rntext')
        line = p.getLine()
        self.assertEqual(line, '3333')
        line = p.getLine()
        self.assertEqual(line, None)

        # write nothing
        p.feed('')
        line = p.getLine()
        self.assertEqual(line, None)

        p.feed('rn')
        lines = list(p.iterLines())
        self.assertEqual(lines, ['text'])

        self.assertEqual(list(p.iterLines()), [])

def suite():
    suite = unittest.TestSuite()
    suite.addTest(unittest.makeSuite(TestLineParser))
    return suite

if __name__ == '__main__':
    unittest.main(defaultTest='suite')
很簡單的想法就是列出幾種常見的case,還有你能想到的特例代進去,好的測試資料要能夠測到每一行程式,但是要做到那樣需要花不少心力,其實能夠做到大部份常見的情況和常見的特例,就已經相當足夠

整合測試

有些程式,無可避免地會依賴其它程式,如果我們針對這兩個程式同時測試,會無法分出出錯到底是誰的錯,再者,很多依賴的部份可能會牽扯到IO或是其 它系統資源,讓測試變得更複雜,例如有個類別是負責輸出文件到印表機的,那你要如何確認印表機印出來的東西是正確的? 答案就是做一個假的 (Mocking)印表機丟給那個類別去做列印的動作,再去讀取裡面的資料,確認跟你預期的一樣
雖然單元測試在相當單純的模擬環境下測過了我們的程式,然而世界並不是那樣的美好,總有些事情沒有經過真槍實彈操演過可能會有差錯,因此有時我們會 引入部份受控制的真實環境來測試,例如你想測試網路連線,或許你可以寫一段script在Amazon EC2上建起幾個instance,並上傳程式到那些機器中,自動讓他們連線來確保這些功能是正常的,然而越真實的環境變因就越多,因此測試也就相對困難

每日建構的好幫手 – Jenkins

Joel有說過 每日建構是你的朋友 (Daily build is your friend),也有提過 軟體開發成功的12個法則 (The Joel Test: 12 Steps to Better Code), 裡面的daily build是指利用工具每天自動建構整個專案,通常對於編譯式的語言,如C語言寫的大型專案會較需要這類的工具,但是這樣的工具還有一個目的,在於確保程 式是可以正常編譯的,並且讓測試員容易拿到最新的程式進行測試,然而自動化測試,同樣的也需要類似的工具,因為通常你在改完程式就會進行測試,那每次一改 完程式就得跑一次測試指令,這不是一件很煩的事情嗎?
記得,工具是為我們服務的,不是我們為工具服務,這樣重複的瑣事理所當然也是由工具來幫忙,謝天謝地現今有好用又免費的工具,可以幫你做到這點,那就是Jenkins, 它是一套基於網頁的自動化測試管理工具,它可以做到什麼呢? 它可以做到幫你定時去版本控制系統取程式回來,用預先設定好的流程進行測試,並且記錄測試的結果,如果有某個測試出錯了,當然也可以發Email通知你, 以Now.in的開發為例,因為專案為數眾多,其中又有依賴關係,有了Jenkins的幫忙,程式只要一改送到BitBucket,它就會自動進行測試
如此一來就省下了大量的時間,同時,也可以專心在於開發上
Jenkins除了功能強大以外,他還有一項特色令我驚訝,就是非常簡單易用,從安裝到設定完所有的測試,除了clone hg檔案庫和設置測試環境以外,我從沒因為Jenkins打過一行指令,全部都可以透過它友善的網頁介面完成,同時它也有內建資料庫,也沒因此設定 MySQL,在Windows下安裝更是容易,一個安裝檔執行完就是安裝完成,如果你希望有工具幫你自動定時測試或是建構,請不要懷疑,Jenkins是 你最佳選擇

部署前的自動化測試

執行自動化測試的時機,除了剛改完程式,還有一個重要的時機,那就是在你把程式部署到伺服器以前,讓你的自動化部署的script先跑過一次自動測 試,確認測試通過了再進行部署,為什麼要這樣做呢? 還記得先前提到的PTT和Dropbox以及一些網站對於authentication的return true慘劇嗎? 為了不讓那種事情發生,或著至少讓機會降低,在deploy前讓自動化測試跑過一次,確保測試的範圍內都是正確的,可以大大降低那種情況發生的機會,除此 之外,也比較不會因為改出bug,自己沒發現,等到使用者來抱怨了才知道問題在哪

測試的幾項重點

自動化測試雖然是一項利器,但是得經過正確的使用才會有好的效果,自動化測試有所謂的覆蓋率,也就是你的程式裡以行為單位,有多少行是在跑測試時有執行過的? 這些工具都可以幫你統計出來,但是切記
不要為了追求高測試覆蓋率,替foo bar寫測試
這只是在浪費時間,如果某段程式已經簡單到沒測試的必要,你寫了也是多餘
除此之外,寫測試事實上也是成本,因此如果時間有限,請
優先針對重要的核心、資料模型、商業邏輯測試
因為就算你測再多無關緊要的程式,最重要的核心出錯了,可能整個系統就完蛋了,所以盡量以重要的程式做為測試的優先考量
優先針對安全性相關、存取權限、身份認證、常見攻擊手法測試
雖然身份認證這種事情算不上是核心,但這關係到你的系統會不會被輕易地攻擊,除此之外,如果你的程式是網站應用程式,SQL Injection、XSS、buffer overflow這類攻擊也會很常發生,因此,你也需要優先自行設計一些攻擊,針對這些常見的問題餵一些資料,雖然這無法保證一定不會犯錯,至少確保不會 發生太低等級的錯誤,因為常見的case都已經有自動測試過了,搭配先前所提到的,deploy前跑過一次測試,如此一來就能將犯錯的機會降低許多
雖然你的程式可能大多都已經有自動化測試在幫你測試,但即使如此,你還是會發現新的bug,如果說,你直接改了bug,就這樣了事,很有可能在下幾次改版bug又回來了,因此
每當你發現你先前沒想到的bug,請加到你的測試中
如此一來,隨著你針對的bug測試case越多,你的程式品質就越高,未發現的bug也會越少,在未來確保這些bug不會再出現

最後

再一次,自動化測試不是萬能的,除此之外也需要正確的運用,如果台灣軟體業界能夠好好運用自動化測試,軟體的品質可以有所提升,開發者也不會因為除錯除到死加班到天亮,雖然寫測試是額外的負擔,但是對於大形專案長期看來是非常值得的投資

Reference:
http://blog.ez2learn.com/2011/10/20/taiwan-software-lacking-of-auto-testing/

No comments: